MediaWiki 1.44.0
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 = [];
54 protected $schemaVars;
55 protected DbQuoter $quoter;
56 protected LoggerInterface $logger;
58 protected $errorLogger;
59
60 public function __construct(
62 ?LoggerInterface $logger = null,
64 $errorLogger = null
65 ) {
66 $this->quoter = $quoter;
67 $this->logger = $logger ?? new NullLogger();
68 $this->currentDomain = $currentDomain ?? DatabaseDomain::newUnspecified();
69 $this->errorLogger = $errorLogger ?? static function ( Throwable $e ) {
70 trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
71 };
72 }
73
74 public function bitNot( $field ) {
75 return "(~$field)";
76 }
77
78 public function bitAnd( $fieldLeft, $fieldRight ) {
79 return "($fieldLeft & $fieldRight)";
80 }
81
82 public function bitOr( $fieldLeft, $fieldRight ) {
83 return "($fieldLeft | $fieldRight)";
84 }
85
86 public function addIdentifierQuotes( $s ) {
87 if ( strcspn( $s, "\0\"`'." ) !== strlen( $s ) ) {
88 throw new DBLanguageError(
89 "Identifier must not contain quote, dot or null characters: got '$s'"
90 );
91 }
92 $quoteChar = $this->getIdentifierQuoteChar();
93 return $quoteChar . $s . $quoteChar;
94 }
95
100 protected function getIdentifierQuoteChar() {
101 return '"';
102 }
103
107 public function buildGreatest( $fields, $values ) {
108 return $this->buildSuperlative( 'GREATEST', $fields, $values );
109 }
110
114 public function buildLeast( $fields, $values ) {
115 return $this->buildSuperlative( 'LEAST', $fields, $values );
116 }
117
132 protected function buildSuperlative( $sqlfunc, $fields, $values ) {
133 $fields = is_array( $fields ) ? $fields : [ $fields ];
134 $values = is_array( $values ) ? $values : [ $values ];
135
136 $encValues = [];
137 foreach ( $fields as $alias => $field ) {
138 if ( is_int( $alias ) ) {
139 $encValues[] = $this->addIdentifierQuotes( $field );
140 } else {
141 $encValues[] = $field; // expression
142 }
143 }
144 foreach ( $values as $value ) {
145 if ( is_int( $value ) || is_float( $value ) ) {
146 $encValues[] = $value;
147 } elseif ( is_string( $value ) ) {
148 $encValues[] = $this->quoter->addQuotes( $value );
149 } elseif ( $value === null ) {
150 throw new DBLanguageError( 'Null value in superlative' );
151 } else {
152 throw new DBLanguageError( 'Unexpected value type in superlative' );
153 }
154 }
155
156 return $sqlfunc . '(' . implode( ',', $encValues ) . ')';
157 }
158
159 public function buildComparison( string $op, array $conds ): string {
160 if ( !in_array( $op, [ '>', '>=', '<', '<=' ] ) ) {
161 throw new InvalidArgumentException( "Comparison operator must be one of '>', '>=', '<', '<='" );
162 }
163 if ( count( $conds ) === 0 ) {
164 throw new InvalidArgumentException( "Empty input" );
165 }
166
167 // Construct a condition string by starting with the least significant part of the index, and
168 // adding more significant parts progressively to the left of the string.
169 //
170 // For example, given $conds = [ 'a' => 4, 'b' => 7, 'c' => 1 ], this will generate a condition
171 // like this:
172 //
173 // WHERE a > 4
174 // OR (a = 4 AND (b > 7
175 // OR (b = 7 AND (c > 1))))
176 //
177 // …which is equivalent to the following, which might be easier to understand:
178 //
179 // WHERE a > 4
180 // OR a = 4 AND b > 7
181 // OR a = 4 AND b = 7 AND c > 1
182 //
183 // …and also equivalent to the following, using tuple comparison syntax, which is most intuitive
184 // but apparently performs worse:
185 //
186 // WHERE (a, b, c) > (4, 7, 1)
187
188 $sql = '';
189 foreach ( array_reverse( $conds ) as $field => $value ) {
190 if ( is_int( $field ) ) {
191 throw new InvalidArgumentException(
192 'Non-associative array passed to buildComparison() (typo?)'
193 );
194 }
195 $encValue = $this->quoter->addQuotes( $value );
196 if ( $sql === '' ) {
197 $sql = "$field $op $encValue";
198 // Change '>=' to '>' etc. for remaining fields, as the equality is handled separately
199 $op = rtrim( $op, '=' );
200 } else {
201 $sql = "$field $op $encValue OR ($field = $encValue AND ($sql))";
202 }
203 }
204 return $sql;
205 }
206
207 public function makeList( array $a, $mode = self::LIST_COMMA ) {
208 $first = true;
209 $list = '';
210 $keyWarning = null;
211
212 foreach ( $a as $field => $value ) {
213 if ( $first ) {
214 $first = false;
215 } else {
216 if ( $mode == self::LIST_AND ) {
217 $list .= ' AND ';
218 } elseif ( $mode == self::LIST_OR ) {
219 $list .= ' OR ';
220 } else {
221 $list .= ',';
222 }
223 }
224
225 if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
226 if ( $value instanceof IExpression ) {
227 $list .= "(" . $value->toSql( $this->quoter ) . ")";
228 } elseif ( is_array( $value ) ) {
229 throw new InvalidArgumentException( __METHOD__ . ": unexpected array value without key" );
230 } elseif ( $value instanceof RawSQLValue ) {
231 throw new InvalidArgumentException( __METHOD__ . ": unexpected raw value without key" );
232 } else {
233 $list .= "($value)";
234 }
235 } elseif ( $value instanceof IExpression ) {
236 if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
237 throw new InvalidArgumentException( __METHOD__ . ": unexpected key $field for IExpression value" );
238 } else {
239 throw new InvalidArgumentException( __METHOD__ . ": unexpected IExpression outside WHERE clause" );
240 }
241 } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
242 $list .= "$value";
243 } elseif (
244 ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
245 ) {
246 // Remove null from array to be handled separately if found
247 $includeNull = false;
248 foreach ( array_keys( $value, null, true ) as $nullKey ) {
249 $includeNull = true;
250 unset( $value[$nullKey] );
251 }
252 if ( count( $value ) == 0 && !$includeNull ) {
253 throw new InvalidArgumentException(
254 __METHOD__ . ": empty input for field $field" );
255 } elseif ( count( $value ) == 0 ) {
256 // only check if $field is null
257 $list .= "$field IS NULL";
258 } else {
259 // IN clause contains at least one valid element
260 if ( $includeNull ) {
261 // Group subconditions to ensure correct precedence
262 $list .= '(';
263 }
264 if ( count( $value ) == 1 ) {
265 // Special-case single values, as IN isn't terribly efficient
266 // (but call makeList() so that warnings are emitted if needed)
267 $list .= $field . " = " . $this->makeList( $value );
268 } else {
269 $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
270 }
271 // if null present in array, append IS NULL
272 if ( $includeNull ) {
273 $list .= " OR $field IS NULL)";
274 }
275 }
276 } elseif ( is_array( $value ) ) {
277 throw new InvalidArgumentException( __METHOD__ . ": unexpected nested array" );
278 } elseif ( $value === null ) {
279 if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
280 $list .= "$field IS ";
281 } elseif ( $mode == self::LIST_SET ) {
282 $list .= "$field = ";
283 } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
284 $keyWarning ??= [
285 __METHOD__ . ": array key {key} in list of values ignored",
286 [ 'key' => $field, 'exception' => new RuntimeException() ]
287 ];
288 } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
289 $keyWarning ??= [
290 __METHOD__ . ": array key {key} in list of fields ignored",
291 [ 'key' => $field, 'exception' => new RuntimeException() ]
292 ];
293 }
294 $list .= 'NULL';
295 } else {
296 if (
297 $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
298 ) {
299 $list .= "$field = ";
300 } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
301 $keyWarning ??= [
302 __METHOD__ . ": array key {key} in list of values ignored",
303 [ 'key' => $field, 'exception' => new RuntimeException() ]
304 ];
305 } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
306 $keyWarning ??= [
307 __METHOD__ . ": array key {key} in list of fields ignored",
308 [ 'key' => $field, 'exception' => new RuntimeException() ]
309 ];
310 }
311 $list .= $mode == self::LIST_NAMES ? $value : $this->quoter->addQuotes( $value );
312 }
313 }
314
315 if ( $keyWarning ) {
316 // Only log one warning about this per function call, to reduce log spam when a dynamically
317 // generated associative array is passed
318 $this->logger->warning( ...$keyWarning );
319 }
320
321 return $list;
322 }
323
324 public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
325 $conds = [];
326 foreach ( $data as $base => $sub ) {
327 if ( count( $sub ) ) {
328 $conds[] = $this->makeList(
329 [ $baseKey => $base, $subKey => array_map( 'strval', array_keys( $sub ) ) ],
330 self::LIST_AND
331 );
332 }
333 }
334
335 if ( !$conds ) {
336 throw new InvalidArgumentException( "Data for $baseKey and $subKey must be non-empty" );
337 }
338
339 return $this->makeList( $conds, self::LIST_OR );
340 }
341
342 public function factorConds( $condsArray ) {
343 if ( count( $condsArray ) === 0 ) {
344 throw new InvalidArgumentException(
345 __METHOD__ . ": empty condition array" );
346 }
347 $condsByFieldSet = [];
348 foreach ( $condsArray as $conds ) {
349 if ( !count( $conds ) ) {
350 throw new InvalidArgumentException(
351 __METHOD__ . ": empty condition subarray" );
352 }
353 $fieldKey = implode( ',', array_keys( $conds ) );
354 $condsByFieldSet[$fieldKey][] = $conds;
355 }
356 $result = '';
357 foreach ( $condsByFieldSet as $conds ) {
358 if ( $result !== '' ) {
359 $result .= ' OR ';
360 }
361 $result .= $this->factorCondsWithCommonFields( $conds );
362 }
363 return $result;
364 }
365
373 private function factorCondsWithCommonFields( $condsArray ) {
374 $first = $condsArray[array_key_first( $condsArray )];
375 if ( count( $first ) === 1 ) {
376 // IN clause
377 $field = array_key_first( $first );
378 $values = [];
379 foreach ( $condsArray as $conds ) {
380 $values[] = $conds[$field];
381 }
382 return $this->makeList( [ $field => $values ], self::LIST_AND );
383 }
384
385 $field1 = array_key_first( $first );
386 $nullExpressions = [];
387 $expressionsByField1 = [];
388 foreach ( $condsArray as $conds ) {
389 $value1 = $conds[$field1];
390 unset( $conds[$field1] );
391 if ( $value1 === null ) {
392 $nullExpressions[] = $conds;
393 } else {
394 $expressionsByField1[$value1][] = $conds;
395 }
396
397 }
398 $wrap = false;
399 $result = '';
400 foreach ( $expressionsByField1 as $value1 => $expressions ) {
401 if ( $result !== '' ) {
402 $result .= ' OR ';
403 $wrap = true;
404 }
405 $factored = $this->factorCondsWithCommonFields( $expressions );
406 $result .= "($field1 = " . $this->quoter->addQuotes( $value1 ) .
407 " AND $factored)";
408 }
409 if ( count( $nullExpressions ) ) {
410 $factored = $this->factorCondsWithCommonFields( $nullExpressions );
411 if ( $result !== '' ) {
412 $result .= ' OR ';
413 $wrap = true;
414 }
415 $result .= "($field1 IS NULL AND $factored)";
416 }
417 if ( $wrap ) {
418 return "($result)";
419 } else {
420 return $result;
421 }
422 }
423
427 public function buildConcat( $stringList ) {
428 return 'CONCAT(' . implode( ',', $stringList ) . ')';
429 }
430
431 public function limitResult( $sql, $limit, $offset = false ) {
432 if ( !is_numeric( $limit ) ) {
433 throw new DBLanguageError(
434 "Invalid non-numeric limit passed to " . __METHOD__
435 );
436 }
437 // This version works in MySQL and SQLite. It will very likely need to be
438 // overridden for most other RDBMS subclasses.
439 return "$sql LIMIT "
440 . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
441 . "{$limit} ";
442 }
443
449 public function escapeLikeInternal( $s, $escapeChar = '`' ) {
450 return str_replace(
451 [ $escapeChar, '%', '_' ],
452 [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
453 $s
454 );
455 }
456
457 public function buildLike( $param, ...$params ) {
458 if ( is_array( $param ) ) {
459 $params = $param;
460 $param = array_shift( $params );
461 }
462 $likeValue = new LikeValue( $param, ...$params );
463
464 return ' LIKE ' . $likeValue->toSql( $this->quoter );
465 }
466
467 public function anyChar() {
468 return new LikeMatch( '_' );
469 }
470
471 public function anyString() {
472 return new LikeMatch( '%' );
473 }
474
478 public function unionSupportsOrderAndLimit() {
479 return true; // True for almost every DB supported
480 }
481
482 public function unionQueries( $sqls, $all, $options = [] ) {
483 $glue = $all ? ') UNION ALL (' : ') UNION (';
484
485 $sql = '(' . implode( $glue, $sqls ) . ')';
486 if ( !$this->unionSupportsOrderAndLimit() ) {
487 return $sql;
488 }
489 $sql .= $this->makeOrderBy( $options );
490 $limit = $options['LIMIT'] ?? null;
491 $offset = $options['OFFSET'] ?? false;
492 if ( $limit !== null ) {
493 $sql = $this->limitResult( $sql, $limit, $offset );
494 }
495
496 return $sql;
497 }
498
499 public function conditional( $cond, $caseTrueExpression, $caseFalseExpression ) {
500 if ( is_array( $cond ) ) {
501 $cond = $this->makeList( $cond, self::LIST_AND );
502 }
503 if ( $cond instanceof IExpression ) {
504 $cond = $cond->toSql( $this->quoter );
505 }
506
507 return "(CASE WHEN $cond THEN $caseTrueExpression ELSE $caseFalseExpression END)";
508 }
509
510 public function strreplace( $orig, $old, $new ) {
511 return "REPLACE({$orig}, {$old}, {$new})";
512 }
513
514 public function timestamp( $ts = 0 ) {
515 $t = new ConvertibleTimestamp( $ts );
516 // Let errors bubble up to avoid putting garbage in the DB
517 return $t->getTimestamp( TS_MW );
518 }
519
520 public function timestampOrNull( $ts = null ) {
521 if ( $ts === null ) {
522 return null;
523 } else {
524 return $this->timestamp( $ts );
525 }
526 }
527
528 public function getInfinity() {
529 return 'infinity';
530 }
531
532 public function encodeExpiry( $expiry ) {
533 return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
534 ? $this->getInfinity()
535 : $this->timestamp( $expiry );
536 }
537
538 public function decodeExpiry( $expiry, $format = TS_MW ) {
539 if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
540 return 'infinity';
541 }
542
543 return ConvertibleTimestamp::convert( $format, $expiry );
544 }
545
549 public function buildSubstring( $input, $startPosition, $length = null ) {
550 $this->assertBuildSubstringParams( $startPosition, $length );
551 $functionBody = "$input FROM $startPosition";
552 if ( $length !== null ) {
553 $functionBody .= " FOR $length";
554 }
555 return 'SUBSTRING(' . $functionBody . ')';
556 }
557
570 protected function assertBuildSubstringParams( $startPosition, $length ) {
571 if ( $startPosition === 0 ) {
572 // The DBMSs we support use 1-based indexing here.
573 throw new InvalidArgumentException( 'Use 1 as $startPosition for the beginning of the string' );
574 }
575 if ( !is_int( $startPosition ) || $startPosition < 0 ) {
576 throw new InvalidArgumentException(
577 '$startPosition must be a positive integer'
578 );
579 }
580 if ( !( ( is_int( $length ) && $length >= 0 ) || $length === null ) ) {
581 throw new InvalidArgumentException(
582 '$length must be null or an integer greater than or equal to 0'
583 );
584 }
585 }
586
587 public function buildStringCast( $field ) {
588 // In theory this should work for any standards-compliant
589 // SQL implementation, although it may not be the best way to do it.
590 return "CAST( $field AS CHARACTER )";
591 }
592
593 public function buildIntegerCast( $field ) {
594 return 'CAST( ' . $field . ' AS INTEGER )';
595 }
596
597 public function implicitOrderby() {
598 return true;
599 }
600
609 public function indexName( $index ) {
610 return $this->indexAliases[$index] ?? $index;
611 }
612
613 public function setTableAliases( array $aliases ) {
614 $this->tableAliases = $aliases;
615 }
616
617 public function setIndexAliases( array $aliases ) {
618 $this->indexAliases = $aliases;
619 }
620
624 public function getTableAliases() {
625 return $this->tableAliases;
626 }
627
628 public function setPrefix( $prefix ) {
629 $this->currentDomain = new DatabaseDomain(
630 $this->currentDomain->getDatabase(),
631 $this->currentDomain->getSchema(),
632 $prefix
633 );
634 }
635
636 public function setCurrentDomain( DatabaseDomain $currentDomain ) {
637 $this->currentDomain = $currentDomain;
638 }
639
644 public function getCurrentDomain() {
645 return $this->currentDomain;
646 }
647
648 public function selectSQLText(
649 $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
650 ) {
651 if ( !is_array( $tables ) ) {
652 if ( $tables === '' || $tables === null || $tables === false ) {
653 $tables = [];
654 } elseif ( is_string( $tables ) ) {
655 $tables = [ $tables ];
656 } else {
657 throw new DBLanguageError( __METHOD__ . ' called with incorrect table parameter' );
658 }
659 }
660
661 if ( is_array( $vars ) ) {
662 $fields = implode( ',', $this->fieldNamesWithAlias( $vars ) );
663 } else {
664 $fields = $vars;
665 }
666
667 $options = (array)$options;
668
669 $useIndexByTable = $options['USE INDEX'] ?? [];
670 if ( !is_array( $useIndexByTable ) ) {
671 if ( count( $tables ) <= 1 ) {
672 $useIndexByTable = [ reset( $tables ) => $useIndexByTable ];
673 } else {
674 $e = new DBLanguageError( __METHOD__ . " got ambiguous USE INDEX ($fname)" );
675 ( $this->errorLogger )( $e );
676 }
677 }
678
679 $ignoreIndexByTable = $options['IGNORE INDEX'] ?? [];
680 if ( !is_array( $ignoreIndexByTable ) ) {
681 if ( count( $tables ) <= 1 ) {
682 $ignoreIndexByTable = [ reset( $tables ) => $ignoreIndexByTable ];
683 } else {
684 $e = new DBLanguageError( __METHOD__ . " got ambiguous IGNORE INDEX ($fname)" );
685 ( $this->errorLogger )( $e );
686 }
687 }
688
689 if (
690 $this->selectOptionsIncludeLocking( $options ) &&
691 $this->selectFieldsOrOptionsAggregate( $vars, $options )
692 ) {
693 // Some DB types (e.g. postgres) disallow FOR UPDATE with aggregate
694 // functions. Discourage use of such queries to encourage compatibility.
695 $this->logger->warning(
696 __METHOD__ . ": aggregation used with a locking SELECT ($fname)"
697 );
698 }
699
700 if ( count( $tables ) ) {
701 $from = ' FROM ' . $this->tableNamesWithIndexClauseOrJOIN(
702 $tables,
703 $useIndexByTable,
704 $ignoreIndexByTable,
705 $join_conds
706 );
707 } else {
708 $from = '';
709 }
710
711 [ $startOpts, $preLimitTail, $postLimitTail ] = $this->makeSelectOptions( $options );
712
713 if ( is_array( $conds ) ) {
714 $where = $this->makeList( $conds, self::LIST_AND );
715 } elseif ( $conds instanceof IExpression ) {
716 $where = $conds->toSql( $this->quoter );
717 } elseif ( $conds === null || $conds === false ) {
718 $where = '';
719 $this->logger->warning(
720 __METHOD__
721 . ' called from '
722 . $fname
723 . ' with incorrect parameters: $conds must be a string or an array',
724 [ 'db_log_category' => 'sql' ]
725 );
726 } elseif ( is_string( $conds ) ) {
727 $where = $conds;
728 } else {
729 throw new DBLanguageError( __METHOD__ . ' called with incorrect parameters' );
730 }
731
732 // Keep historical extra spaces after FROM to avoid testing failures
733 if ( $where === '' || $where === '*' ) {
734 $sql = "SELECT $startOpts $fields $from $preLimitTail";
735 } else {
736 $sql = "SELECT $startOpts $fields $from WHERE $where $preLimitTail";
737 }
738
739 if ( isset( $options['LIMIT'] ) ) {
740 $sql = $this->limitResult( $sql, $options['LIMIT'], $options['OFFSET'] ?? false );
741 }
742 $sql = "$sql $postLimitTail";
743
744 if ( isset( $options['EXPLAIN'] ) ) {
745 $sql = 'EXPLAIN ' . $sql;
746 }
747
748 if (
749 $fname === static::CALLER_UNKNOWN ||
750 str_starts_with( $fname, 'Wikimedia\\Rdbms\\' ) ||
751 $fname === '{closure}'
752 ) {
753 $exception = new RuntimeException();
754
755 // Try to figure out and report the real caller
756 $caller = '';
757 foreach ( $exception->getTrace() as $call ) {
758 if ( str_ends_with( $call['file'] ?? '', 'Test.php' ) ) {
759 // Don't warn when called directly by test code, adding callers there is pointless
760 break;
761 } elseif ( str_starts_with( $call['class'] ?? '', 'Wikimedia\\Rdbms\\' ) ) {
762 // Keep looking for the caller of a rdbms method
763 } elseif ( str_ends_with( $call['class'] ?? '', 'SelectQueryBuilder' ) ) {
764 // Keep looking for the caller of any custom SelectQueryBuilder
765 } else {
766 // Warn about the external caller we found
767 $caller = implode( '::', array_filter( [ $call['class'] ?? null, $call['function'] ] ) );
768 break;
769 }
770 }
771
772 if ( $fname === '{closure}' ) {
773 // Someone did ->caller( __METHOD__ ) in a local function, e.g. in a callback to
774 // getWithSetCallback(), MWCallableUpdate or doAtomicSection(). That's not very helpful.
775 // Provide a more specific message. The caller has to be provided like this:
776 // $method = __METHOD__;
777 // function ( ... ) use ( $method ) { ... }
778 $warning = "SQL query with incorrect caller (__METHOD__ used inside a closure: {caller}): {sql}";
779 } else {
780 $warning = "SQL query did not specify the caller (guessed caller: {caller}): {sql}";
781 }
782
783 $this->logger->warning(
784 $warning,
785 [ 'sql' => $sql, 'caller' => $caller, 'exception' => $exception ]
786 );
787 }
788
789 return $sql;
790 }
791
796 private function selectOptionsIncludeLocking( $options ) {
797 $options = (array)$options;
798 foreach ( [ 'FOR UPDATE', 'LOCK IN SHARE MODE' ] as $lock ) {
799 if ( in_array( $lock, $options, true ) ) {
800 return true;
801 }
802 }
803
804 return false;
805 }
806
812 private function selectFieldsOrOptionsAggregate( $fields, $options ) {
813 foreach ( (array)$options as $key => $value ) {
814 if ( is_string( $key ) ) {
815 if ( preg_match( '/^(?:GROUP BY|HAVING)$/i', $key ) ) {
816 return true;
817 }
818 } elseif ( is_string( $value ) ) {
819 if ( preg_match( '/^(?:DISTINCT|DISTINCTROW)$/i', $value ) ) {
820 return true;
821 }
822 }
823 }
824
825 $regex = '/^(?:COUNT|MIN|MAX|SUM|GROUP_CONCAT|LISTAGG|ARRAY_AGG)\s*\\(/i';
826 foreach ( (array)$fields as $field ) {
827 if ( is_string( $field ) && preg_match( $regex, $field ) ) {
828 return true;
829 }
830 }
831
832 return false;
833 }
834
841 protected function fieldNamesWithAlias( $fields ) {
842 $retval = [];
843 foreach ( $fields as $alias => $field ) {
844 if ( is_numeric( $alias ) ) {
845 $alias = $field;
846 }
847 $retval[] = $this->fieldNameWithAlias( $field, $alias );
848 }
849
850 return $retval;
851 }
852
861 public function fieldNameWithAlias( $name, $alias = false ) {
862 if ( !$alias || (string)$alias === (string)$name ) {
863 return $name;
864 } else {
865 return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
866 }
867 }
868
880 $tables,
881 $use_index = [],
882 $ignore_index = [],
883 $join_conds = []
884 ) {
885 $ret = [];
886 $retJOIN = [];
887 $use_index = (array)$use_index;
888 $ignore_index = (array)$ignore_index;
889 $join_conds = (array)$join_conds;
890
891 foreach ( $tables as $alias => $table ) {
892 if ( !is_string( $alias ) ) {
893 // No alias? Set it equal to the table name
894 $alias = $table;
895 }
896
897 if ( is_array( $table ) ) {
898 // A parenthesized group
899 if ( count( $table ) > 1 ) {
900 $joinedTable = '(' .
901 $this->tableNamesWithIndexClauseOrJOIN(
902 $table, $use_index, $ignore_index, $join_conds ) . ')';
903 } else {
904 // Degenerate case
905 $innerTable = reset( $table );
906 $innerAlias = key( $table );
907 $joinedTable = $this->tableNameWithAlias(
908 $innerTable,
909 is_string( $innerAlias ) ? $innerAlias : $innerTable
910 );
911 }
912 } else {
913 $joinedTable = $this->tableNameWithAlias( $table, $alias );
914 }
915
916 // Is there a JOIN clause for this table?
917 if ( isset( $join_conds[$alias] ) ) {
918 Assert::parameterType( 'array', $join_conds[$alias], "join_conds[$alias]" );
919 [ $joinType, $conds ] = $join_conds[$alias];
920 $tableClause = $this->normalizeJoinType( $joinType );
921 $tableClause .= ' ' . $joinedTable;
922 if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
923 $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
924 if ( $use != '' ) {
925 $tableClause .= ' ' . $use;
926 }
927 }
928 if ( isset( $ignore_index[$alias] ) ) { // has IGNORE INDEX?
929 $ignore = $this->ignoreIndexClause(
930 implode( ',', (array)$ignore_index[$alias] ) );
931 if ( $ignore != '' ) {
932 $tableClause .= ' ' . $ignore;
933 }
934 }
935 $on = $this->makeList( (array)$conds, self::LIST_AND );
936 if ( $on != '' ) {
937 $tableClause .= ' ON (' . $on . ')';
938 }
939
940 $retJOIN[] = $tableClause;
941 } elseif ( isset( $use_index[$alias] ) ) {
942 // Is there an INDEX clause for this table?
943 $tableClause = $joinedTable;
944 $tableClause .= ' ' . $this->useIndexClause(
945 implode( ',', (array)$use_index[$alias] )
946 );
947
948 $ret[] = $tableClause;
949 } elseif ( isset( $ignore_index[$alias] ) ) {
950 // Is there an INDEX clause for this table?
951 $tableClause = $joinedTable;
952 $tableClause .= ' ' . $this->ignoreIndexClause(
953 implode( ',', (array)$ignore_index[$alias] )
954 );
955
956 $ret[] = $tableClause;
957 } else {
958 $tableClause = $joinedTable;
959
960 $ret[] = $tableClause;
961 }
962 }
963
964 // We can't separate explicit JOIN clauses with ',', use ' ' for those
965 $implicitJoins = implode( ',', $ret );
966 $explicitJoins = implode( ' ', $retJOIN );
967
968 // Compile our final table clause
969 return implode( ' ', [ $implicitJoins, $explicitJoins ] );
970 }
971
980 protected function normalizeJoinType( string $joinType ) {
981 switch ( strtoupper( $joinType ) ) {
982 case 'JOIN':
983 case 'INNER JOIN':
984 return 'JOIN';
985
986 case 'LEFT JOIN':
987 return 'LEFT JOIN';
988
989 case 'STRAIGHT_JOIN':
990 case 'STRAIGHT JOIN':
991 // MySQL only
992 return 'JOIN';
993
994 default:
995 return $joinType;
996 }
997 }
998
1010 protected function tableNameWithAlias( $table, $alias = false ) {
1011 if ( is_string( $table ) ) {
1012 $quotedTable = $this->tableName( $table );
1013 } elseif ( $table instanceof Subquery ) {
1014 $quotedTable = (string)$table;
1015 } else {
1016 throw new InvalidArgumentException( "Table must be a string or Subquery" );
1017 }
1018
1019 if ( $alias === false ) {
1020 if ( $table instanceof Subquery ) {
1021 throw new InvalidArgumentException( "Subquery table missing alias" );
1022 }
1023 $quotedTableWithAnyAlias = $quotedTable;
1024 } elseif (
1025 $alias === $table &&
1026 (
1027 str_contains( $alias, '.' ) ||
1028 $this->tableName( $alias, 'raw' ) === $table
1029 )
1030 ) {
1031 $quotedTableWithAnyAlias = $quotedTable;
1032 } else {
1033 $quotedTableWithAnyAlias = $quotedTable . ' ' . $this->addIdentifierQuotes( $alias );
1034 }
1035
1036 return $quotedTableWithAnyAlias;
1037 }
1038
1039 public function tableName( string $name, $format = 'quoted' ) {
1040 $prefix = $this->currentDomain->getTablePrefix();
1041
1042 // Warn about table names that look qualified
1043 if (
1044 (
1045 str_contains( $name, '.' ) &&
1046 !preg_match( '/^information_schema\.[a-z_0-9]+$/', $name )
1047 ) ||
1048 ( $prefix !== '' && str_starts_with( $name, $prefix ) )
1049 ) {
1050 $this->logger->warning(
1051 __METHOD__ . ' called with qualified table ' . $name,
1052 [ 'db_log_category' => 'sql' ]
1053 );
1054 }
1055
1056 // Extract necessary database, schema, table identifiers and quote them as needed
1057 $formattedComponents = [];
1058 foreach ( $this->qualifiedTableComponents( $name ) as $component ) {
1059 if ( $format === 'quoted' ) {
1060 $formattedComponents[] = $this->addIdentifierQuotes( $component );
1061 } else {
1062 $formattedComponents[] = $component;
1063 }
1064 }
1065
1066 return implode( '.', $formattedComponents );
1067 }
1068
1091 public function qualifiedTableComponents( $name ) {
1092 $identifiers = $this->extractTableNameComponents( $name );
1093 if ( count( $identifiers ) > 3 ) {
1094 throw new DBLanguageError( "Too many components in table name '$name'" );
1095 }
1096 // Table alias config and prefixes only apply to unquoted single-identifier names
1097 if ( count( $identifiers ) == 1 && !$this->isQuotedIdentifier( $identifiers[0] ) ) {
1098 [ $table ] = $identifiers;
1099 if ( isset( $this->tableAliases[$table] ) ) {
1100 // This is an "alias" table that uses a different db/schema/prefix scheme
1101 $database = $this->tableAliases[$table]['dbname'];
1102 $schema = is_string( $this->tableAliases[$table]['schema'] )
1103 ? $this->tableAliases[$table]['schema']
1104 : $this->relationSchemaQualifier();
1105 $prefix = is_string( $this->tableAliases[$table]['prefix'] )
1106 ? $this->tableAliases[$table]['prefix']
1107 : $this->currentDomain->getTablePrefix();
1108 } else {
1109 // Use the current database domain to resolve the schema and prefix
1110 $database = '';
1111 $schema = $this->relationSchemaQualifier();
1112 $prefix = $this->currentDomain->getTablePrefix();
1113 }
1114 $qualifierIdentifiers = [ $database, $schema ];
1115 $tableIdentifier = $prefix . $table;
1116 } else {
1117 $qualifierIdentifiers = array_slice( $identifiers, 0, -1 );
1118 $tableIdentifier = end( $identifiers );
1119 }
1120
1121 $components = [];
1122 foreach ( $qualifierIdentifiers as $identifier ) {
1123 if ( $identifier !== null && $identifier !== '' ) {
1124 $components[] = $this->isQuotedIdentifier( $identifier )
1125 ? substr( $identifier, 1, -1 )
1126 : $identifier;
1127 }
1128 }
1129 $components[] = $this->isQuotedIdentifier( $tableIdentifier )
1130 ? substr( $tableIdentifier, 1, -1 )
1131 : $tableIdentifier;
1132
1133 return $components;
1134 }
1135
1142 public function extractTableNameComponents( string $name ) {
1143 $quoteChar = $this->getIdentifierQuoteChar();
1144 $components = [];
1145 foreach ( explode( '.', $name ) as $component ) {
1146 if ( $this->isQuotedIdentifier( $component ) ) {
1147 $unquotedComponent = substr( $component, 1, -1 );
1148 } else {
1149 $unquotedComponent = $component;
1150 }
1151 if ( str_contains( $unquotedComponent, $quoteChar ) ) {
1152 throw new DBLanguageError(
1153 'Table name component contains unexpected quote or dot character' );
1154 }
1155 $components[] = $component;
1156 }
1157 return $components;
1158 }
1159
1185 public function getDatabaseAndTableIdentifier( string $table ) {
1186 $components = $this->qualifiedTableComponents( $table );
1187 switch ( count( $components ) ) {
1188 case 1:
1189 return [ $this->currentDomain->getDatabase(), $components[0] ];
1190 case 2:
1191 return $components;
1192 default:
1193 throw new DBLanguageError( 'Too many table components' );
1194 }
1195 }
1196
1200 protected function relationSchemaQualifier() {
1201 return $this->currentDomain->getSchema();
1202 }
1203
1204 public function tableNamesN( ...$tables ) {
1205 $retVal = [];
1206
1207 foreach ( $tables as $name ) {
1208 $retVal[] = $this->tableName( $name );
1209 }
1210
1211 return $retVal;
1212 }
1213
1223 public function isQuotedIdentifier( $name ) {
1224 $quoteChar = $this->getIdentifierQuoteChar();
1225 return strlen( $name ) > 1 && $name[0] === $quoteChar && $name[-1] === $quoteChar;
1226 }
1227
1240 public function useIndexClause( $index ) {
1241 return '';
1242 }
1243
1252 public function ignoreIndexClause( $index ) {
1253 return '';
1254 }
1255
1266 protected function makeSelectOptions( array $options ) {
1267 $preLimitTail = $postLimitTail = '';
1268 $startOpts = '';
1269
1270 $noKeyOptions = [];
1271
1272 foreach ( $options as $key => $option ) {
1273 if ( is_numeric( $key ) ) {
1274 $noKeyOptions[$option] = true;
1275 }
1276 }
1277
1278 $preLimitTail .= $this->makeGroupByWithHaving( $options );
1279
1280 $preLimitTail .= $this->makeOrderBy( $options );
1281
1282 if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
1283 $postLimitTail .= ' FOR UPDATE';
1284 }
1285
1286 if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
1287 $postLimitTail .= ' LOCK IN SHARE MODE';
1288 }
1289
1290 if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
1291 $startOpts .= 'DISTINCT';
1292 }
1293
1294 # Various MySQL extensions
1295 if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
1296 $startOpts .= ' /*! STRAIGHT_JOIN */';
1297 }
1298
1299 if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
1300 $startOpts .= ' SQL_BIG_RESULT';
1301 }
1302
1303 if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
1304 $startOpts .= ' SQL_BUFFER_RESULT';
1305 }
1306
1307 if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
1308 $startOpts .= ' SQL_SMALL_RESULT';
1309 }
1310
1311 if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
1312 $startOpts .= ' SQL_CALC_FOUND_ROWS';
1313 }
1314
1315 return [ $startOpts, $preLimitTail, $postLimitTail ];
1316 }
1317
1326 protected function makeGroupByWithHaving( $options ) {
1327 $sql = '';
1328 if ( isset( $options['GROUP BY'] ) ) {
1329 $gb = is_array( $options['GROUP BY'] )
1330 ? implode( ',', $options['GROUP BY'] )
1331 : $options['GROUP BY'];
1332 $sql .= ' GROUP BY ' . $gb;
1333 }
1334 if ( isset( $options['HAVING'] ) ) {
1335 $having = is_array( $options['HAVING'] )
1336 ? $this->makeList( $options['HAVING'], self::LIST_AND )
1337 : $options['HAVING'];
1338 $sql .= ' HAVING ' . $having;
1339 }
1340
1341 return $sql;
1342 }
1343
1352 protected function makeOrderBy( $options ) {
1353 if ( isset( $options['ORDER BY'] ) ) {
1354 $ob = is_array( $options['ORDER BY'] )
1355 ? implode( ',', $options['ORDER BY'] )
1356 : $options['ORDER BY'];
1357
1358 return ' ORDER BY ' . $ob;
1359 }
1360
1361 return '';
1362 }
1363
1364 public function buildGroupConcatField(
1365 $delim, $tables, $field, $conds = '', $join_conds = []
1366 ) {
1367 $fld = "GROUP_CONCAT($field SEPARATOR " . $this->quoter->addQuotes( $delim ) . ')';
1368
1369 return '(' . $this->selectSQLText( $tables, $fld, $conds, static::CALLER_SUBQUERY, [], $join_conds ) . ')';
1370 }
1371
1372 public function buildSelectSubquery(
1373 $tables, $vars, $conds = '', $fname = __METHOD__,
1374 $options = [], $join_conds = []
1375 ) {
1376 return new Subquery(
1377 $this->selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds )
1378 );
1379 }
1380
1381 public function insertSqlText( $table, array $rows ) {
1382 $encTable = $this->tableName( $table );
1383 [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1384
1385 return [
1386 "INSERT INTO $encTable ($sqlColumns) VALUES $sqlTuples",
1387 "INSERT INTO $encTable ($sqlColumns) VALUES '?'"
1388 ];
1389 }
1390
1403 public function makeInsertLists( array $rows, $aliasPrefix = '', array $typeByColumn = [] ) {
1404 $firstRow = $rows[0];
1405 if ( !is_array( $firstRow ) || !$firstRow ) {
1406 throw new DBLanguageError( 'Got an empty row list or empty row' );
1407 }
1408 // List of columns that define the value tuple ordering
1409 $tupleColumns = array_keys( $firstRow );
1410
1411 $valueTuples = [];
1412 foreach ( $rows as $row ) {
1413 $rowColumns = array_keys( $row );
1414 // VALUES(...) requires a uniform correspondence of (column => value)
1415 if ( $rowColumns !== $tupleColumns ) {
1416 throw new DBLanguageError(
1417 'Got row columns (' . implode( ', ', $rowColumns ) . ') ' .
1418 'instead of expected (' . implode( ', ', $tupleColumns ) . ')'
1419 );
1420 }
1421 // Make the value tuple that defines this row
1422 $valueTuples[] = '(' . $this->makeList( array_values( $row ), self::LIST_COMMA ) . ')';
1423 }
1424
1425 $magicAliasFields = [];
1426 foreach ( $tupleColumns as $column ) {
1427 $magicAliasFields[] = $aliasPrefix . $column;
1428 }
1429
1430 return [
1431 $this->makeList( $tupleColumns, self::LIST_NAMES ),
1432 implode( ',', $valueTuples ),
1433 $this->makeList( $magicAliasFields, self::LIST_NAMES )
1434 ];
1435 }
1436
1437 public function insertNonConflictingSqlText( $table, array $rows ) {
1438 $encTable = $this->tableName( $table );
1439 [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1440 [ $sqlVerb, $sqlOpts ] = $this->makeInsertNonConflictingVerbAndOptions();
1441
1442 return [
1443 rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES $sqlTuples $sqlOpts" ),
1444 rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES '?' $sqlOpts" )
1445 ];
1446 }
1447
1453 return [ 'INSERT IGNORE INTO', '' ];
1454 }
1455
1457 $destTable,
1458 $srcTable,
1459 array $varMap,
1460 $conds,
1461 $fname,
1462 array $insertOptions,
1463 array $selectOptions,
1464 $selectJoinConds
1465 ) {
1466 [ $sqlVerb, $sqlOpts ] = $this->isFlagInOptions( 'IGNORE', $insertOptions )
1467 ? $this->makeInsertNonConflictingVerbAndOptions()
1468 : [ 'INSERT INTO', '' ];
1469 $encDstTable = $this->tableName( $destTable );
1470 $sqlDstColumns = implode( ',', array_keys( $varMap ) );
1471 $selectSql = $this->selectSQLText(
1472 $srcTable,
1473 array_values( $varMap ),
1474 $conds,
1475 $fname,
1476 $selectOptions,
1477 $selectJoinConds
1478 );
1479
1480 return rtrim( "$sqlVerb $encDstTable ($sqlDstColumns) $selectSql $sqlOpts" );
1481 }
1482
1489 public function isFlagInOptions( $option, array $options ) {
1490 foreach ( array_keys( $options, $option, true ) as $k ) {
1491 if ( is_int( $k ) ) {
1492 return true;
1493 }
1494 }
1495
1496 return false;
1497 }
1498
1506 public function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
1507 if ( !$rows ) {
1508 throw new DBLanguageError( "Empty row array" );
1509 } elseif ( !$uniqueKey ) {
1510 throw new DBLanguageError( "Empty unique key array" );
1511 }
1512
1513 if ( count( $uniqueKey ) == 1 ) {
1514 // Use a simple IN(...) clause
1515 $column = reset( $uniqueKey );
1516 $values = array_column( $rows, $column );
1517 if ( count( $values ) !== count( $rows ) ) {
1518 throw new DBLanguageError( "Missing values for unique key ($column)" );
1519 }
1520
1521 return $this->makeList( [ $column => $values ], self::LIST_AND );
1522 }
1523
1524 $nullByUniqueKeyColumn = array_fill_keys( $uniqueKey, null );
1525
1526 $orConds = [];
1527 foreach ( $rows as $row ) {
1528 $rowKeyMap = array_intersect_key( $row, $nullByUniqueKeyColumn );
1529 if ( count( $rowKeyMap ) != count( $uniqueKey ) ) {
1530 throw new DBLanguageError(
1531 "Missing values for unique key (" . implode( ',', $uniqueKey ) . ")"
1532 );
1533 }
1534 $orConds[] = $this->makeList( $rowKeyMap, self::LIST_AND );
1535 }
1536
1537 return count( $orConds ) > 1
1538 ? $this->makeList( $orConds, self::LIST_OR )
1539 : $orConds[0];
1540 }
1541
1542 public function deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds ) {
1543 if ( !$conds ) {
1544 throw new DBLanguageError( __METHOD__ . ' called with empty $conds' );
1545 }
1546
1547 $delTable = $this->tableName( $delTable );
1548 $joinTable = $this->tableName( $joinTable );
1549 $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
1550 if ( $conds != '*' ) {
1551 $sql .= 'WHERE ' . $this->makeList( $conds, self::LIST_AND );
1552 }
1553 $sql .= ')';
1554
1555 return $sql;
1556 }
1557
1563 public function deleteSqlText( $table, $conds ) {
1564 $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1565 if ( !$isCondValid ) {
1566 throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1567 }
1568
1569 $encTable = $this->tableName( $table );
1570 $sql = "DELETE FROM $encTable";
1571
1572 $condsSql = '';
1573 $cleanCondsSql = '';
1574 if ( $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1575 $cleanCondsSql = ' WHERE ' . $this->scrubArray( $conds );
1576 if ( is_array( $conds ) ) {
1577 $conds = $this->makeList( $conds, self::LIST_AND );
1578 }
1579 $condsSql .= ' WHERE ' . $conds;
1580 }
1581 return new Query(
1582 $sql . $condsSql,
1583 self::QUERY_CHANGE_ROWS,
1584 'DELETE',
1585 $table,
1586 $sql . $cleanCondsSql
1587 );
1588 }
1589
1594 private function scrubArray( $array, int $listType = self::LIST_AND ): string {
1595 if ( is_array( $array ) ) {
1596 $scrubbedArray = [];
1597 foreach ( $array as $key => $value ) {
1598 if ( $value instanceof IExpression ) {
1599 $scrubbedArray[$key] = $value->toGeneralizedSql();
1600 } else {
1601 $scrubbedArray[$key] = '?';
1602 }
1603 }
1604 return $this->makeList( $scrubbedArray, $listType );
1605 }
1606 return '?';
1607 }
1608
1609 public function updateSqlText( $table, $set, $conds, $options ) {
1610 $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1611 if ( !$isCondValid ) {
1612 throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1613 }
1614 $encTable = $this->tableName( $table );
1615 $opts = $this->makeUpdateOptions( $options );
1616 $sql = "UPDATE $opts $encTable";
1617 $condsSql = " SET " . $this->makeList( $set, self::LIST_SET );
1618 $cleanCondsSql = " SET " . $this->scrubArray( $set, self::LIST_SET );
1619
1620 if ( $conds && $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1621 $cleanCondsSql .= ' WHERE ' . $this->scrubArray( $conds );
1622 if ( is_array( $conds ) ) {
1623 $conds = $this->makeList( $conds, self::LIST_AND );
1624 }
1625 $condsSql .= ' WHERE ' . $conds;
1626 }
1627 return new Query(
1628 $sql . $condsSql,
1629 self::QUERY_CHANGE_ROWS,
1630 'UPDATE',
1631 $table,
1632 $sql . $cleanCondsSql
1633 );
1634 }
1635
1642 protected function makeUpdateOptions( $options ) {
1643 $opts = $this->makeUpdateOptionsArray( $options );
1644
1645 return implode( ' ', $opts );
1646 }
1647
1654 protected function makeUpdateOptionsArray( $options ) {
1655 $options = $this->normalizeOptions( $options );
1656
1657 $opts = [];
1658
1659 if ( in_array( 'IGNORE', $options ) ) {
1660 $opts[] = 'IGNORE';
1661 }
1662
1663 return $opts;
1664 }
1665
1671 final public function normalizeOptions( $options ) {
1672 if ( is_array( $options ) ) {
1673 return $options;
1674 } elseif ( is_string( $options ) ) {
1675 return ( $options === '' ) ? [] : [ $options ];
1676 } else {
1677 throw new DBLanguageError( __METHOD__ . ': expected string or array' );
1678 }
1679 }
1680
1681 public function dropTableSqlText( $table ) {
1682 // https://mariadb.com/kb/en/drop-table/
1683 // https://dev.mysql.com/doc/refman/8.0/en/drop-table.html
1684 // https://www.postgresql.org/docs/9.2/sql-truncate.html
1685 return "DROP TABLE " . $this->tableName( $table ) . " CASCADE";
1686 }
1687
1693 public function getQueryVerb( $sql ) {
1694 wfDeprecated( __METHOD__, '1.42' );
1695 return QueryBuilderFromRawSql::buildQuery( $sql, 0 )->getVerb();
1696 }
1697
1710 public function isTransactableQuery( Query $sql ) {
1711 return !in_array(
1712 $sql->getVerb(),
1713 [
1714 'BEGIN',
1715 'ROLLBACK',
1716 'ROLLBACK TO SAVEPOINT',
1717 'COMMIT',
1718 'SET',
1719 'SHOW',
1720 'CREATE',
1721 'ALTER',
1722 'USE',
1723 'SHOW'
1724 ],
1725 true
1726 );
1727 }
1728
1729 public function buildExcludedValue( $column ) {
1730 /* @see Database::upsert() */
1731 // This can be treated like a single value since __VALS is a single row table
1732 return "(SELECT __$column FROM __VALS)";
1733 }
1734
1735 public function savepointSqlText( $identifier ) {
1736 return 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1737 }
1738
1739 public function releaseSavepointSqlText( $identifier ) {
1740 return 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1741 }
1742
1743 public function rollbackToSavepointSqlText( $identifier ) {
1744 return 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1745 }
1746
1747 public function rollbackSqlText() {
1748 return 'ROLLBACK';
1749 }
1750
1751 public function dispatchingInsertSqlText( $table, $rows, $options ) {
1752 $rows = $this->normalizeRowArray( $rows );
1753 if ( !$rows ) {
1754 return false;
1755 }
1756
1757 $options = $this->normalizeOptions( $options );
1758 if ( $this->isFlagInOptions( 'IGNORE', $options ) ) {
1759 [ $sql, $cleanSql ] = $this->insertNonConflictingSqlText( $table, $rows );
1760 } else {
1761 [ $sql, $cleanSql ] = $this->insertSqlText( $table, $rows );
1762 }
1763 return new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table, $cleanSql );
1764 }
1765
1771 final protected function normalizeRowArray( array $rowOrRows ) {
1772 if ( !$rowOrRows ) {
1773 $rows = [];
1774 } elseif ( isset( $rowOrRows[0] ) ) {
1775 $rows = $rowOrRows;
1776 } else {
1777 $rows = [ $rowOrRows ];
1778 }
1779
1780 foreach ( $rows as $row ) {
1781 if ( !is_array( $row ) ) {
1782 throw new DBLanguageError( "Got non-array in row array" );
1783 } elseif ( !$row ) {
1784 throw new DBLanguageError( "Got empty array in row array" );
1785 }
1786 }
1787
1788 return $rows;
1789 }
1790
1799 final public function normalizeUpsertParams( $uniqueKeys, &$rows ) {
1800 $rows = $this->normalizeRowArray( $rows );
1801 if ( !$uniqueKeys ) {
1802 throw new DBLanguageError( 'No unique key specified for upsert/replace' );
1803 }
1804 $uniqueKey = $this->normalizeUpsertKeys( $uniqueKeys );
1805 $this->assertValidUpsertRowArray( $rows, $uniqueKey );
1806
1807 return $uniqueKey;
1808 }
1809
1816 final public function normalizeConditions( $conds, $fname ) {
1817 if ( $conds === null || $conds === false ) {
1818 $this->logger->warning(
1819 __METHOD__
1820 . ' called from '
1821 . $fname
1822 . ' with incorrect parameters: $conds must be a string or an array',
1823 [ 'db_log_category' => 'sql' ]
1824 );
1825 return [];
1826 } elseif ( $conds === '' ) {
1827 return [];
1828 }
1829
1830 return is_array( $conds ) ? $conds : [ $conds ];
1831 }
1832
1838 private function normalizeUpsertKeys( $uniqueKeys ) {
1839 if ( is_string( $uniqueKeys ) ) {
1840 return [ $uniqueKeys ];
1841 } elseif ( !is_array( $uniqueKeys ) ) {
1842 throw new DBLanguageError( 'Invalid unique key array' );
1843 } else {
1844 if ( count( $uniqueKeys ) !== 1 || !isset( $uniqueKeys[0] ) ) {
1845 throw new DBLanguageError(
1846 "The unique key array should contain a single unique index" );
1847 }
1848
1849 $uniqueKey = $uniqueKeys[0];
1850 if ( is_string( $uniqueKey ) ) {
1851 // Passing a list of strings for single-column unique keys is too
1852 // easily confused with passing the columns of composite unique key
1853 $this->logger->warning( __METHOD__ .
1854 " called with deprecated parameter style: " .
1855 "the unique key array should be a string or array of string arrays",
1856 [
1857 'exception' => new RuntimeException(),
1858 'db_log_category' => 'sql',
1859 ] );
1860 return $uniqueKeys;
1861 } elseif ( is_array( $uniqueKey ) ) {
1862 return $uniqueKey;
1863 } else {
1864 throw new DBLanguageError( 'Invalid unique key array entry' );
1865 }
1866 }
1867 }
1868
1874 final protected function assertValidUpsertRowArray( array $rows, array $uniqueKey ) {
1875 foreach ( $rows as $row ) {
1876 foreach ( $uniqueKey as $column ) {
1877 if ( !isset( $row[$column] ) ) {
1878 throw new DBLanguageError(
1879 "NULL/absent values for unique key (" . implode( ',', $uniqueKey ) . ")"
1880 );
1881 }
1882 }
1883 }
1884 }
1885
1892 final public function assertValidUpsertSetArray(
1893 array $set,
1894 array $uniqueKey,
1895 array $rows
1896 ) {
1897 if ( !$set ) {
1898 throw new DBLanguageError( "Update assignment list can't be empty for upsert" );
1899 }
1900
1901 // Sloppy callers might construct the SET array using the ROW array, leaving redundant
1902 // column definitions for unique key columns. Detect this for backwards compatibility.
1903 $soleRow = ( count( $rows ) == 1 ) ? reset( $rows ) : null;
1904 // Disallow value changes for any columns in the unique key. This avoids additional
1905 // insertion order dependencies that are unwieldy and difficult to implement efficiently
1906 // in PostgreSQL.
1907 foreach ( $set as $k => $v ) {
1908 if ( is_string( $k ) ) {
1909 // Key is a column name and value is a literal (e.g. string, int, null, ...)
1910 if ( in_array( $k, $uniqueKey, true ) ) {
1911 if ( $soleRow && array_key_exists( $k, $soleRow ) && $soleRow[$k] === $v ) {
1912 $this->logger->warning(
1913 __METHOD__ . " called with redundant assignment to column '$k'",
1914 [
1915 'exception' => new RuntimeException(),
1916 'db_log_category' => 'sql',
1917 ]
1918 );
1919 } else {
1920 throw new DBLanguageError(
1921 "Cannot reassign column '$k' since it belongs to the provided unique key"
1922 );
1923 }
1924 }
1925 } elseif ( preg_match( '/^([a-zA-Z0-9_]+)\s*=/', $v, $m ) ) {
1926 // Value is of the form "<unquoted alphanumeric column> = <SQL expression>"
1927 if ( in_array( $m[1], $uniqueKey, true ) ) {
1928 throw new DBLanguageError(
1929 "Cannot reassign column '{$m[1]}' since it belongs to the provided unique key"
1930 );
1931 }
1932 }
1933 }
1934 }
1935
1940 final public function extractSingleFieldFromList( $var ) {
1941 if ( is_array( $var ) ) {
1942 if ( !$var ) {
1943 $column = null;
1944 } elseif ( count( $var ) == 1 ) {
1945 $column = $var[0] ?? reset( $var );
1946 } else {
1947 throw new DBLanguageError( __METHOD__ . ': got multiple columns' );
1948 }
1949 } else {
1950 $column = $var;
1951 }
1952
1953 return $column;
1954 }
1955
1956 public function setSchemaVars( $vars ) {
1957 $this->schemaVars = is_array( $vars ) ? $vars : null;
1958 }
1959
1966 protected function getSchemaVars() {
1967 return $this->schemaVars ?? $this->getDefaultSchemaVars();
1968 }
1969
1978 protected function getDefaultSchemaVars() {
1979 return [];
1980 }
1981
2002 public function replaceVars( $ins ) {
2003 $vars = $this->getSchemaVars();
2004 return preg_replace_callback(
2005 '!
2006 /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
2007 \'\{\$ (\w+) }\' | # 3. addQuotes
2008 `\{\$ (\w+) }` | # 4. addIdentifierQuotes
2009 /\*\$ (\w+) \*/ # 5. leave unencoded
2010 !x',
2011 function ( $m ) use ( $vars ) {
2012 // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
2013 // check for both nonexistent keys *and* the empty string.
2014 if ( isset( $m[1] ) && $m[1] !== '' ) {
2015 if ( $m[1] === 'i' ) {
2016 return $this->indexName( $m[2] );
2017 } else {
2018 return $this->tableName( $m[2] );
2019 }
2020 } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
2021 return $this->quoter->addQuotes( $vars[$m[3]] );
2022 } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
2023 return $this->addIdentifierQuotes( $vars[$m[4]] );
2024 } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
2025 return $vars[$m[5]];
2026 } else {
2027 return $m[0];
2028 }
2029 },
2030 $ins
2031 );
2032 }
2033
2034 public function lockSQLText( $lockName, $timeout ) {
2035 throw new RuntimeException( 'locking must be implemented in subclasses' );
2036 }
2037
2038 public function lockIsFreeSQLText( $lockName ) {
2039 throw new RuntimeException( 'locking must be implemented in subclasses' );
2040 }
2041
2042 public function unlockSQLText( $lockName ) {
2043 throw new RuntimeException( 'locking must be implemented in subclasses' );
2044 }
2045}
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:82
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.
Holds information on Query to be executed.
Definition Query.php:31
Raw SQL value to be used in query builders.
Interface for query language.