Correct Aphlict websocket URI construction after PHP8 compatibility changes
[phabricator.git] / src / infrastructure / storage / xsprintf / qsprintf.php
blob7b46b34cd4d27c5918b999709be22bef23d21749
1 <?php
3 /**
4 * Format an SQL query. This function behaves like `sprintf`, except that all
5 * the normal conversions (like "%s") will be properly escaped, and additional
6 * conversions are supported:
8 * %nd, %ns, %nf, %nB
9 * "Nullable" versions of %d, %s, %f and %B. Will produce 'NULL' if the
10 * argument is a strict null.
12 * %=d, %=s, %=f
13 * "Nullable Test" versions of %d, %s and %f. If you pass a value, you
14 * get "= 3"; if you pass null, you get "IS NULL". For instance, this
15 * will work properly if `hatID' is a nullable column and $hat is null.
17 * qsprintf($escaper, 'WHERE hatID %=d', $hat);
19 * %Ld, %Ls, %Lf, %LB
20 * "List" versions of %d, %s, %f and %B. These are appropriate for use in
21 * an "IN" clause. For example:
23 * qsprintf($escaper, 'WHERE hatID IN (%Ld)', $list_of_hats);
25 * %B ("Binary String")
26 * Escapes a string for insertion into a pure binary column, ignoring
27 * tests for characters outside of the basic multilingual plane.
29 * %C, %LC, %LK ("Column", "Key Column")
30 * Escapes a column name or a list of column names. The "%LK" variant
31 * escapes a list of key column specifications which may look like
32 * "column(32)".
34 * %K ("Comment")
35 * Escapes a comment.
37 * %Q, %LA, %LO, %LQ, %LJ ("Query Fragment")
38 * Injects a query fragment from a prior call to qsprintf(). The list
39 * variants join a list of query fragments with AND, OR, comma, or space.
41 * %Z ("Raw Query")
42 * Injects a raw, unescaped query fragment. Dangerous!
44 * %R ("Database and Table Reference")
45 * Behaves like "%T.%T" and prints a full reference to a table including
46 * the database. Accepts a AphrontDatabaseTableRefInterface.
48 * %P ("Password or Secret")
49 * Behaves like "%s", but shows "********" when the query is printed in
50 * logs or traces. Accepts a PhutilOpaqueEnvelope.
52 * %~ ("Substring")
53 * Escapes a substring query for a LIKE (or NOT LIKE) clause. For example:
55 * // Find all rows with $search as a substring of `name`.
56 * qsprintf($escaper, 'WHERE name LIKE %~', $search);
58 * See also %> and %<.
60 * %> ("Prefix")
61 * Escapes a prefix query for a LIKE clause. For example:
63 * // Find all rows where `name` starts with $prefix.
64 * qsprintf($escaper, 'WHERE name LIKE %>', $prefix);
66 * %< ("Suffix")
67 * Escapes a suffix query for a LIKE clause. For example:
69 * // Find all rows where `name` ends with $suffix.
70 * qsprintf($escaper, 'WHERE name LIKE %<', $suffix);
72 * %T ("Table")
73 * Escapes a table name. In most cases, you should use "%R" instead.
75 function qsprintf(PhutilQsprintfInterface $escaper, $pattern /* , ... */) {
76 $args = func_get_args();
77 array_shift($args);
78 return new PhutilQueryString($escaper, $args);
81 function vqsprintf(PhutilQsprintfInterface $escaper, $pattern, array $argv) {
82 array_unshift($argv, $pattern);
83 return new PhutilQueryString($escaper, $argv);
86 /**
87 * @{function:xsprintf} callback for encoding SQL queries. See
88 * @{function:qsprintf}.
90 function xsprintf_query($userdata, &$pattern, &$pos, &$value, &$length) {
91 $type = $pattern[$pos];
93 if (is_array($userdata)) {
94 $escaper = $userdata['escaper'];
95 $unmasked = $userdata['unmasked'];
96 } else {
97 $escaper = $userdata;
98 $unmasked = false;
101 $next = (strlen($pattern) > $pos + 1) ? $pattern[$pos + 1] : null;
102 $nullable = false;
103 $done = false;
105 $prefix = '';
107 if (!($escaper instanceof PhutilQsprintfInterface)) {
108 throw new InvalidArgumentException(pht('Invalid database escaper.'));
111 switch ($type) {
112 case '=': // Nullable test
113 switch ($next) {
114 case 'd':
115 case 'f':
116 case 's':
117 $pattern = substr_replace($pattern, '', $pos, 1);
118 $length = strlen($pattern);
119 $type = 's';
120 if ($value === null) {
121 $value = 'IS NULL';
122 $done = true;
123 } else {
124 $prefix = '= ';
125 $type = $next;
127 break;
128 default:
129 throw new Exception(
130 pht(
131 'Unknown conversion, try %s, %s, or %s.',
132 '%=d',
133 '%=s',
134 '%=f'));
136 break;
138 case 'n': // Nullable...
139 switch ($next) {
140 case 'd': // ...integer.
141 case 'f': // ...float.
142 case 's': // ...string.
143 case 'B': // ...binary string.
144 $pattern = substr_replace($pattern, '', $pos, 1);
145 $length = strlen($pattern);
146 $type = $next;
147 $nullable = true;
148 break;
149 default:
150 throw new XsprintfUnknownConversionException("%n{$next}");
152 break;
154 case 'L': // List of..
155 qsprintf_check_type($value, "L{$next}", $pattern);
156 $pattern = substr_replace($pattern, '', $pos, 1);
157 $length = strlen($pattern);
158 $type = 's';
159 $done = true;
161 switch ($next) {
162 case 'd': // ...integers.
163 $value = implode(', ', array_map('intval', $value));
164 break;
165 case 'f': // ...floats.
166 $value = implode(', ', array_map('floatval', $value));
167 break;
168 case 's': // ...strings.
169 foreach ($value as $k => $v) {
170 $value[$k] = "'".$escaper->escapeUTF8String((string)$v)."'";
172 $value = implode(', ', $value);
173 break;
174 case 'B': // ...binary strings.
175 foreach ($value as $k => $v) {
176 $value[$k] = "'".$escaper->escapeBinaryString((string)$v)."'";
178 $value = implode(', ', $value);
179 break;
180 case 'C': // ...columns.
181 foreach ($value as $k => $v) {
182 $value[$k] = $escaper->escapeColumnName($v);
184 $value = implode(', ', $value);
185 break;
186 case 'K': // ...key columns.
187 // This is like "%LC", but for escaping column lists passed to key
188 // specifications. These should be escaped as "`column`(123)". For
189 // example:
191 // ALTER TABLE `x` ADD KEY `y` (`u`(16), `v`(32));
193 foreach ($value as $k => $v) {
194 $matches = null;
195 if (preg_match('/\((\d+)\)\z/', $v, $matches)) {
196 $v = substr($v, 0, -(strlen($matches[1]) + 2));
197 $prefix_len = '('.((int)$matches[1]).')';
198 } else {
199 $prefix_len = '';
202 $value[$k] = $escaper->escapeColumnName($v).$prefix_len;
205 $value = implode(', ', $value);
206 break;
207 case 'Q':
208 // TODO: Here, and in "%LO", "%LA", and "%LJ", we should eventually
209 // stop accepting strings.
210 foreach ($value as $k => $v) {
211 if (is_string($v)) {
212 continue;
214 $value[$k] = $v->getUnmaskedString();
216 $value = implode(', ', $value);
217 break;
218 case 'O':
219 foreach ($value as $k => $v) {
220 if (is_string($v)) {
221 continue;
223 $value[$k] = $v->getUnmaskedString();
225 if (count($value) == 1) {
226 $value = '('.head($value).')';
227 } else {
228 $value = '(('.implode(') OR (', $value).'))';
230 break;
231 case 'A':
232 foreach ($value as $k => $v) {
233 if (is_string($v)) {
234 continue;
236 $value[$k] = $v->getUnmaskedString();
238 if (count($value) == 1) {
239 $value = '('.head($value).')';
240 } else {
241 $value = '(('.implode(') AND (', $value).'))';
243 break;
244 case 'J':
245 foreach ($value as $k => $v) {
246 if (is_string($v)) {
247 continue;
249 $value[$k] = $v->getUnmaskedString();
251 $value = implode(' ', $value);
252 break;
253 default:
254 throw new XsprintfUnknownConversionException("%L{$next}");
256 break;
259 if (!$done) {
260 qsprintf_check_type($value, $type, $pattern);
261 switch ($type) {
262 case 's': // String
263 if ($nullable && $value === null) {
264 $value = 'NULL';
265 } else {
266 $value = "'".$escaper->escapeUTF8String((string)$value)."'";
268 $type = 's';
269 break;
271 case 'B': // Binary String
272 if ($nullable && $value === null) {
273 $value = 'NULL';
274 } else {
275 $value = "'".$escaper->escapeBinaryString((string)$value)."'";
277 $type = 's';
278 break;
280 case 'Q': // Query Fragment
281 if ($value instanceof PhutilQueryString) {
282 $value = $value->getUnmaskedString();
284 $type = 's';
285 break;
287 case 'Z': // Raw Query Fragment
288 $type = 's';
289 break;
291 case '~': // Like Substring
292 case '>': // Like Prefix
293 case '<': // Like Suffix
294 $value = $escaper->escapeStringForLikeClause($value);
295 switch ($type) {
296 case '~': $value = "'%".$value."%'"; break;
297 case '>': $value = "'".$value."%'"; break;
298 case '<': $value = "'%".$value."'"; break;
300 $type = 's';
301 break;
303 case 'f': // Float
304 if ($nullable && $value === null) {
305 $value = 'NULL';
306 } else {
307 $value = (float)$value;
309 $type = 's';
310 break;
312 case 'd': // Integer
313 if ($nullable && $value === null) {
314 $value = 'NULL';
315 } else {
316 $value = (int)$value;
318 $type = 's';
319 break;
321 case 'T': // Table
322 case 'C': // Column
323 $value = $escaper->escapeColumnName($value);
324 $type = 's';
325 break;
327 case 'K': // Komment
328 $value = $escaper->escapeMultilineComment($value);
329 $type = 's';
330 break;
332 case 'R': // Database + Table Reference
333 $database_name = $value->getAphrontRefDatabaseName();
334 $database_name = $escaper->escapeColumnName($database_name);
336 $table_name = $value->getAphrontRefTableName();
337 $table_name = $escaper->escapeColumnName($table_name);
339 $value = $database_name.'.'.$table_name;
340 $type = 's';
341 break;
343 case 'P': // Password or Secret
344 if ($unmasked) {
345 $value = $value->openEnvelope();
346 $value = "'".$escaper->escapeUTF8String($value)."'";
347 } else {
348 $value = '********';
350 $type = 's';
351 break;
353 default:
354 throw new XsprintfUnknownConversionException($type);
358 if ($prefix) {
359 $value = $prefix.$value;
362 $pattern[$pos] = $type;
365 function qsprintf_check_type($value, $type, $query) {
366 switch ($type) {
367 case 'Ld':
368 case 'Ls':
369 case 'LC':
370 case 'LK':
371 case 'LB':
372 case 'Lf':
373 case 'LQ':
374 case 'LA':
375 case 'LO':
376 case 'LJ':
377 if (!is_array($value)) {
378 throw new AphrontParameterQueryException(
379 $query,
380 pht('Expected array argument for %%%s conversion.', $type));
382 if (empty($value)) {
383 throw new AphrontParameterQueryException(
384 $query,
385 pht('Array for %%%s conversion is empty.', $type));
388 foreach ($value as $scalar) {
389 qsprintf_check_scalar_type($scalar, $type, $query);
391 break;
392 default:
393 qsprintf_check_scalar_type($value, $type, $query);
394 break;
398 function qsprintf_check_scalar_type($value, $type, $query) {
399 switch ($type) {
400 case 'LQ':
401 case 'LA':
402 case 'LO':
403 case 'LJ':
404 // TODO: See T13217. Remove this eventually.
405 if (is_string($value)) {
406 phlog(
407 pht(
408 'UNSAFE: Raw string ("%s") passed to query ("%s") subclause '.
409 'for "%%%s" conversion. Subclause conversions should be passed '.
410 'a list of PhutilQueryString objects.',
411 $value,
412 $query,
413 $type));
414 break;
417 if (!($value instanceof PhutilQueryString)) {
418 throw new AphrontParameterQueryException(
419 $query,
420 pht(
421 'Expected a list of PhutilQueryString objects for %%%s '.
422 'conversion.',
423 $type));
425 break;
427 case 'Q':
428 // TODO: See T13217. Remove this eventually.
429 if (is_string($value)) {
430 phlog(
431 pht(
432 'UNSAFE: Raw string ("%s") passed to query ("%s") for "%%Q" '.
433 'conversion. %%Q should be passed a query string.',
434 $value,
435 $query));
436 break;
439 if (!($value instanceof PhutilQueryString)) {
440 throw new AphrontParameterQueryException(
441 $query,
442 pht('Expected a PhutilQueryString for %%%s conversion.', $type));
444 break;
446 case 'Z':
447 if (!is_string($value)) {
448 throw new AphrontParameterQueryException(
449 $query,
450 pht('Value for "%%Z" conversion should be a raw string.'));
452 break;
454 case 'LC':
455 case 'LK':
456 case 'T':
457 case 'C':
458 if (!is_string($value)) {
459 throw new AphrontParameterQueryException(
460 $query,
461 pht('Expected a string for %%%s conversion.', $type));
463 break;
465 case 'Ld':
466 case 'Lf':
467 case 'd':
468 case 'f':
469 if (!is_null($value) && !is_numeric($value)) {
470 throw new AphrontParameterQueryException(
471 $query,
472 pht('Expected a numeric scalar or null for %%%s conversion.', $type));
474 break;
476 case 'Ls':
477 case 's':
478 case 'LB':
479 case 'B':
480 case '~':
481 case '>':
482 case '<':
483 case 'K':
484 if (!is_null($value) && !is_scalar($value)) {
485 throw new AphrontParameterQueryException(
486 $query,
487 pht('Expected a scalar or null for %%%s conversion.', $type));
489 break;
491 case 'R':
492 if (!($value instanceof AphrontDatabaseTableRefInterface)) {
493 throw new AphrontParameterQueryException(
494 $query,
495 pht(
496 'Parameter to "%s" conversion in "qsprintf(...)" is not an '.
497 'instance of AphrontDatabaseTableRefInterface.',
498 '%R'));
500 break;
502 case 'P':
503 if (!($value instanceof PhutilOpaqueEnvelope)) {
504 throw new AphrontParameterQueryException(
505 $query,
506 pht(
507 'Parameter to "%s" conversion in "qsprintf(...)" is not an '.
508 'instance of PhutilOpaqueEnvelope.',
509 '%P'));
511 break;
513 default:
514 throw new XsprintfUnknownConversionException($type);