MediaWiki master
SQLPlatform.php
Go to the documentation of this file.
1<?php
21
22use InvalidArgumentException;
23use Psr\Log\LoggerInterface;
24use Psr\Log\NullLogger;
25use RuntimeException;
26use Throwable;
27use Wikimedia\Assert\Assert;
38use Wikimedia\Timestamp\ConvertibleTimestamp;
39
47class SQLPlatform implements ISQLPlatform {
49 protected $tableAliases = [];
51 protected $indexAliases = [];
53 protected $currentDomain;
55 protected $schemaVars;
57 protected $quoter;
59 protected $logger;
61 protected $errorLogger;
62
63 public function __construct(
65 ?LoggerInterface $logger = null,
67 $errorLogger = null
68
69 ) {
70 $this->quoter = $quoter;
71 $this->logger = $logger ?? new NullLogger();
72 $this->currentDomain = $currentDomain ?: DatabaseDomain::newUnspecified();
73 $this->errorLogger = $errorLogger ?? static function ( Throwable $e ) {
74 trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
75 };
76 }
77
78 public function bitNot( $field ) {
79 return "(~$field)";
80 }
81
82 public function bitAnd( $fieldLeft, $fieldRight ) {
83 return "($fieldLeft & $fieldRight)";
84 }
85
86 public function bitOr( $fieldLeft, $fieldRight ) {
87 return "($fieldLeft | $fieldRight)";
88 }
89
90 public function addIdentifierQuotes( $s ) {
91 if ( strcspn( $s, "\0\"`'." ) !== strlen( $s ) ) {
92 throw new DBLanguageError(
93 "Identifier must not contain quote, dot or null characters: got '$s'"
94 );
95 }
96 $quoteChar = $this->getIdentifierQuoteChar();
97 return $quoteChar . $s . $quoteChar;
98 }
99
104 protected function getIdentifierQuoteChar() {
105 return '"';
106 }
107
111 public function buildGreatest( $fields, $values ) {
112 return $this->buildSuperlative( 'GREATEST', $fields, $values );
113 }
114
118 public function buildLeast( $fields, $values ) {
119 return $this->buildSuperlative( 'LEAST', $fields, $values );
120 }
121
136 protected function buildSuperlative( $sqlfunc, $fields, $values ) {
137 $fields = is_array( $fields ) ? $fields : [ $fields ];
138 $values = is_array( $values ) ? $values : [ $values ];
139
140 $encValues = [];
141 foreach ( $fields as $alias => $field ) {
142 if ( is_int( $alias ) ) {
143 $encValues[] = $this->addIdentifierQuotes( $field );
144 } else {
145 $encValues[] = $field; // expression
146 }
147 }
148 foreach ( $values as $value ) {
149 if ( is_int( $value ) || is_float( $value ) ) {
150 $encValues[] = $value;
151 } elseif ( is_string( $value ) ) {
152 $encValues[] = $this->quoter->addQuotes( $value );
153 } elseif ( $value === null ) {
154 throw new DBLanguageError( 'Null value in superlative' );
155 } else {
156 throw new DBLanguageError( 'Unexpected value type in superlative' );
157 }
158 }
159
160 return $sqlfunc . '(' . implode( ',', $encValues ) . ')';
161 }
162
163 public function buildComparison( string $op, array $conds ): string {
164 if ( !in_array( $op, [ '>', '>=', '<', '<=' ] ) ) {
165 throw new InvalidArgumentException( "Comparison operator must be one of '>', '>=', '<', '<='" );
166 }
167 if ( count( $conds ) === 0 ) {
168 throw new InvalidArgumentException( "Empty input" );
169 }
170
171 // Construct a condition string by starting with the least significant part of the index, and
172 // adding more significant parts progressively to the left of the string.
173 //
174 // For example, given $conds = [ 'a' => 4, 'b' => 7, 'c' => 1 ], this will generate a condition
175 // like this:
176 //
177 // WHERE a > 4
178 // OR (a = 4 AND (b > 7
179 // OR (b = 7 AND (c > 1))))
180 //
181 // …which is equivalent to the following, which might be easier to understand:
182 //
183 // WHERE a > 4
184 // OR a = 4 AND b > 7
185 // OR a = 4 AND b = 7 AND c > 1
186 //
187 // …and also equivalent to the following, using tuple comparison syntax, which is most intuitive
188 // but apparently performs worse:
189 //
190 // WHERE (a, b, c) > (4, 7, 1)
191
192 $sql = '';
193 foreach ( array_reverse( $conds ) as $field => $value ) {
194 if ( is_int( $field ) ) {
195 throw new InvalidArgumentException(
196 'Non-associative array passed to buildComparison() (typo?)'
197 );
198 }
199 $encValue = $this->quoter->addQuotes( $value );
200 if ( $sql === '' ) {
201 $sql = "$field $op $encValue";
202 // Change '>=' to '>' etc. for remaining fields, as the equality is handled separately
203 $op = rtrim( $op, '=' );
204 } else {
205 $sql = "$field $op $encValue OR ($field = $encValue AND ($sql))";
206 }
207 }
208 return $sql;
209 }
210
211 public function makeList( array $a, $mode = self::LIST_COMMA ) {
212 $first = true;
213 $list = '';
214 $keyWarning = null;
215
216 foreach ( $a as $field => $value ) {
217 if ( $first ) {
218 $first = false;
219 } else {
220 if ( $mode == self::LIST_AND ) {
221 $list .= ' AND ';
222 } elseif ( $mode == self::LIST_OR ) {
223 $list .= ' OR ';
224 } else {
225 $list .= ',';
226 }
227 }
228
229 if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
230 if ( $value instanceof IExpression ) {
231 $list .= "(" . $value->toSql( $this->quoter ) . ")";
232 } elseif ( is_array( $value ) ) {
233 throw new InvalidArgumentException( __METHOD__ . ": unexpected array value without key" );
234 } elseif ( $value instanceof RawSQLValue ) {
235 throw new InvalidArgumentException( __METHOD__ . ": unexpected raw value without key" );
236 } else {
237 $list .= "($value)";
238 }
239 } elseif ( $value instanceof IExpression ) {
240 if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
241 throw new InvalidArgumentException( __METHOD__ . ": unexpected key $field for IExpression value" );
242 } else {
243 throw new InvalidArgumentException( __METHOD__ . ": unexpected IExpression outside WHERE clause" );
244 }
245 } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
246 $list .= "$value";
247 } elseif (
248 ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
249 ) {
250 // Remove null from array to be handled separately if found
251 $includeNull = false;
252 foreach ( array_keys( $value, null, true ) as $nullKey ) {
253 $includeNull = true;
254 unset( $value[$nullKey] );
255 }
256 if ( count( $value ) == 0 && !$includeNull ) {
257 throw new InvalidArgumentException(
258 __METHOD__ . ": empty input for field $field" );
259 } elseif ( count( $value ) == 0 ) {
260 // only check if $field is null
261 $list .= "$field IS NULL";
262 } else {
263 // IN clause contains at least one valid element
264 if ( $includeNull ) {
265 // Group subconditions to ensure correct precedence
266 $list .= '(';
267 }
268 if ( count( $value ) == 1 ) {
269 // Special-case single values, as IN isn't terribly efficient
270 // (but call makeList() so that warnings are emitted if needed)
271 $list .= $field . " = " . $this->makeList( $value );
272 } else {
273 $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
274 }
275 // if null present in array, append IS NULL
276 if ( $includeNull ) {
277 $list .= " OR $field IS NULL)";
278 }
279 }
280 } elseif ( is_array( $value ) ) {
281 throw new InvalidArgumentException( __METHOD__ . ": unexpected nested array" );
282 } elseif ( $value === null ) {
283 if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
284 $list .= "$field IS ";
285 } elseif ( $mode == self::LIST_SET ) {
286 $list .= "$field = ";
287 } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
288 $keyWarning ??= [
289 __METHOD__ . ": array key {key} in list of values ignored",
290 [ 'key' => $field, 'exception' => new RuntimeException() ]
291 ];
292 } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
293 $keyWarning ??= [
294 __METHOD__ . ": array key {key} in list of fields ignored",
295 [ 'key' => $field, 'exception' => new RuntimeException() ]
296 ];
297 }
298 $list .= 'NULL';
299 } else {
300 if (
301 $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
302 ) {
303 $list .= "$field = ";
304 } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
305 $keyWarning ??= [
306 __METHOD__ . ": array key {key} in list of values ignored",
307 [ 'key' => $field, 'exception' => new RuntimeException() ]
308 ];
309 } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
310 $keyWarning ??= [
311 __METHOD__ . ": array key {key} in list of fields ignored",
312 [ 'key' => $field, 'exception' => new RuntimeException() ]
313 ];
314 }
315 $list .= $mode == self::LIST_NAMES ? $value : $this->quoter->addQuotes( $value );
316 }
317 }
318
319 if ( $keyWarning ) {
320 // Only log one warning about this per function call, to reduce log spam when a dynamically
321 // generated associative array is passed
322 $this->logger->warning( ...$keyWarning );
323 }
324
325 return $list;
326 }
327
328 public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
329 $conds = [];
330 foreach ( $data as $base => $sub ) {
331 if ( count( $sub ) ) {
332 $conds[] = $this->makeList(
333 [ $baseKey => $base, $subKey => array_map( 'strval', array_keys( $sub ) ) ],
334 self::LIST_AND
335 );
336 }
337 }
338
339 if ( !$conds ) {
340 throw new InvalidArgumentException( "Data for $baseKey and $subKey must be non-empty" );
341 }
342
343 return $this->makeList( $conds, self::LIST_OR );
344 }
345
346 public function factorConds( $condsArray ) {
347 if ( count( $condsArray ) === 0 ) {
348 throw new InvalidArgumentException(
349 __METHOD__ . ": empty condition array" );
350 }
351 $condsByFieldSet = [];
352 foreach ( $condsArray as $conds ) {
353 if ( !count( $conds ) ) {
354 throw new InvalidArgumentException(
355 __METHOD__ . ": empty condition subarray" );
356 }
357 $fieldKey = implode( ',', array_keys( $conds ) );
358 $condsByFieldSet[$fieldKey][] = $conds;
359 }
360 $result = '';
361 foreach ( $condsByFieldSet as $conds ) {
362 if ( $result !== '' ) {
363 $result .= ' OR ';
364 }
365 $result .= $this->factorCondsWithCommonFields( $conds );
366 }
367 return $result;
368 }
369
377 private function factorCondsWithCommonFields( $condsArray ) {
378 $first = $condsArray[array_key_first( $condsArray )];
379 if ( count( $first ) === 1 ) {
380 // IN clause
381 $field = array_key_first( $first );
382 $values = [];
383 foreach ( $condsArray as $conds ) {
384 $values[] = $conds[$field];
385 }
386 return $this->makeList( [ $field => $values ], self::LIST_AND );
387 }
388
389 $field1 = array_key_first( $first );
390 $nullExpressions = [];
391 $expressionsByField1 = [];
392 foreach ( $condsArray as $conds ) {
393 $value1 = $conds[$field1];
394 unset( $conds[$field1] );
395 if ( $value1 === null ) {
396 $nullExpressions[] = $conds;
397 } else {
398 $expressionsByField1[$value1][] = $conds;
399 }
400
401 }
402 $wrap = false;
403 $result = '';
404 foreach ( $expressionsByField1 as $value1 => $expressions ) {
405 if ( $result !== '' ) {
406 $result .= ' OR ';
407 $wrap = true;
408 }
409 $factored = $this->factorCondsWithCommonFields( $expressions );
410 $result .= "($field1 = " . $this->quoter->addQuotes( $value1 ) .
411 " AND $factored)";
412 }
413 if ( count( $nullExpressions ) ) {
414 $factored = $this->factorCondsWithCommonFields( $nullExpressions );
415 if ( $result !== '' ) {
416 $result .= ' OR ';
417 $wrap = true;
418 }
419 $result .= "($field1 IS NULL AND $factored)";
420 }
421 if ( $wrap ) {
422 return "($result)";
423 } else {
424 return $result;
425 }
426 }
427
432 public function buildConcat( $stringList ) {
433 return 'CONCAT(' . implode( ',', $stringList ) . ')';
434 }
435
436 public function limitResult( $sql, $limit, $offset = false ) {
437 if ( !is_numeric( $limit ) ) {
438 throw new DBLanguageError(
439 "Invalid non-numeric limit passed to " . __METHOD__
440 );
441 }
442 // This version works in MySQL and SQLite. It will very likely need to be
443 // overridden for most other RDBMS subclasses.
444 return "$sql LIMIT "
445 . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
446 . "{$limit} ";
447 }
448
455 public function escapeLikeInternal( $s, $escapeChar = '`' ) {
456 return str_replace(
457 [ $escapeChar, '%', '_' ],
458 [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
459 $s
460 );
461 }
462
463 public function buildLike( $param, ...$params ) {
464 if ( is_array( $param ) ) {
465 $params = $param;
466 $param = array_shift( $params );
467 }
468 $likeValue = new LikeValue( $param, ...$params );
469
470 return ' LIKE ' . $likeValue->toSql( $this->quoter );
471 }
472
473 public function anyChar() {
474 return new LikeMatch( '_' );
475 }
476
477 public function anyString() {
478 return new LikeMatch( '%' );
479 }
480
485 public function unionSupportsOrderAndLimit() {
486 return true; // True for almost every DB supported
487 }
488
489 public function unionQueries( $sqls, $all, $options = [] ) {
490 $glue = $all ? ') UNION ALL (' : ') UNION (';
491
492 $sql = '(' . implode( $glue, $sqls ) . ')';
493 if ( !$this->unionSupportsOrderAndLimit() ) {
494 return $sql;
495 }
496 $sql .= $this->makeOrderBy( $options );
497 $limit = $options['LIMIT'] ?? null;
498 $offset = $options['OFFSET'] ?? false;
499 if ( $limit !== null ) {
500 $sql = $this->limitResult( $sql, $limit, $offset );
501 }
502
503 return $sql;
504 }
505
506 public function conditional( $cond, $caseTrueExpression, $caseFalseExpression ) {
507 if ( is_array( $cond ) ) {
508 $cond = $this->makeList( $cond, self::LIST_AND );
509 }
510 if ( $cond instanceof IExpression ) {
511 $cond = $cond->toSql( $this->quoter );
512 }
513
514 return "(CASE WHEN $cond THEN $caseTrueExpression ELSE $caseFalseExpression END)";
515 }
516
517 public function strreplace( $orig, $old, $new ) {
518 return "REPLACE({$orig}, {$old}, {$new})";
519 }
520
521 public function timestamp( $ts = 0 ) {
522 $t = new ConvertibleTimestamp( $ts );
523 // Let errors bubble up to avoid putting garbage in the DB
524 return $t->getTimestamp( TS_MW );
525 }
526
527 public function timestampOrNull( $ts = null ) {
528 if ( $ts === null ) {
529 return null;
530 } else {
531 return $this->timestamp( $ts );
532 }
533 }
534
535 public function getInfinity() {
536 return 'infinity';
537 }
538
539 public function encodeExpiry( $expiry ) {
540 return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
541 ? $this->getInfinity()
542 : $this->timestamp( $expiry );
543 }
544
545 public function decodeExpiry( $expiry, $format = TS_MW ) {
546 if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
547 return 'infinity';
548 }
549
550 return ConvertibleTimestamp::convert( $format, $expiry );
551 }
552
557 public function buildSubstring( $input, $startPosition, $length = null ) {
558 $this->assertBuildSubstringParams( $startPosition, $length );
559 $functionBody = "$input FROM $startPosition";
560 if ( $length !== null ) {
561 $functionBody .= " FOR $length";
562 }
563 return 'SUBSTRING(' . $functionBody . ')';
564 }
565
578 protected function assertBuildSubstringParams( $startPosition, $length ) {
579 if ( $startPosition === 0 ) {
580 // The DBMSs we support use 1-based indexing here.
581 throw new InvalidArgumentException( 'Use 1 as $startPosition for the beginning of the string' );
582 }
583 if ( !is_int( $startPosition ) || $startPosition < 0 ) {
584 throw new InvalidArgumentException(
585 '$startPosition must be a positive integer'
586 );
587 }
588 if ( !( ( is_int( $length ) && $length >= 0 ) || $length === null ) ) {
589 throw new InvalidArgumentException(
590 '$length must be null or an integer greater than or equal to 0'
591 );
592 }
593 }
594
595 public function buildStringCast( $field ) {
596 // In theory this should work for any standards-compliant
597 // SQL implementation, although it may not be the best way to do it.
598 return "CAST( $field AS CHARACTER )";
599 }
600
601 public function buildIntegerCast( $field ) {
602 return 'CAST( ' . $field . ' AS INTEGER )';
603 }
604
605 public function implicitOrderby() {
606 return true;
607 }
608
617 public function indexName( $index ) {
618 return $this->indexAliases[$index] ?? $index;
619 }
620
621 public function setTableAliases( array $aliases ) {
622 $this->tableAliases = $aliases;
623 }
624
625 public function setIndexAliases( array $aliases ) {
626 $this->indexAliases = $aliases;
627 }
628
632 public function getTableAliases() {
633 return $this->tableAliases;
634 }
635
636 public function setPrefix( $prefix ) {
637 $this->currentDomain = new DatabaseDomain(
638 $this->currentDomain->getDatabase(),
639 $this->currentDomain->getSchema(),
640 $prefix
641 );
642 }
643
644 public function setCurrentDomain( DatabaseDomain $currentDomain ) {
645 $this->currentDomain = $currentDomain;
646 }
647
652 public function getCurrentDomain() {
653 return $this->currentDomain;
654 }
655
656 public function selectSQLText(
657 $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
658 ) {
659 if ( !is_array( $tables ) ) {
660 if ( $tables === '' || $tables === null || $tables === false ) {
661 $tables = [];
662 } elseif ( is_string( $tables ) ) {
663 $tables = [ $tables ];
664 } else {
665 throw new DBLanguageError( __METHOD__ . ' called with incorrect table parameter' );
666 }
667 }
668
669 if ( is_array( $vars ) ) {
670 $fields = implode( ',', $this->fieldNamesWithAlias( $vars ) );
671 } else {
672 $fields = $vars;
673 }
674
675 $options = (array)$options;
676
677 $useIndexByTable = $options['USE INDEX'] ?? [];
678 if ( !is_array( $useIndexByTable ) ) {
679 if ( count( $tables ) <= 1 ) {
680 $useIndexByTable = [ reset( $tables ) => $useIndexByTable ];
681 } else {
682 $e = new DBLanguageError( __METHOD__ . " got ambiguous USE INDEX ($fname)" );
683 ( $this->errorLogger )( $e );
684 }
685 }
686
687 $ignoreIndexByTable = $options['IGNORE INDEX'] ?? [];
688 if ( !is_array( $ignoreIndexByTable ) ) {
689 if ( count( $tables ) <= 1 ) {
690 $ignoreIndexByTable = [ reset( $tables ) => $ignoreIndexByTable ];
691 } else {
692 $e = new DBLanguageError( __METHOD__ . " got ambiguous IGNORE INDEX ($fname)" );
693 ( $this->errorLogger )( $e );
694 }
695 }
696
697 if (
698 $this->selectOptionsIncludeLocking( $options ) &&
699 $this->selectFieldsOrOptionsAggregate( $vars, $options )
700 ) {
701 // Some DB types (e.g. postgres) disallow FOR UPDATE with aggregate
702 // functions. Discourage use of such queries to encourage compatibility.
703 $this->logger->warning(
704 __METHOD__ . ": aggregation used with a locking SELECT ($fname)"
705 );
706 }
707
708 if ( count( $tables ) ) {
709 $from = ' FROM ' . $this->tableNamesWithIndexClauseOrJOIN(
710 $tables,
711 $useIndexByTable,
712 $ignoreIndexByTable,
713 $join_conds
714 );
715 } else {
716 $from = '';
717 }
718
719 [ $startOpts, $preLimitTail, $postLimitTail ] = $this->makeSelectOptions( $options );
720
721 if ( is_array( $conds ) ) {
722 $where = $this->makeList( $conds, self::LIST_AND );
723 } elseif ( $conds instanceof IExpression ) {
724 $where = $conds->toSql( $this->quoter );
725 } elseif ( $conds === null || $conds === false ) {
726 $where = '';
727 $this->logger->warning(
728 __METHOD__
729 . ' called from '
730 . $fname
731 . ' with incorrect parameters: $conds must be a string or an array',
732 [ 'db_log_category' => 'sql' ]
733 );
734 } elseif ( is_string( $conds ) ) {
735 $where = $conds;
736 } else {
737 throw new DBLanguageError( __METHOD__ . ' called with incorrect parameters' );
738 }
739
740 // Keep historical extra spaces after FROM to avoid testing failures
741 if ( $where === '' || $where === '*' ) {
742 $sql = "SELECT $startOpts $fields $from $preLimitTail";
743 } else {
744 $sql = "SELECT $startOpts $fields $from WHERE $where $preLimitTail";
745 }
746
747 if ( isset( $options['LIMIT'] ) ) {
748 $sql = $this->limitResult( $sql, $options['LIMIT'], $options['OFFSET'] ?? false );
749 }
750 $sql = "$sql $postLimitTail";
751
752 if ( isset( $options['EXPLAIN'] ) ) {
753 $sql = 'EXPLAIN ' . $sql;
754 }
755
756 if (
757 $fname === static::CALLER_UNKNOWN ||
758 str_starts_with( $fname, 'Wikimedia\\Rdbms\\' ) ||
759 $fname === '{closure}'
760 ) {
761 $exception = new RuntimeException();
762
763 // Try to figure out and report the real caller
764 $caller = '';
765 foreach ( $exception->getTrace() as $call ) {
766 if ( str_ends_with( $call['file'] ?? '', 'Test.php' ) ) {
767 // Don't warn when called directly by test code, adding callers there is pointless
768 break;
769 } elseif ( str_starts_with( $call['class'] ?? '', 'Wikimedia\\Rdbms\\' ) ) {
770 // Keep looking for the caller of a rdbms method
771 } elseif ( str_ends_with( $call['class'] ?? '', 'SelectQueryBuilder' ) ) {
772 // Keep looking for the caller of any custom SelectQueryBuilder
773 } else {
774 // Warn about the external caller we found
775 $caller = implode( '::', array_filter( [ $call['class'] ?? null, $call['function'] ] ) );
776 break;
777 }
778 }
779
780 if ( $fname === '{closure}' ) {
781 // Someone did ->caller( __METHOD__ ) in a local function, e.g. in a callback to
782 // getWithSetCallback(), MWCallableUpdate or doAtomicSection(). That's not very helpful.
783 // Provide a more specific message. The caller has to be provided like this:
784 // $method = __METHOD__;
785 // function ( ... ) use ( $method ) { ... }
786 $warning = "SQL query with incorrect caller (__METHOD__ used inside a closure: {caller}): {sql}";
787 } else {
788 $warning = "SQL query did not specify the caller (guessed caller: {caller}): {sql}";
789 }
790
791 $this->logger->warning(
792 $warning,
793 [ 'sql' => $sql, 'caller' => $caller, 'exception' => $exception ]
794 );
795 }
796
797 return $sql;
798 }
799
804 private function selectOptionsIncludeLocking( $options ) {
805 $options = (array)$options;
806 foreach ( [ 'FOR UPDATE', 'LOCK IN SHARE MODE' ] as $lock ) {
807 if ( in_array( $lock, $options, true ) ) {
808 return true;
809 }
810 }
811
812 return false;
813 }
814
820 private function selectFieldsOrOptionsAggregate( $fields, $options ) {
821 foreach ( (array)$options as $key => $value ) {
822 if ( is_string( $key ) ) {
823 if ( preg_match( '/^(?:GROUP BY|HAVING)$/i', $key ) ) {
824 return true;
825 }
826 } elseif ( is_string( $value ) ) {
827 if ( preg_match( '/^(?:DISTINCT|DISTINCTROW)$/i', $value ) ) {
828 return true;
829 }
830 }
831 }
832
833 $regex = '/^(?:COUNT|MIN|MAX|SUM|GROUP_CONCAT|LISTAGG|ARRAY_AGG)\s*\\(/i';
834 foreach ( (array)$fields as $field ) {
835 if ( is_string( $field ) && preg_match( $regex, $field ) ) {
836 return true;
837 }
838 }
839
840 return false;
841 }
842
849 protected function fieldNamesWithAlias( $fields ) {
850 $retval = [];
851 foreach ( $fields as $alias => $field ) {
852 if ( is_numeric( $alias ) ) {
853 $alias = $field;
854 }
855 $retval[] = $this->fieldNameWithAlias( $field, $alias );
856 }
857
858 return $retval;
859 }
860
870 public function fieldNameWithAlias( $name, $alias = false ) {
871 if ( !$alias || (string)$alias === (string)$name ) {
872 return $name;
873 } else {
874 return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
875 }
876 }
877
889 $tables,
890 $use_index = [],
891 $ignore_index = [],
892 $join_conds = []
893 ) {
894 $ret = [];
895 $retJOIN = [];
896 $use_index = (array)$use_index;
897 $ignore_index = (array)$ignore_index;
898 $join_conds = (array)$join_conds;
899
900 foreach ( $tables as $alias => $table ) {
901 if ( !is_string( $alias ) ) {
902 // No alias? Set it equal to the table name
903 $alias = $table;
904 }
905
906 if ( is_array( $table ) ) {
907 // A parenthesized group
908 if ( count( $table ) > 1 ) {
909 $joinedTable = '(' .
910 $this->tableNamesWithIndexClauseOrJOIN(
911 $table, $use_index, $ignore_index, $join_conds ) . ')';
912 } else {
913 // Degenerate case
914 $innerTable = reset( $table );
915 $innerAlias = key( $table );
916 $joinedTable = $this->tableNameWithAlias(
917 $innerTable,
918 is_string( $innerAlias ) ? $innerAlias : $innerTable
919 );
920 }
921 } else {
922 $joinedTable = $this->tableNameWithAlias( $table, $alias );
923 }
924
925 // Is there a JOIN clause for this table?
926 if ( isset( $join_conds[$alias] ) ) {
927 Assert::parameterType( 'array', $join_conds[$alias], "join_conds[$alias]" );
928 [ $joinType, $conds ] = $join_conds[$alias];
929 $tableClause = $this->normalizeJoinType( $joinType );
930 $tableClause .= ' ' . $joinedTable;
931 if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
932 $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
933 if ( $use != '' ) {
934 $tableClause .= ' ' . $use;
935 }
936 }
937 if ( isset( $ignore_index[$alias] ) ) { // has IGNORE INDEX?
938 $ignore = $this->ignoreIndexClause(
939 implode( ',', (array)$ignore_index[$alias] ) );
940 if ( $ignore != '' ) {
941 $tableClause .= ' ' . $ignore;
942 }
943 }
944 $on = $this->makeList( (array)$conds, self::LIST_AND );
945 if ( $on != '' ) {
946 $tableClause .= ' ON (' . $on . ')';
947 }
948
949 $retJOIN[] = $tableClause;
950 } elseif ( isset( $use_index[$alias] ) ) {
951 // Is there an INDEX clause for this table?
952 $tableClause = $joinedTable;
953 $tableClause .= ' ' . $this->useIndexClause(
954 implode( ',', (array)$use_index[$alias] )
955 );
956
957 $ret[] = $tableClause;
958 } elseif ( isset( $ignore_index[$alias] ) ) {
959 // Is there an INDEX clause for this table?
960 $tableClause = $joinedTable;
961 $tableClause .= ' ' . $this->ignoreIndexClause(
962 implode( ',', (array)$ignore_index[$alias] )
963 );
964
965 $ret[] = $tableClause;
966 } else {
967 $tableClause = $joinedTable;
968
969 $ret[] = $tableClause;
970 }
971 }
972
973 // We can't separate explicit JOIN clauses with ',', use ' ' for those
974 $implicitJoins = implode( ',', $ret );
975 $explicitJoins = implode( ' ', $retJOIN );
976
977 // Compile our final table clause
978 return implode( ' ', [ $implicitJoins, $explicitJoins ] );
979 }
980
989 protected function normalizeJoinType( string $joinType ) {
990 switch ( strtoupper( $joinType ) ) {
991 case 'JOIN':
992 case 'INNER JOIN':
993 return 'JOIN';
994
995 case 'LEFT JOIN':
996 return 'LEFT JOIN';
997
998 case 'STRAIGHT_JOIN':
999 case 'STRAIGHT JOIN':
1000 // MySQL only
1001 return 'JOIN';
1002
1003 default:
1004 return $joinType;
1005 }
1006 }
1007
1019 protected function tableNameWithAlias( $table, $alias = false ) {
1020 if ( is_string( $table ) ) {
1021 $quotedTable = $this->tableName( $table );
1022 } elseif ( $table instanceof Subquery ) {
1023 $quotedTable = (string)$table;
1024 } else {
1025 throw new InvalidArgumentException( "Table must be a string or Subquery" );
1026 }
1027
1028 if ( $alias === false ) {
1029 if ( $table instanceof Subquery ) {
1030 throw new InvalidArgumentException( "Subquery table missing alias" );
1031 }
1032 $quotedTableWithAnyAlias = $quotedTable;
1033 } elseif (
1034 $alias === $table &&
1035 (
1036 str_contains( $alias, '.' ) ||
1037 $this->tableName( $alias, 'raw' ) === $table
1038 )
1039 ) {
1040 $quotedTableWithAnyAlias = $quotedTable;
1041 } else {
1042 $quotedTableWithAnyAlias = $quotedTable . ' ' . $this->addIdentifierQuotes( $alias );
1043 }
1044
1045 return $quotedTableWithAnyAlias;
1046 }
1047
1048 public function tableName( string $name, $format = 'quoted' ) {
1049 $prefix = $this->currentDomain->getTablePrefix();
1050
1051 // Warn about table names that look qualified
1052 if (
1053 (
1054 str_contains( $name, '.' ) &&
1055 !preg_match( '/^information_schema\.[a-z_0-9]+$/', $name )
1056 ) ||
1057 ( $prefix !== '' && str_starts_with( $name, $prefix ) )
1058 ) {
1059 $this->logger->warning(
1060 __METHOD__ . ' called with qualified table ' . $name,
1061 [ 'db_log_category' => 'sql' ]
1062 );
1063 }
1064
1065 // Extract necessary database, schema, table identifiers and quote them as needed
1066 $formattedComponents = [];
1067 foreach ( $this->qualifiedTableComponents( $name ) as $component ) {
1068 if ( $format === 'quoted' ) {
1069 $formattedComponents[] = $this->addIdentifierQuotes( $component );
1070 } else {
1071 $formattedComponents[] = $component;
1072 }
1073 }
1074
1075 return implode( '.', $formattedComponents );
1076 }
1077
1100 public function qualifiedTableComponents( $name ) {
1101 $identifiers = $this->extractTableNameComponents( $name );
1102 if ( count( $identifiers ) > 3 ) {
1103 throw new DBLanguageError( "Too many components in table name '$name'" );
1104 }
1105 // Table alias config and prefixes only apply to unquoted single-identifier names
1106 if ( count( $identifiers ) == 1 && !$this->isQuotedIdentifier( $identifiers[0] ) ) {
1107 [ $table ] = $identifiers;
1108 if ( isset( $this->tableAliases[$table] ) ) {
1109 // This is an "alias" table that uses a different db/schema/prefix scheme
1110 $database = $this->tableAliases[$table]['dbname'];
1111 $schema = is_string( $this->tableAliases[$table]['schema'] )
1112 ? $this->tableAliases[$table]['schema']
1113 : $this->relationSchemaQualifier();
1114 $prefix = is_string( $this->tableAliases[$table]['prefix'] )
1115 ? $this->tableAliases[$table]['prefix']
1116 : $this->currentDomain->getTablePrefix();
1117 } else {
1118 // Use the current database domain to resolve the schema and prefix
1119 $database = '';
1120 $schema = $this->relationSchemaQualifier();
1121 $prefix = $this->currentDomain->getTablePrefix();
1122 }
1123 $qualifierIdentifiers = [ $database, $schema ];
1124 $tableIdentifier = $prefix . $table;
1125 } else {
1126 $qualifierIdentifiers = array_slice( $identifiers, 0, -1 );
1127 $tableIdentifier = end( $identifiers );
1128 }
1129
1130 $components = [];
1131 foreach ( $qualifierIdentifiers as $identifier ) {
1132 if ( $identifier !== null && $identifier !== '' ) {
1133 $components[] = $this->isQuotedIdentifier( $identifier )
1134 ? substr( $identifier, 1, -1 )
1135 : $identifier;
1136 }
1137 }
1138 $components[] = $this->isQuotedIdentifier( $tableIdentifier )
1139 ? substr( $tableIdentifier, 1, -1 )
1140 : $tableIdentifier;
1141
1142 return $components;
1143 }
1144
1151 public function extractTableNameComponents( string $name ) {
1152 $quoteChar = $this->getIdentifierQuoteChar();
1153 $components = [];
1154 foreach ( explode( '.', $name ) as $component ) {
1155 if ( $this->isQuotedIdentifier( $component ) ) {
1156 $unquotedComponent = substr( $component, 1, -1 );
1157 } else {
1158 $unquotedComponent = $component;
1159 }
1160 if ( str_contains( $unquotedComponent, $quoteChar ) ) {
1161 throw new DBLanguageError(
1162 'Table name component contains unexpected quote or dot character' );
1163 }
1164 $components[] = $component;
1165 }
1166 return $components;
1167 }
1168
1194 public function getDatabaseAndTableIdentifier( string $table ) {
1195 $components = $this->qualifiedTableComponents( $table );
1196 switch ( count( $components ) ) {
1197 case 1:
1198 return [ $this->currentDomain->getDatabase(), $components[0] ];
1199 case 2:
1200 return $components;
1201 default:
1202 throw new DBLanguageError( 'Too many table components' );
1203 }
1204 }
1205
1210 protected function relationSchemaQualifier() {
1211 return $this->currentDomain->getSchema();
1212 }
1213
1214 public function tableNamesN( ...$tables ) {
1215 $retVal = [];
1216
1217 foreach ( $tables as $name ) {
1218 $retVal[] = $this->tableName( $name );
1219 }
1220
1221 return $retVal;
1222 }
1223
1233 public function isQuotedIdentifier( $name ) {
1234 $quoteChar = $this->getIdentifierQuoteChar();
1235 return strlen( $name ) > 1 && $name[0] === $quoteChar && $name[-1] === $quoteChar;
1236 }
1237
1251 public function useIndexClause( $index ) {
1252 return '';
1253 }
1254
1263 public function ignoreIndexClause( $index ) {
1264 return '';
1265 }
1266
1278 protected function makeSelectOptions( array $options ) {
1279 $preLimitTail = $postLimitTail = '';
1280 $startOpts = '';
1281
1282 $noKeyOptions = [];
1283
1284 foreach ( $options as $key => $option ) {
1285 if ( is_numeric( $key ) ) {
1286 $noKeyOptions[$option] = true;
1287 }
1288 }
1289
1290 $preLimitTail .= $this->makeGroupByWithHaving( $options );
1291
1292 $preLimitTail .= $this->makeOrderBy( $options );
1293
1294 if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
1295 $postLimitTail .= ' FOR UPDATE';
1296 }
1297
1298 if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
1299 $postLimitTail .= ' LOCK IN SHARE MODE';
1300 }
1301
1302 if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
1303 $startOpts .= 'DISTINCT';
1304 }
1305
1306 # Various MySQL extensions
1307 if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
1308 $startOpts .= ' /*! STRAIGHT_JOIN */';
1309 }
1310
1311 if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
1312 $startOpts .= ' SQL_BIG_RESULT';
1313 }
1314
1315 if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
1316 $startOpts .= ' SQL_BUFFER_RESULT';
1317 }
1318
1319 if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
1320 $startOpts .= ' SQL_SMALL_RESULT';
1321 }
1322
1323 if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
1324 $startOpts .= ' SQL_CALC_FOUND_ROWS';
1325 }
1326
1327 return [ $startOpts, $preLimitTail, $postLimitTail ];
1328 }
1329
1338 protected function makeGroupByWithHaving( $options ) {
1339 $sql = '';
1340 if ( isset( $options['GROUP BY'] ) ) {
1341 $gb = is_array( $options['GROUP BY'] )
1342 ? implode( ',', $options['GROUP BY'] )
1343 : $options['GROUP BY'];
1344 $sql .= ' GROUP BY ' . $gb;
1345 }
1346 if ( isset( $options['HAVING'] ) ) {
1347 $having = is_array( $options['HAVING'] )
1348 ? $this->makeList( $options['HAVING'], self::LIST_AND )
1349 : $options['HAVING'];
1350 $sql .= ' HAVING ' . $having;
1351 }
1352
1353 return $sql;
1354 }
1355
1364 protected function makeOrderBy( $options ) {
1365 if ( isset( $options['ORDER BY'] ) ) {
1366 $ob = is_array( $options['ORDER BY'] )
1367 ? implode( ',', $options['ORDER BY'] )
1368 : $options['ORDER BY'];
1369
1370 return ' ORDER BY ' . $ob;
1371 }
1372
1373 return '';
1374 }
1375
1376 public function buildGroupConcatField(
1377 $delim, $tables, $field, $conds = '', $join_conds = []
1378 ) {
1379 $fld = "GROUP_CONCAT($field SEPARATOR " . $this->quoter->addQuotes( $delim ) . ')';
1380
1381 return '(' . $this->selectSQLText( $tables, $fld, $conds, static::CALLER_SUBQUERY, [], $join_conds ) . ')';
1382 }
1383
1384 public function buildSelectSubquery(
1385 $tables, $vars, $conds = '', $fname = __METHOD__,
1386 $options = [], $join_conds = []
1387 ) {
1388 return new Subquery(
1389 $this->selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds )
1390 );
1391 }
1392
1393 public function insertSqlText( $table, array $rows ) {
1394 $encTable = $this->tableName( $table );
1395 [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1396
1397 return [
1398 "INSERT INTO $encTable ($sqlColumns) VALUES $sqlTuples",
1399 "INSERT INTO $encTable ($sqlColumns) VALUES '?'"
1400 ];
1401 }
1402
1415 public function makeInsertLists( array $rows, $aliasPrefix = '', array $typeByColumn = [] ) {
1416 $firstRow = $rows[0];
1417 if ( !is_array( $firstRow ) || !$firstRow ) {
1418 throw new DBLanguageError( 'Got an empty row list or empty row' );
1419 }
1420 // List of columns that define the value tuple ordering
1421 $tupleColumns = array_keys( $firstRow );
1422
1423 $valueTuples = [];
1424 foreach ( $rows as $row ) {
1425 $rowColumns = array_keys( $row );
1426 // VALUES(...) requires a uniform correspondence of (column => value)
1427 if ( $rowColumns !== $tupleColumns ) {
1428 throw new DBLanguageError(
1429 'Got row columns (' . implode( ', ', $rowColumns ) . ') ' .
1430 'instead of expected (' . implode( ', ', $tupleColumns ) . ')'
1431 );
1432 }
1433 // Make the value tuple that defines this row
1434 $valueTuples[] = '(' . $this->makeList( array_values( $row ), self::LIST_COMMA ) . ')';
1435 }
1436
1437 $magicAliasFields = [];
1438 foreach ( $tupleColumns as $column ) {
1439 $magicAliasFields[] = $aliasPrefix . $column;
1440 }
1441
1442 return [
1443 $this->makeList( $tupleColumns, self::LIST_NAMES ),
1444 implode( ',', $valueTuples ),
1445 $this->makeList( $magicAliasFields, self::LIST_NAMES )
1446 ];
1447 }
1448
1449 public function insertNonConflictingSqlText( $table, array $rows ) {
1450 $encTable = $this->tableName( $table );
1451 [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1452 [ $sqlVerb, $sqlOpts ] = $this->makeInsertNonConflictingVerbAndOptions();
1453
1454 return [
1455 rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES $sqlTuples $sqlOpts" ),
1456 rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES '?' $sqlOpts" )
1457 ];
1458 }
1459
1466 return [ 'INSERT IGNORE INTO', '' ];
1467 }
1468
1470 $destTable,
1471 $srcTable,
1472 array $varMap,
1473 $conds,
1474 $fname,
1475 array $insertOptions,
1476 array $selectOptions,
1477 $selectJoinConds
1478 ) {
1479 [ $sqlVerb, $sqlOpts ] = $this->isFlagInOptions( 'IGNORE', $insertOptions )
1480 ? $this->makeInsertNonConflictingVerbAndOptions()
1481 : [ 'INSERT INTO', '' ];
1482 $encDstTable = $this->tableName( $destTable );
1483 $sqlDstColumns = implode( ',', array_keys( $varMap ) );
1484 $selectSql = $this->selectSQLText(
1485 $srcTable,
1486 array_values( $varMap ),
1487 $conds,
1488 $fname,
1489 $selectOptions,
1490 $selectJoinConds
1491 );
1492
1493 return rtrim( "$sqlVerb $encDstTable ($sqlDstColumns) $selectSql $sqlOpts" );
1494 }
1495
1502 public function isFlagInOptions( $option, array $options ) {
1503 foreach ( array_keys( $options, $option, true ) as $k ) {
1504 if ( is_int( $k ) ) {
1505 return true;
1506 }
1507 }
1508
1509 return false;
1510 }
1511
1519 public function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
1520 if ( !$rows ) {
1521 throw new DBLanguageError( "Empty row array" );
1522 } elseif ( !$uniqueKey ) {
1523 throw new DBLanguageError( "Empty unique key array" );
1524 }
1525
1526 if ( count( $uniqueKey ) == 1 ) {
1527 // Use a simple IN(...) clause
1528 $column = reset( $uniqueKey );
1529 $values = array_column( $rows, $column );
1530 if ( count( $values ) !== count( $rows ) ) {
1531 throw new DBLanguageError( "Missing values for unique key ($column)" );
1532 }
1533
1534 return $this->makeList( [ $column => $values ], self::LIST_AND );
1535 }
1536
1537 $nullByUniqueKeyColumn = array_fill_keys( $uniqueKey, null );
1538
1539 $orConds = [];
1540 foreach ( $rows as $row ) {
1541 $rowKeyMap = array_intersect_key( $row, $nullByUniqueKeyColumn );
1542 if ( count( $rowKeyMap ) != count( $uniqueKey ) ) {
1543 throw new DBLanguageError(
1544 "Missing values for unique key (" . implode( ',', $uniqueKey ) . ")"
1545 );
1546 }
1547 $orConds[] = $this->makeList( $rowKeyMap, self::LIST_AND );
1548 }
1549
1550 return count( $orConds ) > 1
1551 ? $this->makeList( $orConds, self::LIST_OR )
1552 : $orConds[0];
1553 }
1554
1555 public function deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds ) {
1556 if ( !$conds ) {
1557 throw new DBLanguageError( __METHOD__ . ' called with empty $conds' );
1558 }
1559
1560 $delTable = $this->tableName( $delTable );
1561 $joinTable = $this->tableName( $joinTable );
1562 $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
1563 if ( $conds != '*' ) {
1564 $sql .= 'WHERE ' . $this->makeList( $conds, self::LIST_AND );
1565 }
1566 $sql .= ')';
1567
1568 return $sql;
1569 }
1570
1576 public function deleteSqlText( $table, $conds ) {
1577 $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1578 if ( !$isCondValid ) {
1579 throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1580 }
1581
1582 $encTable = $this->tableName( $table );
1583 $sql = "DELETE FROM $encTable";
1584
1585 $condsSql = '';
1586 $cleanCondsSql = '';
1587 if ( $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1588 $cleanCondsSql = ' WHERE ' . $this->scrubArray( $conds );
1589 if ( is_array( $conds ) ) {
1590 $conds = $this->makeList( $conds, self::LIST_AND );
1591 }
1592 $condsSql .= ' WHERE ' . $conds;
1593 }
1594 return new Query(
1595 $sql . $condsSql,
1596 self::QUERY_CHANGE_ROWS,
1597 'DELETE',
1598 $table,
1599 $sql . $cleanCondsSql
1600 );
1601 }
1602
1603 private function scrubArray( $array, $listType = self::LIST_AND ) {
1604 if ( is_array( $array ) ) {
1605 $scrubbedArray = [];
1606 foreach ( $array as $key => $value ) {
1607 if ( $value instanceof IExpression ) {
1608 $scrubbedArray[$key] = $value->toGeneralizedSql();
1609 } else {
1610 $scrubbedArray[$key] = '?';
1611 }
1612 }
1613 return $this->makeList( $scrubbedArray, $listType );
1614 }
1615 return '?';
1616 }
1617
1618 public function updateSqlText( $table, $set, $conds, $options ) {
1619 $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1620 if ( !$isCondValid ) {
1621 throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1622 }
1623 $encTable = $this->tableName( $table );
1624 $opts = $this->makeUpdateOptions( $options );
1625 $sql = "UPDATE $opts $encTable";
1626 $condsSql = " SET " . $this->makeList( $set, self::LIST_SET );
1627 $cleanCondsSql = " SET " . $this->scrubArray( $set, self::LIST_SET );
1628
1629 if ( $conds && $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1630 $cleanCondsSql .= ' WHERE ' . $this->scrubArray( $conds );
1631 if ( is_array( $conds ) ) {
1632 $conds = $this->makeList( $conds, self::LIST_AND );
1633 }
1634 $condsSql .= ' WHERE ' . $conds;
1635 }
1636 return new Query(
1637 $sql . $condsSql,
1638 self::QUERY_CHANGE_ROWS,
1639 'UPDATE',
1640 $table,
1641 $sql . $cleanCondsSql
1642 );
1643 }
1644
1651 protected function makeUpdateOptions( $options ) {
1652 $opts = $this->makeUpdateOptionsArray( $options );
1653
1654 return implode( ' ', $opts );
1655 }
1656
1664 protected function makeUpdateOptionsArray( $options ) {
1665 $options = $this->normalizeOptions( $options );
1666
1667 $opts = [];
1668
1669 if ( in_array( 'IGNORE', $options ) ) {
1670 $opts[] = 'IGNORE';
1671 }
1672
1673 return $opts;
1674 }
1675
1681 final public function normalizeOptions( $options ) {
1682 if ( is_array( $options ) ) {
1683 return $options;
1684 } elseif ( is_string( $options ) ) {
1685 return ( $options === '' ) ? [] : [ $options ];
1686 } else {
1687 throw new DBLanguageError( __METHOD__ . ': expected string or array' );
1688 }
1689 }
1690
1691 public function dropTableSqlText( $table ) {
1692 // https://mariadb.com/kb/en/drop-table/
1693 // https://dev.mysql.com/doc/refman/8.0/en/drop-table.html
1694 // https://www.postgresql.org/docs/9.2/sql-truncate.html
1695 return "DROP TABLE " . $this->tableName( $table ) . " CASCADE";
1696 }
1697
1703 public function getQueryVerb( $sql ) {
1704 wfDeprecated( __METHOD__, '1.42' );
1705 return QueryBuilderFromRawSql::buildQuery( $sql, 0 )->getVerb();
1706 }
1707
1720 public function isTransactableQuery( Query $sql ) {
1721 return !in_array(
1722 $sql->getVerb(),
1723 [
1724 'BEGIN',
1725 'ROLLBACK',
1726 'ROLLBACK TO SAVEPOINT',
1727 'COMMIT',
1728 'SET',
1729 'SHOW',
1730 'CREATE',
1731 'ALTER',
1732 'USE',
1733 'SHOW'
1734 ],
1735 true
1736 );
1737 }
1738
1739 public function buildExcludedValue( $column ) {
1740 /* @see Database::upsert() */
1741 // This can be treated like a single value since __VALS is a single row table
1742 return "(SELECT __$column FROM __VALS)";
1743 }
1744
1745 public function savepointSqlText( $identifier ) {
1746 return 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1747 }
1748
1749 public function releaseSavepointSqlText( $identifier ) {
1750 return 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1751 }
1752
1753 public function rollbackToSavepointSqlText( $identifier ) {
1754 return 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1755 }
1756
1757 public function rollbackSqlText() {
1758 return 'ROLLBACK';
1759 }
1760
1761 public function dispatchingInsertSqlText( $table, $rows, $options ) {
1762 $rows = $this->normalizeRowArray( $rows );
1763 if ( !$rows ) {
1764 return false;
1765 }
1766
1767 $options = $this->normalizeOptions( $options );
1768 if ( $this->isFlagInOptions( 'IGNORE', $options ) ) {
1769 [ $sql, $cleanSql ] = $this->insertNonConflictingSqlText( $table, $rows );
1770 } else {
1771 [ $sql, $cleanSql ] = $this->insertSqlText( $table, $rows );
1772 }
1773 return new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table, $cleanSql );
1774 }
1775
1781 final protected function normalizeRowArray( array $rowOrRows ) {
1782 if ( !$rowOrRows ) {
1783 $rows = [];
1784 } elseif ( isset( $rowOrRows[0] ) ) {
1785 $rows = $rowOrRows;
1786 } else {
1787 $rows = [ $rowOrRows ];
1788 }
1789
1790 foreach ( $rows as $row ) {
1791 if ( !is_array( $row ) ) {
1792 throw new DBLanguageError( "Got non-array in row array" );
1793 } elseif ( !$row ) {
1794 throw new DBLanguageError( "Got empty array in row array" );
1795 }
1796 }
1797
1798 return $rows;
1799 }
1800
1809 final public function normalizeUpsertParams( $uniqueKeys, &$rows ) {
1810 $rows = $this->normalizeRowArray( $rows );
1811 if ( !$uniqueKeys ) {
1812 throw new DBLanguageError( 'No unique key specified for upsert/replace' );
1813 }
1814 $uniqueKey = $this->normalizeUpsertKeys( $uniqueKeys );
1815 $this->assertValidUpsertRowArray( $rows, $uniqueKey );
1816
1817 return $uniqueKey;
1818 }
1819
1826 final public function normalizeConditions( $conds, $fname ) {
1827 if ( $conds === null || $conds === false ) {
1828 $this->logger->warning(
1829 __METHOD__
1830 . ' called from '
1831 . $fname
1832 . ' with incorrect parameters: $conds must be a string or an array',
1833 [ 'db_log_category' => 'sql' ]
1834 );
1835 return [];
1836 } elseif ( $conds === '' ) {
1837 return [];
1838 }
1839
1840 return is_array( $conds ) ? $conds : [ $conds ];
1841 }
1842
1848 private function normalizeUpsertKeys( $uniqueKeys ) {
1849 if ( is_string( $uniqueKeys ) ) {
1850 return [ $uniqueKeys ];
1851 } elseif ( !is_array( $uniqueKeys ) ) {
1852 throw new DBLanguageError( 'Invalid unique key array' );
1853 } else {
1854 if ( count( $uniqueKeys ) !== 1 || !isset( $uniqueKeys[0] ) ) {
1855 throw new DBLanguageError(
1856 "The unique key array should contain a single unique index" );
1857 }
1858
1859 $uniqueKey = $uniqueKeys[0];
1860 if ( is_string( $uniqueKey ) ) {
1861 // Passing a list of strings for single-column unique keys is too
1862 // easily confused with passing the columns of composite unique key
1863 $this->logger->warning( __METHOD__ .
1864 " called with deprecated parameter style: " .
1865 "the unique key array should be a string or array of string arrays",
1866 [
1867 'exception' => new RuntimeException(),
1868 'db_log_category' => 'sql',
1869 ] );
1870 return $uniqueKeys;
1871 } elseif ( is_array( $uniqueKey ) ) {
1872 return $uniqueKey;
1873 } else {
1874 throw new DBLanguageError( 'Invalid unique key array entry' );
1875 }
1876 }
1877 }
1878
1884 final protected function assertValidUpsertRowArray( array $rows, array $uniqueKey ) {
1885 foreach ( $rows as $row ) {
1886 foreach ( $uniqueKey as $column ) {
1887 if ( !isset( $row[$column] ) ) {
1888 throw new DBLanguageError(
1889 "NULL/absent values for unique key (" . implode( ',', $uniqueKey ) . ")"
1890 );
1891 }
1892 }
1893 }
1894 }
1895
1902 final public function assertValidUpsertSetArray(
1903 array $set,
1904 array $uniqueKey,
1905 array $rows
1906 ) {
1907 if ( !$set ) {
1908 throw new DBLanguageError( "Update assignment list can't be empty for upsert" );
1909 }
1910
1911 // Sloppy callers might construct the SET array using the ROW array, leaving redundant
1912 // column definitions for unique key columns. Detect this for backwards compatibility.
1913 $soleRow = ( count( $rows ) == 1 ) ? reset( $rows ) : null;
1914 // Disallow value changes for any columns in the unique key. This avoids additional
1915 // insertion order dependencies that are unwieldy and difficult to implement efficiently
1916 // in PostgreSQL.
1917 foreach ( $set as $k => $v ) {
1918 if ( is_string( $k ) ) {
1919 // Key is a column name and value is a literal (e.g. string, int, null, ...)
1920 if ( in_array( $k, $uniqueKey, true ) ) {
1921 if ( $soleRow && array_key_exists( $k, $soleRow ) && $soleRow[$k] === $v ) {
1922 $this->logger->warning(
1923 __METHOD__ . " called with redundant assignment to column '$k'",
1924 [
1925 'exception' => new RuntimeException(),
1926 'db_log_category' => 'sql',
1927 ]
1928 );
1929 } else {
1930 throw new DBLanguageError(
1931 "Cannot reassign column '$k' since it belongs to the provided unique key"
1932 );
1933 }
1934 }
1935 } elseif ( preg_match( '/^([a-zA-Z0-9_]+)\s*=/', $v, $m ) ) {
1936 // Value is of the form "<unquoted alphanumeric column> = <SQL expression>"
1937 if ( in_array( $m[1], $uniqueKey, true ) ) {
1938 throw new DBLanguageError(
1939 "Cannot reassign column '{$m[1]}' since it belongs to the provided unique key"
1940 );
1941 }
1942 }
1943 }
1944 }
1945
1950 final public function extractSingleFieldFromList( $var ) {
1951 if ( is_array( $var ) ) {
1952 if ( !$var ) {
1953 $column = null;
1954 } elseif ( count( $var ) == 1 ) {
1955 $column = $var[0] ?? reset( $var );
1956 } else {
1957 throw new DBLanguageError( __METHOD__ . ': got multiple columns' );
1958 }
1959 } else {
1960 $column = $var;
1961 }
1962
1963 return $column;
1964 }
1965
1966 public function setSchemaVars( $vars ) {
1967 $this->schemaVars = is_array( $vars ) ? $vars : null;
1968 }
1969
1976 protected function getSchemaVars() {
1977 return $this->schemaVars ?? $this->getDefaultSchemaVars();
1978 }
1979
1989 protected function getDefaultSchemaVars() {
1990 return [];
1991 }
1992
2013 public function replaceVars( $ins ) {
2014 $vars = $this->getSchemaVars();
2015 return preg_replace_callback(
2016 '!
2017 /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
2018 \'\{\$ (\w+) }\' | # 3. addQuotes
2019 `\{\$ (\w+) }` | # 4. addIdentifierQuotes
2020 /\*\$ (\w+) \*/ # 5. leave unencoded
2021 !x',
2022 function ( $m ) use ( $vars ) {
2023 // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
2024 // check for both nonexistent keys *and* the empty string.
2025 if ( isset( $m[1] ) && $m[1] !== '' ) {
2026 if ( $m[1] === 'i' ) {
2027 return $this->indexName( $m[2] );
2028 } else {
2029 return $this->tableName( $m[2] );
2030 }
2031 } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
2032 return $this->quoter->addQuotes( $vars[$m[3]] );
2033 } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
2034 return $this->addIdentifierQuotes( $vars[$m[4]] );
2035 } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
2036 return $vars[$m[5]];
2037 } else {
2038 return $m[0];
2039 }
2040 },
2041 $ins
2042 );
2043 }
2044
2045 public function lockSQLText( $lockName, $timeout ) {
2046 throw new RuntimeException( 'locking must be implemented in subclasses' );
2047 }
2048
2049 public function lockIsFreeSQLText( $lockName ) {
2050 throw new RuntimeException( 'locking must be implemented in subclasses' );
2051 }
2052
2053 public function unlockSQLText( $lockName ) {
2054 throw new RuntimeException( 'locking must be implemented in subclasses' );
2055 }
2056}
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
array $params
The job parameters.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Class to handle database/schema/prefix specifications for IDatabase.
Used by Database::buildLike() to represent characters that have special meaning in SQL LIKE clauses a...
Definition LikeMatch.php:10
Content of like value.
Definition LikeValue.php:14
buildGroupConcatField( $delim, $tables, $field, $conds='', $join_conds=[])
Build a GROUP_CONCAT or equivalent statement for a query.
decodeExpiry( $expiry, $format=TS_MW)
Decode an expiry time into a DBMS independent format.
getIdentifierQuoteChar()
Get the character used for identifier quoting.
anyChar()
Returns a token for buildLike() that denotes a '_' to be used in a LIKE query.
buildConcat( $stringList)
Build a concatenation list to feed into a SQL query.string
insertSelectNativeSqlText( $destTable, $srcTable, array $varMap, $conds, $fname, array $insertOptions, array $selectOptions, $selectJoinConds)
getInfinity()
Find out when 'infinity' is.
buildGreatest( $fields, $values)
Build a GREATEST function statement comparing columns/values.Integer and float values in $values will...
makeGroupByWithHaving( $options)
Returns an optional GROUP BY with an optional HAVING.
buildSelectSubquery( $tables, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Equivalent to IDatabase::selectSQLText() except wraps the result in Subquery.
makeSelectOptions(array $options)
Returns an optional USE INDEX clause to go after the table, and a string to go at the end of the quer...
strreplace( $orig, $old, $new)
Returns a SQL expression for simple string replacement (e.g.
replaceVars( $ins)
Database-independent variable replacement.
normalizeUpsertParams( $uniqueKeys, &$rows)
Validate and normalize parameters to upsert() or replace()
setIndexAliases(array $aliases)
Convert certain index names to alternative names before querying the DB.
qualifiedTableComponents( $name)
Get the table components needed for a query given the currently selected database/schema.
makeKeyCollisionCondition(array $rows, array $uniqueKey)
Build an SQL condition to find rows with matching key values to those in $rows.
__construct(DbQuoter $quoter, ?LoggerInterface $logger=null, ?DatabaseDomain $currentDomain=null, $errorLogger=null)
array null $schemaVars
Current variables use for schema element placeholders.
ignoreIndexClause( $index)
IGNORE INDEX clause.
makeOrderBy( $options)
Returns an optional ORDER BY.
string[] $indexAliases
Current map of (index alias => index)
buildLike( $param,... $params)
LIKE statement wrapper.
bitOr( $fieldLeft, $fieldRight)
isTransactableQuery(Query $sql)
Determine whether a SQL statement is sensitive to isolation level.
fieldNameWithAlias( $name, $alias=false)
Get an aliased field name e.g.
buildComparison(string $op, array $conds)
Build a condition comparing multiple values, for use with indexes that cover multiple fields,...
limitResult( $sql, $limit, $offset=false)
Construct a LIMIT query with optional offset.
unionSupportsOrderAndLimit()
Determine if the RDBMS supports ORDER BY and LIMIT for separate subqueries within UNION....
buildExcludedValue( $column)
Build a reference to a column value from the conflicting proposed upsert() row.
timestamp( $ts=0)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
buildSuperlative( $sqlfunc, $fields, $values)
Build a superlative function statement comparing columns/values.
makeUpdateOptions( $options)
Make UPDATE options for the Database::update function.
assertBuildSubstringParams( $startPosition, $length)
Check type and bounds for parameters to self::buildSubstring()
getDatabaseAndTableIdentifier(string $table)
Get the database identifer and prefixed table name identifier for a table.
assertValidUpsertSetArray(array $set, array $uniqueKey, array $rows)
bitAnd( $fieldLeft, $fieldRight)
array[] $tableAliases
Current map of (table => (dbname, schema, prefix) map)
normalizeJoinType(string $joinType)
Validate and normalize a join type.
indexName( $index)
Allows for index remapping in queries where this is not consistent across DBMS.
buildSubstring( $input, $startPosition, $length=null)
unionQueries( $sqls, $all, $options=[])
Construct a UNION query.
getDefaultSchemaVars()
Get schema variables to use if none have been set via setSchemaVars().
factorConds( $condsArray)
Given an array of condition arrays representing an OR list of AND lists, for example:
dispatchingInsertSqlText( $table, $rows, $options)
updateSqlText( $table, $set, $conds, $options)
implicitOrderby()
Returns true if this database does an implicit order by when the column has an index For example: SEL...
anyString()
Returns a token for buildLike() that denotes a '' to be used in a LIKE query.
makeWhereFrom2d( $data, $baseKey, $subKey)
Build a "OR" condition with pairs from a two-dimensional array.
tableName(string $name, $format='quoted')
Format a table name ready for use in constructing an SQL query.
assertValidUpsertRowArray(array $rows, array $uniqueKey)
deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds)
buildLeast( $fields, $values)
Build a LEAST function statement comparing columns/values.Integer and float values in $values will no...
makeInsertLists(array $rows, $aliasPrefix='', array $typeByColumn=[])
Make SQL lists of columns, row tuples, and column aliases for INSERT/VALUES expressions.
useIndexClause( $index)
USE INDEX clause.
encodeExpiry( $expiry)
Encode an expiry time into the DBMS dependent format.
setTableAliases(array $aliases)
Make certain table names use their own database, schema, and table prefix when passed into SQL querie...
isFlagInOptions( $option, array $options)
conditional( $cond, $caseTrueExpression, $caseFalseExpression)
Returns an SQL expression for a simple conditional.
setSchemaVars( $vars)
Set schema variables to be used when streaming commands from SQL files or stdin.
setCurrentDomain(DatabaseDomain $currentDomain)
selectSQLText( $tables, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Take the same arguments as IDatabase::select() and return the SQL it would use.
timestampOrNull( $ts=null)
Convert a timestamp in one of the formats accepted by ConvertibleTimestamp to the format used for ins...
isQuotedIdentifier( $name)
Returns if the given identifier looks quoted or not according to the database convention for quoting ...
getSchemaVars()
Get schema variables.
makeList(array $a, $mode=self::LIST_COMMA)
Makes an encoded list of strings from an array.
insertNonConflictingSqlText( $table, array $rows)
addIdentifierQuotes( $s)
Escape a SQL identifier (e.g.
extractTableNameComponents(string $name)
Extract the dot-separated components of a table name, preserving identifier quotation.
escapeLikeInternal( $s, $escapeChar='`')
fieldNamesWithAlias( $fields)
Gets an array of aliased field names.
tableNamesN(... $tables)
Fetch a number of table names into a zero-indexed numerical array.
makeUpdateOptionsArray( $options)
Make UPDATE options array for Database::makeUpdateOptions.
tableNameWithAlias( $table, $alias=false)
Get an aliased table name.
callable $errorLogger
Error logging callback.
tableNamesWithIndexClauseOrJOIN( $tables, $use_index=[], $ignore_index=[], $join_conds=[])
Get the aliased table name clause for a FROM clause which might have a JOIN and/or USE INDEX or IGNOR...
This is to contain any regex on SQL work and get rid of them eventually.
static buildQuery(string $sql, $flags, string $tablePrefix='')
Holds information on Query to be executed.
Definition Query.php:31
Raw SQL value to be used in query builders.
Interface for query language.