MediaWiki REL1_39
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;
33use Wikimedia\Timestamp\ConvertibleTimestamp;
34
42class SQLPlatform implements ISQLPlatform {
44 protected $tableAliases = [];
46 protected $indexAliases = [];
48 protected $currentDomain;
50 protected $schemaVars;
52 protected $quoter;
54 protected $logger;
56 protected $errorLogger;
57
58 public function __construct(
60 LoggerInterface $logger = null,
62 $errorLogger = null
63
64 ) {
65 $this->quoter = $quoter;
66 $this->logger = $logger ?? new NullLogger();
67 $this->currentDomain = $currentDomain;
68 $this->errorLogger = $errorLogger ?? static function ( Throwable $e ) {
69 trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
70 };
71 }
72
77 public function bitNot( $field ) {
78 return "(~$field)";
79 }
80
85 public function bitAnd( $fieldLeft, $fieldRight ) {
86 return "($fieldLeft & $fieldRight)";
87 }
88
93 public function bitOr( $fieldLeft, $fieldRight ) {
94 return "($fieldLeft | $fieldRight)";
95 }
96
101 public function addIdentifierQuotes( $s ) {
102 return '"' . str_replace( '"', '""', $s ) . '"';
103 }
104
108 public function buildGreatest( $fields, $values ) {
109 return $this->buildSuperlative( 'GREATEST', $fields, $values );
110 }
111
115 public function buildLeast( $fields, $values ) {
116 return $this->buildSuperlative( 'LEAST', $fields, $values );
117 }
118
133 protected function buildSuperlative( $sqlfunc, $fields, $values ) {
134 $fields = is_array( $fields ) ? $fields : [ $fields ];
135 $values = is_array( $values ) ? $values : [ $values ];
136
137 $encValues = [];
138 foreach ( $fields as $alias => $field ) {
139 if ( is_int( $alias ) ) {
140 $encValues[] = $this->addIdentifierQuotes( $field );
141 } else {
142 $encValues[] = $field; // expression
143 }
144 }
145 foreach ( $values as $value ) {
146 if ( is_int( $value ) || is_float( $value ) ) {
147 $encValues[] = $value;
148 } elseif ( is_string( $value ) ) {
149 $encValues[] = $this->quoter->addQuotes( $value );
150 } elseif ( $value === null ) {
151 throw new DBLanguageError( 'Null value in superlative' );
152 } else {
153 throw new DBLanguageError( 'Unexpected value type in superlative' );
154 }
155 }
156
157 return $sqlfunc . '(' . implode( ',', $encValues ) . ')';
158 }
159
160 public function makeList( array $a, $mode = self::LIST_COMMA ) {
161 $first = true;
162 $list = '';
163
164 foreach ( $a as $field => $value ) {
165 if ( $first ) {
166 $first = false;
167 } else {
168 if ( $mode == self::LIST_AND ) {
169 $list .= ' AND ';
170 } elseif ( $mode == self::LIST_OR ) {
171 $list .= ' OR ';
172 } else {
173 $list .= ',';
174 }
175 }
176
177 if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
178 $list .= "($value)";
179 } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
180 $list .= "$value";
181 } elseif (
182 ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
183 ) {
184 // Remove null from array to be handled separately if found
185 $includeNull = false;
186 foreach ( array_keys( $value, null, true ) as $nullKey ) {
187 $includeNull = true;
188 unset( $value[$nullKey] );
189 }
190 if ( count( $value ) == 0 && !$includeNull ) {
191 throw new InvalidArgumentException(
192 __METHOD__ . ": empty input for field $field" );
193 } elseif ( count( $value ) == 0 ) {
194 // only check if $field is null
195 $list .= "$field IS NULL";
196 } else {
197 // IN clause contains at least one valid element
198 if ( $includeNull ) {
199 // Group subconditions to ensure correct precedence
200 $list .= '(';
201 }
202 if ( count( $value ) == 1 ) {
203 // Special-case single values, as IN isn't terribly efficient
204 // Don't necessarily assume the single key is 0; we don't
205 // enforce linear numeric ordering on other arrays here.
206 $value = array_values( $value )[0];
207 $list .= $field . " = " . $this->quoter->addQuotes( $value );
208 } else {
209 $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
210 }
211 // if null present in array, append IS NULL
212 if ( $includeNull ) {
213 $list .= " OR $field IS NULL)";
214 }
215 }
216 } elseif ( $value === null ) {
217 if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
218 $list .= "$field IS ";
219 } elseif ( $mode == self::LIST_SET ) {
220 $list .= "$field = ";
221 }
222 $list .= 'NULL';
223 } else {
224 if (
225 $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
226 ) {
227 $list .= "$field = ";
228 }
229 $list .= $mode == self::LIST_NAMES ? $value : $this->quoter->addQuotes( $value );
230 }
231 }
232
233 return $list;
234 }
235
236 public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
237 $conds = [];
238
239 foreach ( $data as $base => $sub ) {
240 if ( count( $sub ) ) {
241 $conds[] = $this->makeList(
242 [ $baseKey => $base, $subKey => array_map( 'strval', array_keys( $sub ) ) ],
243 self::LIST_AND
244 );
245 }
246 }
247
248 if ( $conds ) {
249 return $this->makeList( $conds, self::LIST_OR );
250 } else {
251 // Nothing to search for...
252 return false;
253 }
254 }
255
256 public function factorConds( $condsArray ) {
257 if ( count( $condsArray ) === 0 ) {
258 throw new InvalidArgumentException(
259 __METHOD__ . ": empty condition array" );
260 }
261 $condsByFieldSet = [];
262 foreach ( $condsArray as $conds ) {
263 if ( !count( $conds ) ) {
264 throw new InvalidArgumentException(
265 __METHOD__ . ": empty condition subarray" );
266 }
267 $fieldKey = implode( ',', array_keys( $conds ) );
268 $condsByFieldSet[$fieldKey][] = $conds;
269 }
270 $result = '';
271 foreach ( $condsByFieldSet as $conds ) {
272 if ( $result !== '' ) {
273 $result .= ' OR ';
274 }
275 $result .= $this->factorCondsWithCommonFields( $conds );
276 }
277 return $result;
278 }
279
287 private function factorCondsWithCommonFields( $condsArray ) {
288 $first = $condsArray[array_key_first( $condsArray )];
289 if ( count( $first ) === 1 ) {
290 // IN clause
291 $field = array_key_first( $first );
292 $values = [];
293 foreach ( $condsArray as $conds ) {
294 $values[] = $conds[$field];
295 }
296 return $this->makeList( [ $field => $values ], self::LIST_AND );
297 }
298
299 $field1 = array_key_first( $first );
300 $nullExpressions = [];
301 $expressionsByField1 = [];
302 foreach ( $condsArray as $conds ) {
303 $value1 = $conds[$field1];
304 unset( $conds[$field1] );
305 if ( $value1 === null ) {
306 $nullExpressions[] = $conds;
307 } else {
308 $expressionsByField1[$value1][] = $conds;
309 }
310
311 }
312 $wrap = false;
313 $result = '';
314 foreach ( $expressionsByField1 as $value1 => $expressions ) {
315 if ( $result !== '' ) {
316 $result .= ' OR ';
317 $wrap = true;
318 }
319 $factored = $this->factorCondsWithCommonFields( $expressions );
320 $result .= "($field1 = " . $this->quoter->addQuotes( $value1 ) .
321 " AND $factored)";
322 }
323 if ( count( $nullExpressions ) ) {
324 $factored = $this->factorCondsWithCommonFields( $nullExpressions );
325 if ( $result !== '' ) {
326 $result .= ' OR ';
327 $wrap = true;
328 }
329 $result .= "($field1 IS NULL AND $factored)";
330 }
331 if ( $wrap ) {
332 return "($result)";
333 } else {
334 return $result;
335 }
336 }
337
342 public function buildConcat( $stringList ) {
343 return 'CONCAT(' . implode( ',', $stringList ) . ')';
344 }
345
350 public function limitResult( $sql, $limit, $offset = false ) {
351 if ( !is_numeric( $limit ) ) {
352 throw new DBLanguageError(
353 "Invalid non-numeric limit passed to " . __METHOD__
354 );
355 }
356 // This version works in MySQL and SQLite. It will very likely need to be
357 // overridden for most other RDBMS subclasses.
358 return "$sql LIMIT "
359 . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
360 . "{$limit} ";
361 }
362
369 public function escapeLikeInternal( $s, $escapeChar = '`' ) {
370 return str_replace(
371 [ $escapeChar, '%', '_' ],
372 [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
373 $s
374 );
375 }
376
381 public function buildLike( $param, ...$params ) {
382 if ( is_array( $param ) ) {
383 $params = $param;
384 } else {
385 $params = func_get_args();
386 }
387
388 $s = '';
389
390 // We use ` instead of \ as the default LIKE escape character, since addQuotes()
391 // may escape backslashes, creating problems of double escaping. The `
392 // character has good cross-DBMS compatibility, avoiding special operators
393 // in MS SQL like ^ and %
394 $escapeChar = '`';
395
396 foreach ( $params as $value ) {
397 if ( $value instanceof LikeMatch ) {
398 $s .= $value->toString();
399 } else {
400 $s .= $this->escapeLikeInternal( $value, $escapeChar );
401 }
402 }
403
404 return ' LIKE ' .
405 $this->quoter->addQuotes( $s ) . ' ESCAPE ' . $this->quoter->addQuotes( $escapeChar ) . ' ';
406 }
407
408 public function anyChar() {
409 return new LikeMatch( '_' );
410 }
411
412 public function anyString() {
413 return new LikeMatch( '%' );
414 }
415
420 public function unionSupportsOrderAndLimit() {
421 return true; // True for almost every DB supported
422 }
423
428 public function unionQueries( $sqls, $all ) {
429 $glue = $all ? ') UNION ALL (' : ') UNION (';
430
431 return '(' . implode( $glue, $sqls ) . ')';
432 }
433
438 public function conditional( $cond, $caseTrueExpression, $caseFalseExpression ) {
439 if ( is_array( $cond ) ) {
440 $cond = $this->makeList( $cond, self::LIST_AND );
441 }
442
443 return "(CASE WHEN $cond THEN $caseTrueExpression ELSE $caseFalseExpression END)";
444 }
445
450 public function strreplace( $orig, $old, $new ) {
451 return "REPLACE({$orig}, {$old}, {$new})";
452 }
453
458 public function timestamp( $ts = 0 ) {
459 $t = new ConvertibleTimestamp( $ts );
460 // Let errors bubble up to avoid putting garbage in the DB
461 return $t->getTimestamp( TS_MW );
462 }
463
464 public function timestampOrNull( $ts = null ) {
465 if ( $ts === null ) {
466 return null;
467 } else {
468 return $this->timestamp( $ts );
469 }
470 }
471
476 public function getInfinity() {
477 return 'infinity';
478 }
479
480 public function encodeExpiry( $expiry ) {
481 return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
482 ? $this->getInfinity()
483 : $this->timestamp( $expiry );
484 }
485
486 public function decodeExpiry( $expiry, $format = TS_MW ) {
487 if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
488 return 'infinity';
489 }
490
491 return ConvertibleTimestamp::convert( $format, $expiry );
492 }
493
498 public function buildSubstring( $input, $startPosition, $length = null ) {
499 $this->assertBuildSubstringParams( $startPosition, $length );
500 $functionBody = "$input FROM $startPosition";
501 if ( $length !== null ) {
502 $functionBody .= " FOR $length";
503 }
504 return 'SUBSTRING(' . $functionBody . ')';
505 }
506
519 protected function assertBuildSubstringParams( $startPosition, $length ) {
520 if ( $startPosition === 0 ) {
521 // The DBMSs we support use 1-based indexing here.
522 throw new InvalidArgumentException( 'Use 1 as $startPosition for the beginning of the string' );
523 }
524 if ( !is_int( $startPosition ) || $startPosition < 0 ) {
525 throw new InvalidArgumentException(
526 '$startPosition must be a positive integer'
527 );
528 }
529 if ( !( is_int( $length ) && $length >= 0 || $length === null ) ) {
530 throw new InvalidArgumentException(
531 '$length must be null or an integer greater than or equal to 0'
532 );
533 }
534 }
535
540 public function buildStringCast( $field ) {
541 // In theory this should work for any standards-compliant
542 // SQL implementation, although it may not be the best way to do it.
543 return "CAST( $field AS CHARACTER )";
544 }
545
550 public function buildIntegerCast( $field ) {
551 return 'CAST( ' . $field . ' AS INTEGER )';
552 }
553
558 public function implicitOrderby() {
559 return true;
560 }
561
570 public function indexName( $index ) {
571 return $this->indexAliases[$index] ?? $index;
572 }
573
578 public function setTableAliases( array $aliases ) {
579 $this->tableAliases = $aliases;
580 }
581
586 public function setIndexAliases( array $aliases ) {
587 $this->indexAliases = $aliases;
588 }
589
593 public function getTableAliases() {
594 return $this->tableAliases;
595 }
596
597 public function setPrefix( $prefix ) {
598 $this->currentDomain = new DatabaseDomain(
599 $this->currentDomain->getDatabase(),
600 $this->currentDomain->getSchema(),
601 $prefix
602 );
603 }
604
606 $this->currentDomain = $currentDomain;
607 }
608
613 public function selectSQLText(
614 $table, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
615 ) {
616 if ( is_array( $table ) ) {
617 $tables = $table;
618 } elseif ( $table === '' || $table === null || $table === false ) {
619 $tables = [];
620 } elseif ( is_string( $table ) ) {
621 $tables = [ $table ];
622 } else {
623 throw new DBLanguageError( __METHOD__ . ' called with incorrect table parameter' );
624 }
625
626 if ( is_array( $vars ) ) {
627 $fields = implode( ',', $this->fieldNamesWithAlias( $vars ) );
628 } else {
629 $fields = $vars;
630 }
631
632 $options = (array)$options;
633
634 $useIndexByTable = $options['USE INDEX'] ?? [];
635 if ( !is_array( $useIndexByTable ) ) {
636 if ( count( $tables ) <= 1 ) {
637 $useIndexByTable = [ reset( $tables ) => $useIndexByTable ];
638 } else {
639 $e = new DBLanguageError( __METHOD__ . " got ambiguous USE INDEX ($fname)" );
640 ( $this->errorLogger )( $e );
641 }
642 }
643
644 $ignoreIndexByTable = $options['IGNORE INDEX'] ?? [];
645 if ( !is_array( $ignoreIndexByTable ) ) {
646 if ( count( $tables ) <= 1 ) {
647 $ignoreIndexByTable = [ reset( $tables ) => $ignoreIndexByTable ];
648 } else {
649 $e = new DBLanguageError( __METHOD__ . " got ambiguous IGNORE INDEX ($fname)" );
650 ( $this->errorLogger )( $e );
651 }
652 }
653
654 if (
655 $this->selectOptionsIncludeLocking( $options ) &&
656 $this->selectFieldsOrOptionsAggregate( $vars, $options )
657 ) {
658 // Some DB types (e.g. postgres) disallow FOR UPDATE with aggregate
659 // functions. Discourage use of such queries to encourage compatibility.
660 $this->logger->warning(
661 __METHOD__ . ": aggregation used with a locking SELECT ($fname)"
662 );
663 }
664
665 if ( count( $tables ) ) {
666 $from = ' FROM ' . $this->tableNamesWithIndexClauseOrJOIN(
667 $tables,
668 $useIndexByTable,
669 $ignoreIndexByTable,
670 $join_conds
671 );
672 } else {
673 $from = '';
674 }
675
676 list( $startOpts, $preLimitTail, $postLimitTail ) = $this->makeSelectOptions( $options );
677
678 if ( is_array( $conds ) ) {
679 $where = $this->makeList( $conds, self::LIST_AND );
680 } elseif ( $conds === null || $conds === false ) {
681 $where = '';
682 $this->logger->warning(
683 __METHOD__
684 . ' called from '
685 . $fname
686 . ' with incorrect parameters: $conds must be a string or an array',
687 [ 'db_log_category' => 'sql' ]
688 );
689 } elseif ( is_string( $conds ) ) {
690 $where = $conds;
691 } else {
692 throw new DBLanguageError( __METHOD__ . ' called with incorrect parameters' );
693 }
694
695 // Keep historical extra spaces after FROM to avoid testing failures
696 if ( $where === '' || $where === '*' ) {
697 $sql = "SELECT $startOpts $fields $from $preLimitTail";
698 } else {
699 $sql = "SELECT $startOpts $fields $from WHERE $where $preLimitTail";
700 }
701
702 if ( isset( $options['LIMIT'] ) ) {
703 $sql = $this->limitResult( $sql, $options['LIMIT'], $options['OFFSET'] ?? false );
704 }
705 $sql = "$sql $postLimitTail";
706
707 if ( isset( $options['EXPLAIN'] ) ) {
708 $sql = 'EXPLAIN ' . $sql;
709 }
710
711 return $sql;
712 }
713
718 private function selectOptionsIncludeLocking( $options ) {
719 $options = (array)$options;
720 foreach ( [ 'FOR UPDATE', 'LOCK IN SHARE MODE' ] as $lock ) {
721 if ( in_array( $lock, $options, true ) ) {
722 return true;
723 }
724 }
725
726 return false;
727 }
728
734 private function selectFieldsOrOptionsAggregate( $fields, $options ) {
735 foreach ( (array)$options as $key => $value ) {
736 if ( is_string( $key ) ) {
737 if ( preg_match( '/^(?:GROUP BY|HAVING)$/i', $key ) ) {
738 return true;
739 }
740 } elseif ( is_string( $value ) ) {
741 if ( preg_match( '/^(?:DISTINCT|DISTINCTROW)$/i', $value ) ) {
742 return true;
743 }
744 }
745 }
746
747 $regex = '/^(?:COUNT|MIN|MAX|SUM|GROUP_CONCAT|LISTAGG|ARRAY_AGG)\s*\\(/i';
748 foreach ( (array)$fields as $field ) {
749 if ( is_string( $field ) && preg_match( $regex, $field ) ) {
750 return true;
751 }
752 }
753
754 return false;
755 }
756
763 protected function fieldNamesWithAlias( $fields ) {
764 $retval = [];
765 foreach ( $fields as $alias => $field ) {
766 if ( is_numeric( $alias ) ) {
767 $alias = $field;
768 }
769 $retval[] = $this->fieldNameWithAlias( $field, $alias );
770 }
771
772 return $retval;
773 }
774
784 public function fieldNameWithAlias( $name, $alias = false ) {
785 if ( !$alias || (string)$alias === (string)$name ) {
786 return $name;
787 } else {
788 return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
789 }
790 }
791
803 $tables,
804 $use_index = [],
805 $ignore_index = [],
806 $join_conds = []
807 ) {
808 $ret = [];
809 $retJOIN = [];
810 $use_index = (array)$use_index;
811 $ignore_index = (array)$ignore_index;
812 $join_conds = (array)$join_conds;
813
814 foreach ( $tables as $alias => $table ) {
815 if ( !is_string( $alias ) ) {
816 // No alias? Set it equal to the table name
817 $alias = $table;
818 }
819
820 if ( is_array( $table ) ) {
821 // A parenthesized group
822 if ( count( $table ) > 1 ) {
823 $joinedTable = '(' .
825 $table, $use_index, $ignore_index, $join_conds ) . ')';
826 } else {
827 // Degenerate case
828 $innerTable = reset( $table );
829 $innerAlias = key( $table );
830 $joinedTable = $this->tableNameWithAlias(
831 $innerTable,
832 is_string( $innerAlias ) ? $innerAlias : $innerTable
833 );
834 }
835 } else {
836 $joinedTable = $this->tableNameWithAlias( $table, $alias );
837 }
838
839 // Is there a JOIN clause for this table?
840 if ( isset( $join_conds[$alias] ) ) {
841 Assert::parameterType( 'array', $join_conds[$alias], "join_conds[$alias]" );
842 list( $joinType, $conds ) = $join_conds[$alias];
843 $tableClause = $this->normalizeJoinType( $joinType );
844 $tableClause .= ' ' . $joinedTable;
845 if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
846 $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
847 if ( $use != '' ) {
848 $tableClause .= ' ' . $use;
849 }
850 }
851 if ( isset( $ignore_index[$alias] ) ) { // has IGNORE INDEX?
852 $ignore = $this->ignoreIndexClause(
853 implode( ',', (array)$ignore_index[$alias] ) );
854 if ( $ignore != '' ) {
855 $tableClause .= ' ' . $ignore;
856 }
857 }
858 $on = $this->makeList( (array)$conds, self::LIST_AND );
859 if ( $on != '' ) {
860 $tableClause .= ' ON (' . $on . ')';
861 }
862
863 $retJOIN[] = $tableClause;
864 } elseif ( isset( $use_index[$alias] ) ) {
865 // Is there an INDEX clause for this table?
866 $tableClause = $joinedTable;
867 $tableClause .= ' ' . $this->useIndexClause(
868 implode( ',', (array)$use_index[$alias] )
869 );
870
871 $ret[] = $tableClause;
872 } elseif ( isset( $ignore_index[$alias] ) ) {
873 // Is there an INDEX clause for this table?
874 $tableClause = $joinedTable;
875 $tableClause .= ' ' . $this->ignoreIndexClause(
876 implode( ',', (array)$ignore_index[$alias] )
877 );
878
879 $ret[] = $tableClause;
880 } else {
881 $tableClause = $joinedTable;
882
883 $ret[] = $tableClause;
884 }
885 }
886
887 // We can't separate explicit JOIN clauses with ',', use ' ' for those
888 $implicitJoins = implode( ',', $ret );
889 $explicitJoins = implode( ' ', $retJOIN );
890
891 // Compile our final table clause
892 return implode( ' ', [ $implicitJoins, $explicitJoins ] );
893 }
894
903 protected function normalizeJoinType( string $joinType ) {
904 switch ( strtoupper( $joinType ) ) {
905 case 'JOIN':
906 case 'INNER JOIN':
907 return 'JOIN';
908
909 case 'LEFT JOIN':
910 return 'LEFT JOIN';
911
912 case 'STRAIGHT_JOIN':
913 case 'STRAIGHT JOIN':
914 // MySQL only
915 return 'JOIN';
916
917 default:
918 return $joinType;
919 }
920 }
921
933 protected function tableNameWithAlias( $table, $alias = false ) {
934 if ( is_string( $table ) ) {
935 $quotedTable = $this->tableName( $table );
936 } elseif ( $table instanceof Subquery ) {
937 $quotedTable = (string)$table;
938 } else {
939 throw new InvalidArgumentException( "Table must be a string or Subquery" );
940 }
941
942 if ( $alias === false || $alias === $table ) {
943 if ( $table instanceof Subquery ) {
944 throw new InvalidArgumentException( "Subquery table missing alias" );
945 }
946
947 return $quotedTable;
948 } else {
949 return $quotedTable . ' ' . $this->addIdentifierQuotes( $alias );
950 }
951 }
952
957 public function tableName( $name, $format = 'quoted' ) {
958 if ( $name instanceof Subquery ) {
959 throw new DBLanguageError(
960 __METHOD__ . ': got Subquery instance when expecting a string'
961 );
962 }
963
964 # Skip the entire process when we have a string quoted on both ends.
965 # Note that we check the end so that we will still quote any use of
966 # use of `database`.table. But won't break things if someone wants
967 # to query a database table with a dot in the name.
968 if ( $this->isQuotedIdentifier( $name ) ) {
969 return $name;
970 }
971
972 # Lets test for any bits of text that should never show up in a table
973 # name. Basically anything like JOIN or ON which are actually part of
974 # SQL queries, but may end up inside of the table value to combine
975 # sql. Such as how the API is doing.
976 # Note that we use a whitespace test rather than a \b test to avoid
977 # any remote case where a word like on may be inside of a table name
978 # surrounded by symbols which may be considered word breaks.
979 if ( preg_match( '/(^|\s)(DISTINCT|JOIN|ON|AS)(\s|$)/i', $name ) !== 0 ) {
980 $this->logger->warning(
981 __METHOD__ . ": use of subqueries is not supported this way",
982 [
983 'exception' => new RuntimeException(),
984 'db_log_category' => 'sql',
985 ]
986 );
987
988 return $name;
989 }
990
991 # Split database and table into proper variables.
992 list( $database, $schema, $prefix, $table ) = $this->qualifiedTableComponents( $name );
993
994 # Quote $table and apply the prefix if not quoted.
995 # $tableName might be empty if this is called from Database::replaceVars()
996 $tableName = "{$prefix}{$table}";
997 if ( $format === 'quoted'
998 && !$this->isQuotedIdentifier( $tableName )
999 && $tableName !== ''
1000 ) {
1001 $tableName = $this->addIdentifierQuotes( $tableName );
1002 }
1003
1004 # Quote $schema and $database and merge them with the table name if needed
1005 $tableName = $this->prependDatabaseOrSchema( $schema, $tableName, $format );
1006 $tableName = $this->prependDatabaseOrSchema( $database, $tableName, $format );
1007
1008 return $tableName;
1009 }
1010
1017 public function qualifiedTableComponents( $name ) {
1018 # We reverse the explode so that database.table and table both output the correct table.
1019 $dbDetails = explode( '.', $name, 3 );
1020 if ( $this->currentDomain ) {
1021 $currentDomainPrefix = $this->currentDomain->getTablePrefix();
1022 } else {
1023 $currentDomainPrefix = null;
1024 }
1025 if ( count( $dbDetails ) == 3 ) {
1026 list( $database, $schema, $table ) = $dbDetails;
1027 # We don't want any prefix added in this case
1028 $prefix = '';
1029 } elseif ( count( $dbDetails ) == 2 ) {
1030 list( $database, $table ) = $dbDetails;
1031 # We don't want any prefix added in this case
1032 $prefix = '';
1033 # In dbs that support it, $database may actually be the schema
1034 # but that doesn't affect any of the functionality here
1035 $schema = '';
1036 } else {
1037 list( $table ) = $dbDetails;
1038 if ( isset( $this->tableAliases[$table] ) ) {
1039 $database = $this->tableAliases[$table]['dbname'];
1040 $schema = is_string( $this->tableAliases[$table]['schema'] )
1041 ? $this->tableAliases[$table]['schema']
1042 : $this->relationSchemaQualifier();
1043 $prefix = is_string( $this->tableAliases[$table]['prefix'] )
1044 ? $this->tableAliases[$table]['prefix']
1045 : $currentDomainPrefix;
1046 } else {
1047 $database = '';
1048 $schema = $this->relationSchemaQualifier(); # Default schema
1049 $prefix = $currentDomainPrefix; # Default prefix
1050 }
1051 }
1052
1053 return [ $database, $schema, $prefix, $table ];
1054 }
1055
1060 protected function relationSchemaQualifier() {
1061 if ( $this->currentDomain ) {
1062 return $this->currentDomain->getSchema();
1063 }
1064 return null;
1065 }
1066
1073 private function prependDatabaseOrSchema( $namespace, $relation, $format ) {
1074 if ( $namespace !== null && $namespace !== '' ) {
1075 if ( $format === 'quoted' && !$this->isQuotedIdentifier( $namespace ) ) {
1076 $namespace = $this->addIdentifierQuotes( $namespace );
1077 }
1078 $relation = $namespace . '.' . $relation;
1079 }
1080
1081 return $relation;
1082 }
1083
1084 public function tableNames( ...$tables ) {
1085 $retVal = [];
1086
1087 foreach ( $tables as $name ) {
1088 $retVal[$name] = $this->tableName( $name );
1089 }
1090
1091 return $retVal;
1092 }
1093
1094 public function tableNamesN( ...$tables ) {
1095 $retVal = [];
1096
1097 foreach ( $tables as $name ) {
1098 $retVal[] = $this->tableName( $name );
1099 }
1100
1101 return $retVal;
1102 }
1103
1114 public function isQuotedIdentifier( $name ) {
1115 return $name[0] == '"' && substr( $name, -1, 1 ) == '"';
1116 }
1117
1131 public function useIndexClause( $index ) {
1132 return '';
1133 }
1134
1144 public function ignoreIndexClause( $index ) {
1145 return '';
1146 }
1147
1159 protected function makeSelectOptions( array $options ) {
1160 $preLimitTail = $postLimitTail = '';
1161 $startOpts = '';
1162
1163 $noKeyOptions = [];
1164
1165 foreach ( $options as $key => $option ) {
1166 if ( is_numeric( $key ) ) {
1167 $noKeyOptions[$option] = true;
1168 }
1169 }
1170
1171 $preLimitTail .= $this->makeGroupByWithHaving( $options );
1172
1173 $preLimitTail .= $this->makeOrderBy( $options );
1174
1175 if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
1176 $postLimitTail .= ' FOR UPDATE';
1177 }
1178
1179 if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
1180 $postLimitTail .= ' LOCK IN SHARE MODE';
1181 }
1182
1183 if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
1184 $startOpts .= 'DISTINCT';
1185 }
1186
1187 # Various MySQL extensions
1188 if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
1189 $startOpts .= ' /*! STRAIGHT_JOIN */';
1190 }
1191
1192 if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
1193 $startOpts .= ' SQL_BIG_RESULT';
1194 }
1195
1196 if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
1197 $startOpts .= ' SQL_BUFFER_RESULT';
1198 }
1199
1200 if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
1201 $startOpts .= ' SQL_SMALL_RESULT';
1202 }
1203
1204 if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
1205 $startOpts .= ' SQL_CALC_FOUND_ROWS';
1206 }
1207
1208 return [ $startOpts, $preLimitTail, $postLimitTail ];
1209 }
1210
1219 protected function makeGroupByWithHaving( $options ) {
1220 $sql = '';
1221 if ( isset( $options['GROUP BY'] ) ) {
1222 $gb = is_array( $options['GROUP BY'] )
1223 ? implode( ',', $options['GROUP BY'] )
1224 : $options['GROUP BY'];
1225 $sql .= ' GROUP BY ' . $gb;
1226 }
1227 if ( isset( $options['HAVING'] ) ) {
1228 $having = is_array( $options['HAVING'] )
1229 ? $this->makeList( $options['HAVING'], self::LIST_AND )
1230 : $options['HAVING'];
1231 $sql .= ' HAVING ' . $having;
1232 }
1233
1234 return $sql;
1235 }
1236
1245 protected function makeOrderBy( $options ) {
1246 if ( isset( $options['ORDER BY'] ) ) {
1247 $ob = is_array( $options['ORDER BY'] )
1248 ? implode( ',', $options['ORDER BY'] )
1249 : $options['ORDER BY'];
1250
1251 return ' ORDER BY ' . $ob;
1252 }
1253
1254 return '';
1255 }
1256
1258 $table,
1259 $vars,
1260 array $permute_conds,
1261 $extra_conds = '',
1262 $fname = __METHOD__,
1263 $options = [],
1264 $join_conds = []
1265 ) {
1266 // First, build the Cartesian product of $permute_conds
1267 $conds = [ [] ];
1268 foreach ( $permute_conds as $field => $values ) {
1269 if ( !$values ) {
1270 // Skip empty $values
1271 continue;
1272 }
1273 $values = array_unique( $values );
1274 $newConds = [];
1275 foreach ( $conds as $cond ) {
1276 foreach ( $values as $value ) {
1277 $cond[$field] = $value;
1278 $newConds[] = $cond; // Arrays are by-value, not by-reference, so this works
1279 }
1280 }
1281 $conds = $newConds;
1282 }
1283
1284 $extra_conds = $extra_conds === '' ? [] : (array)$extra_conds;
1285
1286 // If there's just one condition and no subordering, hand off to
1287 // selectSQLText directly.
1288 if ( count( $conds ) === 1 &&
1289 ( !isset( $options['INNER ORDER BY'] ) || !$this->unionSupportsOrderAndLimit() )
1290 ) {
1291 return $this->selectSQLText(
1292 $table, $vars, $conds[0] + $extra_conds, $fname, $options, $join_conds
1293 );
1294 }
1295
1296 // Otherwise, we need to pull out the order and limit to apply after
1297 // the union. Then build the SQL queries for each set of conditions in
1298 // $conds. Then union them together (using UNION ALL, because the
1299 // product *should* already be distinct).
1300 $orderBy = $this->makeOrderBy( $options );
1301 $limit = $options['LIMIT'] ?? null;
1302 $offset = $options['OFFSET'] ?? false;
1303 $all = empty( $options['NOTALL'] ) && !in_array( 'NOTALL', $options );
1304 if ( !$this->unionSupportsOrderAndLimit() ) {
1305 unset( $options['ORDER BY'], $options['LIMIT'], $options['OFFSET'] );
1306 } else {
1307 if ( array_key_exists( 'INNER ORDER BY', $options ) ) {
1308 $options['ORDER BY'] = $options['INNER ORDER BY'];
1309 }
1310 if ( $limit !== null && is_numeric( $offset ) && $offset != 0 ) {
1311 // We need to increase the limit by the offset rather than
1312 // using the offset directly, otherwise it'll skip incorrectly
1313 // in the subqueries.
1314 $options['LIMIT'] = $limit + $offset;
1315 unset( $options['OFFSET'] );
1316 }
1317 }
1318
1319 $sqls = [];
1320 foreach ( $conds as $cond ) {
1321 $sqls[] = $this->selectSQLText(
1322 $table, $vars, $cond + $extra_conds, $fname, $options, $join_conds
1323 );
1324 }
1325 $sql = $this->unionQueries( $sqls, $all ) . $orderBy;
1326 if ( $limit !== null ) {
1327 $sql = $this->limitResult( $sql, $limit, $offset );
1328 }
1329
1330 return $sql;
1331 }
1332
1337 public function buildGroupConcatField(
1338 $delim, $table, $field, $conds = '', $join_conds = []
1339 ) {
1340 $fld = "GROUP_CONCAT($field SEPARATOR " . $this->quoter->addQuotes( $delim ) . ')';
1341
1342 return '(' . $this->selectSQLText( $table, $fld, $conds, __METHOD__, [], $join_conds ) . ')';
1343 }
1344
1345 public function buildSelectSubquery(
1346 $table, $vars, $conds = '', $fname = __METHOD__,
1347 $options = [], $join_conds = []
1348 ) {
1349 return new Subquery(
1350 $this->selectSQLText( $table, $vars, $conds, $fname, $options, $join_conds )
1351 );
1352 }
1353
1354 public function insertSqlText( $table, array $rows ) {
1355 $encTable = $this->tableName( $table );
1356 list( $sqlColumns, $sqlTuples ) = $this->makeInsertLists( $rows );
1357
1358 return "INSERT INTO $encTable ($sqlColumns) VALUES $sqlTuples";
1359 }
1360
1372 public function makeInsertLists( array $rows, $aliasPrefix = '' ) {
1373 $firstRow = $rows[0];
1374 if ( !is_array( $firstRow ) || !$firstRow ) {
1375 throw new DBLanguageError( 'Got an empty row list or empty row' );
1376 }
1377 // List of columns that define the value tuple ordering
1378 $tupleColumns = array_keys( $firstRow );
1379
1380 $valueTuples = [];
1381 foreach ( $rows as $row ) {
1382 $rowColumns = array_keys( $row );
1383 // VALUES(...) requires a uniform correspondence of (column => value)
1384 if ( $rowColumns !== $tupleColumns ) {
1385 throw new DBLanguageError(
1386 'Got row columns (' . implode( ', ', $rowColumns ) . ') ' .
1387 'instead of expected (' . implode( ', ', $tupleColumns ) . ')'
1388 );
1389 }
1390 // Make the value tuple that defines this row
1391 $valueTuples[] = '(' . $this->makeList( $row, self::LIST_COMMA ) . ')';
1392 }
1393
1394 $magicAliasFields = [];
1395 foreach ( $tupleColumns as $column ) {
1396 $magicAliasFields[] = $aliasPrefix . $column;
1397 }
1398
1399 return [
1400 $this->makeList( $tupleColumns, self::LIST_NAMES ),
1401 implode( ',', $valueTuples ),
1402 $this->makeList( $magicAliasFields, self::LIST_NAMES )
1403 ];
1404 }
1405
1406 public function insertNonConflictingSqlText( $table, array $rows ) {
1407 $encTable = $this->tableName( $table );
1408 list( $sqlColumns, $sqlTuples ) = $this->makeInsertLists( $rows );
1409 list( $sqlVerb, $sqlOpts ) = $this->makeInsertNonConflictingVerbAndOptions();
1410
1411 return rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES $sqlTuples $sqlOpts" );
1412 }
1413
1420 return [ 'INSERT IGNORE INTO', '' ];
1421 }
1422
1424 $destTable,
1425 $srcTable,
1426 array $varMap,
1427 $conds,
1428 $fname,
1429 array $insertOptions,
1430 array $selectOptions,
1431 $selectJoinConds
1432 ) {
1433 list( $sqlVerb, $sqlOpts ) = $this->isFlagInOptions( 'IGNORE', $insertOptions )
1435 : [ 'INSERT INTO', '' ];
1436 $encDstTable = $this->tableName( $destTable );
1437 $sqlDstColumns = implode( ',', array_keys( $varMap ) );
1438 $selectSql = $this->selectSQLText(
1439 $srcTable,
1440 array_values( $varMap ),
1441 $conds,
1442 $fname,
1443 $selectOptions,
1444 $selectJoinConds
1445 );
1446
1447 return rtrim( "$sqlVerb $encDstTable ($sqlDstColumns) $selectSql $sqlOpts" );
1448 }
1449
1456 public function isFlagInOptions( $option, array $options ) {
1457 foreach ( array_keys( $options, $option, true ) as $k ) {
1458 if ( is_int( $k ) ) {
1459 return true;
1460 }
1461 }
1462
1463 return false;
1464 }
1465
1473 public function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
1474 if ( !$rows ) {
1475 throw new DBLanguageError( "Empty row array" );
1476 } elseif ( !$uniqueKey ) {
1477 throw new DBLanguageError( "Empty unique key array" );
1478 }
1479
1480 if ( count( $uniqueKey ) == 1 ) {
1481 // Use a simple IN(...) clause
1482 $column = reset( $uniqueKey );
1483 $values = array_column( $rows, $column );
1484 if ( count( $values ) !== count( $rows ) ) {
1485 throw new DBLanguageError( "Missing values for unique key ($column)" );
1486 }
1487
1488 return $this->makeList( [ $column => $values ], self::LIST_AND );
1489 }
1490
1491 $nullByUniqueKeyColumn = array_fill_keys( $uniqueKey, null );
1492
1493 $orConds = [];
1494 foreach ( $rows as $row ) {
1495 $rowKeyMap = array_intersect_key( $row, $nullByUniqueKeyColumn );
1496 if ( count( $rowKeyMap ) != count( $uniqueKey ) ) {
1497 throw new DBLanguageError(
1498 "Missing values for unique key (" . implode( ',', $uniqueKey ) . ")"
1499 );
1500 }
1501 $orConds[] = $this->makeList( $rowKeyMap, self::LIST_AND );
1502 }
1503
1504 return count( $orConds ) > 1
1505 ? $this->makeList( $orConds, self::LIST_OR )
1506 : $orConds[0];
1507 }
1508
1509 public function deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds ) {
1510 if ( !$conds ) {
1511 throw new DBLanguageError( __METHOD__ . ' called with empty $conds' );
1512 }
1513
1514 $delTable = $this->tableName( $delTable );
1515 $joinTable = $this->tableName( $joinTable );
1516 $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
1517 if ( $conds != '*' ) {
1518 $sql .= 'WHERE ' . $this->makeList( $conds, self::LIST_AND );
1519 }
1520 $sql .= ')';
1521
1522 return $sql;
1523 }
1524
1525 public function deleteSqlText( $table, $conds ) {
1526 $this->assertConditionIsNotEmpty( $conds, __METHOD__, false );
1527
1528 $table = $this->tableName( $table );
1529 $sql = "DELETE FROM $table";
1530
1531 if ( $conds !== self::ALL_ROWS ) {
1532 if ( is_array( $conds ) ) {
1533 $conds = $this->makeList( $conds, self::LIST_AND );
1534 }
1535 $sql .= ' WHERE ' . $conds;
1536 }
1537
1538 return $sql;
1539 }
1540
1541 public function updateSqlText( $table, $set, $conds, $options ) {
1542 $this->assertConditionIsNotEmpty( $conds, __METHOD__, true );
1543 $table = $this->tableName( $table );
1544 $opts = $this->makeUpdateOptions( $options );
1545 $sql = "UPDATE $opts $table SET " . $this->makeList( $set, self::LIST_SET );
1546
1547 if ( $conds && $conds !== self::ALL_ROWS ) {
1548 if ( is_array( $conds ) ) {
1549 $conds = $this->makeList( $conds, self::LIST_AND );
1550 }
1551 $sql .= ' WHERE ' . $conds;
1552 }
1553
1554 return $sql;
1555 }
1556
1570 protected function assertConditionIsNotEmpty( $conds, string $fname, bool $deprecate ) {
1571 $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1572 if ( !$isCondValid ) {
1573 if ( $deprecate ) {
1574 wfDeprecated( $fname . ' called with empty $conds', '1.35', false, 4 );
1575 } else {
1576 throw new DBLanguageError( $fname . ' called with empty conditions' );
1577 }
1578 }
1579 }
1580
1588 protected function makeUpdateOptions( $options ) {
1589 $opts = $this->makeUpdateOptionsArray( $options );
1590
1591 return implode( ' ', $opts );
1592 }
1593
1601 protected function makeUpdateOptionsArray( $options ) {
1602 $options = $this->normalizeOptions( $options );
1603
1604 $opts = [];
1605
1606 if ( in_array( 'IGNORE', $options ) ) {
1607 $opts[] = 'IGNORE';
1608 }
1609
1610 return $opts;
1611 }
1612
1618 final public function normalizeOptions( $options ) {
1619 if ( is_array( $options ) ) {
1620 return $options;
1621 } elseif ( is_string( $options ) ) {
1622 return ( $options === '' ) ? [] : [ $options ];
1623 } else {
1624 throw new DBLanguageError( __METHOD__ . ': expected string or array' );
1625 }
1626 }
1627
1628 public function dropTableSqlText( $table ) {
1629 // https://mariadb.com/kb/en/drop-table/
1630 // https://dev.mysql.com/doc/refman/8.0/en/drop-table.html
1631 // https://www.postgresql.org/docs/9.2/sql-truncate.html
1632 return "DROP TABLE " . $this->tableName( $table ) . " CASCADE";
1633 }
1634
1639 public function getQueryVerb( $sql ) {
1640 // Distinguish ROLLBACK from ROLLBACK TO SAVEPOINT
1641 return preg_match(
1642 '/^\s*(rollback\s+to\s+savepoint|[a-z]+)/i',
1643 $sql,
1644 $m
1645 ) ? strtoupper( $m[1] ) : null;
1646 }
1647
1662 public function isTransactableQuery( $sql ) {
1663 return !in_array(
1664 $this->getQueryVerb( $sql ),
1665 [
1666 'BEGIN',
1667 'ROLLBACK',
1668 'ROLLBACK TO SAVEPOINT',
1669 'COMMIT',
1670 'SET',
1671 'SHOW',
1672 'CREATE',
1673 'ALTER',
1674 'USE',
1675 'SHOW'
1676 ],
1677 true
1678 );
1679 }
1680
1698 public function isWriteQuery( $sql, $flags ) {
1699 // Check if a SQL wrapper method already flagged the query as a write
1700 if (
1701 $this->fieldHasBit( $flags, self::QUERY_CHANGE_ROWS ) ||
1702 $this->fieldHasBit( $flags, self::QUERY_CHANGE_SCHEMA )
1703 ) {
1704 return true;
1705 }
1706 // Check if a SQL wrapper method already flagged the query as a non-write
1707 if (
1708 $this->fieldHasBit( $flags, self::QUERY_CHANGE_NONE ) ||
1709 $this->fieldHasBit( $flags, self::QUERY_CHANGE_TRX ) ||
1710 $this->fieldHasBit( $flags, self::QUERY_CHANGE_LOCKS )
1711 ) {
1712 return false;
1713 }
1714 // Treat SELECT queries without FOR UPDATE queries as non-writes. This matches
1715 // how MySQL enforces read_only (FOR SHARE and LOCK IN SHADE MODE are allowed).
1716 // Handle (SELECT ...) UNION (SELECT ...) queries in a similar fashion.
1717 if ( preg_match( '/^\s*\‍(?SELECT\b/i', $sql ) ) {
1718 return (bool)preg_match( '/\bFOR\s+UPDATE\‍)?\s*$/i', $sql );
1719 }
1720 // BEGIN and COMMIT queries are considered non-write queries here.
1721 // Database backends and drivers (MySQL, MariaDB, php-mysqli) generally
1722 // treat these as write queries, in that their results have "affected rows"
1723 // as meta data as from writes, instead of "num rows" as from reads.
1724 // But, we treat them as non-write queries because when reading data (from
1725 // either replica or primary DB) we use transactions to enable repeatable-read
1726 // snapshots, which ensures we get consistent results from the same snapshot
1727 // for all queries within a request. Use cases:
1728 // - Treating these as writes would trigger ChronologyProtector (see method doc).
1729 // - We use this method to reject writes to replicas, but we need to allow
1730 // use of transactions on replicas for read snapshots. This is fine given
1731 // that transactions by themselves don't make changes, only actual writes
1732 // within the transaction matter, which we still detect.
1733 return !preg_match(
1734 '/^\s*(BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|USE)\b/i',
1735 $sql
1736 );
1737 }
1738
1744 final protected function fieldHasBit( int $flags, int $bit ) {
1745 return ( ( $flags & $bit ) === $bit );
1746 }
1747
1748 public function buildExcludedValue( $column ) {
1749 /* @see Database::doUpsert() */
1750 // This can be treated like a single value since __VALS is a single row table
1751 return "(SELECT __$column FROM __VALS)";
1752 }
1753
1754 public function savepointSqlText( $identifier ) {
1755 return 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1756 }
1757
1758 public function releaseSavepointSqlText( $identifier ) {
1759 return 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1760 }
1761
1762 public function rollbackToSavepointSqlText( $identifier ) {
1763 return 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1764 }
1765
1766 public function rollbackSqlText() {
1767 return 'ROLLBACK';
1768 }
1769
1770 public function dispatchingInsertSqlText( $table, $rows, $options ) {
1771 $rows = $this->normalizeRowArray( $rows );
1772 if ( !$rows ) {
1773 return false;
1774 }
1775
1776 $options = $this->normalizeOptions( $options );
1777 if ( $this->isFlagInOptions( 'IGNORE', $options ) ) {
1778 return $this->insertNonConflictingSqlText( $table, $rows );
1779 } else {
1780 return $this->insertSqlText( $table, $rows );
1781 }
1782 }
1783
1789 final protected function normalizeRowArray( array $rowOrRows ) {
1790 if ( !$rowOrRows ) {
1791 $rows = [];
1792 } elseif ( isset( $rowOrRows[0] ) ) {
1793 $rows = $rowOrRows;
1794 } else {
1795 $rows = [ $rowOrRows ];
1796 }
1797
1798 foreach ( $rows as $row ) {
1799 if ( !is_array( $row ) ) {
1800 throw new DBLanguageError( "Got non-array in row array" );
1801 } elseif ( !$row ) {
1802 throw new DBLanguageError( "Got empty array in row array" );
1803 }
1804 }
1805
1806 return $rows;
1807 }
1808
1818 final public function normalizeUpsertParams( $uniqueKeys, &$rows ) {
1819 $rows = $this->normalizeRowArray( $rows );
1820 if ( !$rows ) {
1821 return null;
1822 }
1823 if ( !$uniqueKeys ) {
1824 // For backwards compatibility, allow insertion of rows with no applicable key
1825 $this->logger->warning(
1826 "upsert/replace called with no unique key",
1827 [
1828 'exception' => new RuntimeException(),
1829 'db_log_category' => 'sql',
1830 ]
1831 );
1832 return null;
1833 }
1834 $identityKey = $this->normalizeUpsertKeys( $uniqueKeys );
1835 if ( $identityKey ) {
1836 $allDefaultKeyValues = $this->assertValidUpsertRowArray( $rows, $identityKey );
1837 if ( $allDefaultKeyValues ) {
1838 // For backwards compatibility, allow insertion of rows with all-NULL
1839 // values for the unique columns (e.g. for an AUTOINCREMENT column)
1840 $this->logger->warning(
1841 "upsert/replace called with all-null values for unique key",
1842 [
1843 'exception' => new RuntimeException(),
1844 'db_log_category' => 'sql',
1845 ]
1846 );
1847 return null;
1848 }
1849 }
1850 return $identityKey;
1851 }
1852
1859 final public function normalizeConditions( $conds, $fname ) {
1860 if ( $conds === null || $conds === false ) {
1861 $this->logger->warning(
1862 __METHOD__
1863 . ' called from '
1864 . $fname
1865 . ' with incorrect parameters: $conds must be a string or an array',
1866 [ 'db_log_category' => 'sql' ]
1867 );
1868 return [];
1869 } elseif ( $conds === '' ) {
1870 return [];
1871 }
1872
1873 return is_array( $conds ) ? $conds : [ $conds ];
1874 }
1875
1882 private function normalizeUpsertKeys( $uniqueKeys ) {
1883 if ( is_string( $uniqueKeys ) ) {
1884 return [ $uniqueKeys ];
1885 } elseif ( !is_array( $uniqueKeys ) ) {
1886 throw new DBLanguageError( 'Invalid unique key array' );
1887 } else {
1888 if ( count( $uniqueKeys ) !== 1 || !isset( $uniqueKeys[0] ) ) {
1889 throw new DBLanguageError(
1890 "The unique key array should contain a single unique index" );
1891 }
1892
1893 $uniqueKey = $uniqueKeys[0];
1894 if ( is_string( $uniqueKey ) ) {
1895 // Passing a list of strings for single-column unique keys is too
1896 // easily confused with passing the columns of composite unique key
1897 $this->logger->warning( __METHOD__ .
1898 " called with deprecated parameter style: " .
1899 "the unique key array should be a string or array of string arrays",
1900 [
1901 'exception' => new RuntimeException(),
1902 'db_log_category' => 'sql',
1903 ] );
1904 return $uniqueKeys;
1905 } elseif ( is_array( $uniqueKey ) ) {
1906 return $uniqueKey;
1907 } else {
1908 throw new DBLanguageError( 'Invalid unique key array entry' );
1909 }
1910 }
1911 }
1912
1919 final protected function assertValidUpsertRowArray( array $rows, array $identityKey ) {
1920 $numNulls = 0;
1921 foreach ( $rows as $row ) {
1922 foreach ( $identityKey as $column ) {
1923 $numNulls += ( isset( $row[$column] ) ? 0 : 1 );
1924 }
1925 }
1926
1927 if (
1928 $numNulls &&
1929 $numNulls !== ( count( $rows ) * count( $identityKey ) )
1930 ) {
1931 throw new DBLanguageError(
1932 "NULL/absent values for unique key (" . implode( ',', $identityKey ) . ")"
1933 );
1934 }
1935
1936 return (bool)$numNulls;
1937 }
1938
1945 final public function assertValidUpsertSetArray(
1946 array $set,
1947 array $identityKey,
1948 array $rows
1949 ) {
1950 // Sloppy callers might construct the SET array using the ROW array, leaving redundant
1951 // column definitions for identity key columns. Detect this for backwards compatibility.
1952 $soleRow = ( count( $rows ) == 1 ) ? reset( $rows ) : null;
1953 // Disallow value changes for any columns in the identity key. This avoids additional
1954 // insertion order dependencies that are unwieldy and difficult to implement efficiently
1955 // in PostgreSQL.
1956 foreach ( $set as $k => $v ) {
1957 if ( is_string( $k ) ) {
1958 // Key is a column name and value is a literal (e.g. string, int, null, ...)
1959 if ( in_array( $k, $identityKey, true ) ) {
1960 if ( $soleRow && array_key_exists( $k, $soleRow ) && $soleRow[$k] === $v ) {
1961 $this->logger->warning(
1962 __METHOD__ . " called with redundant assignment to column '$k'",
1963 [
1964 'exception' => new RuntimeException(),
1965 'db_log_category' => 'sql',
1966 ]
1967 );
1968 } else {
1969 throw new DBLanguageError(
1970 "Cannot reassign column '$k' since it belongs to identity key"
1971 );
1972 }
1973 }
1974 } elseif ( preg_match( '/^([a-zA-Z0-9_]+)\s*=/', $v, $m ) ) {
1975 // Value is of the form "<unquoted alphanumeric column> = <SQL expression>"
1976 if ( in_array( $m[1], $identityKey, true ) ) {
1977 throw new DBLanguageError(
1978 "Cannot reassign column '{$m[1]}' since it belongs to identity key"
1979 );
1980 }
1981 }
1982 }
1983 }
1984
1989 final public function extractSingleFieldFromList( $var ) {
1990 if ( is_array( $var ) ) {
1991 if ( !$var ) {
1992 $column = null;
1993 } elseif ( count( $var ) == 1 ) {
1994 $column = $var[0] ?? reset( $var );
1995 } else {
1996 throw new DBLanguageError( __METHOD__ . ': got multiple columns' );
1997 }
1998 } else {
1999 $column = $var;
2000 }
2001
2002 return $column;
2003 }
2004
2005 public function setSchemaVars( $vars ) {
2006 $this->schemaVars = is_array( $vars ) ? $vars : null;
2007 }
2008
2015 protected function getSchemaVars() {
2016 return $this->schemaVars ?? $this->getDefaultSchemaVars();
2017 }
2018
2028 protected function getDefaultSchemaVars() {
2029 return [];
2030 }
2031
2053 public function replaceVars( $ins ) {
2054 $vars = $this->getSchemaVars();
2055 return preg_replace_callback(
2056 '!
2057 /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
2058 \'\{\$ (\w+) }\' | # 3. addQuotes
2059 `\{\$ (\w+) }` | # 4. addIdentifierQuotes
2060 /\*\$ (\w+) \*/ # 5. leave unencoded
2061 !x',
2062 function ( $m ) use ( $vars ) {
2063 // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
2064 // check for both nonexistent keys *and* the empty string.
2065 if ( isset( $m[1] ) && $m[1] !== '' ) {
2066 if ( $m[1] === 'i' ) {
2067 return $this->indexName( $m[2] );
2068 } else {
2069 return $this->tableName( $m[2] );
2070 }
2071 } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
2072 return $this->quoter->addQuotes( $vars[$m[3]] );
2073 } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
2074 return $this->addIdentifierQuotes( $vars[$m[4]] );
2075 } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
2076 return $vars[$m[5]];
2077 } else {
2078 return $m[0];
2079 }
2080 },
2081 $ins
2082 );
2083 }
2084
2085 public function lockSQLText( $lockName, $timeout ) {
2086 throw new RuntimeException( 'locking must be implemented in subclasses' );
2087 }
2088
2089 public function lockIsFreeSQLText( $lockName ) {
2090 throw new RuntimeException( 'locking must be implemented in subclasses' );
2091 }
2092
2093 public function unlockSQLText( $lockName ) {
2094 throw new RuntimeException( 'locking must be implemented in subclasses' );
2095 }
2096}
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
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
decodeExpiry( $expiry, $format=TS_MW)
Decode an expiry time into a DBMS independent format.
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)
makeInsertLists(array $rows, $aliasPrefix='')
Make SQL lists of columns, row tuples, and column aliases for INSERT/VALUES expressions.
getInfinity()
Find out when 'infinity' is.Most DBMSes support this. This is a special keyword for timestamps in Pos...
buildGreatest( $fields, $values)
Build a GREATEST function statement comparing columns/values.Integer and float values in $values will...
__construct(DbQuoter $quoter, LoggerInterface $logger=null, DatabaseDomain $currentDomain=null, $errorLogger=null)
makeGroupByWithHaving( $options)
Returns an optional GROUP BY with an optional HAVING.
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.REPLACE() in mysql)string
replaceVars( $ins)
Database-independent variable replacement.
buildStringCast( $field)
string 1.28 in IDatabase, moved to ISQLPlatform in 1.39
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.Note that this applies to ind...
qualifiedTableComponents( $name)
Get the table components needed for a query given the currently selected database.
makeKeyCollisionCondition(array $rows, array $uniqueKey)
Build an SQL condition to find rows with matching key values to those in $rows.
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.This takes a variable-length argument list with parts of pattern to match cont...
bitOr( $fieldLeft, $fieldRight)
string
fieldNameWithAlias( $name, $alias=false)
Get an aliased field name e.g.
limitResult( $sql, $limit, $offset=false)
Construct a LIMIT query with optional offset.The SQL should be adjusted so that only the first $limit...
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.
tableNames(... $tables)
Fetch a number of table names into an associative array.
makeUpdateOptions( $options)
Make UPDATE options for the Database::update function.
assertBuildSubstringParams( $startPosition, $length)
Check type and bounds for parameters to self::buildSubstring()
selectSQLText( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Take the same arguments as IDatabase::select() and return the SQL it would use.This can be useful for...
bitAnd( $fieldLeft, $fieldRight)
string
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)
unionConditionPermutations( $table, $vars, array $permute_conds, $extra_conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Construct a UNION query for permutations of conditions.
assertValidUpsertRowArray(array $rows, array $identityKey)
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)
assertValidUpsertSetArray(array $set, array $identityKey, array $rows)
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 partial where clause from a 2-d array such as used for LinkBatch.
isTransactableQuery( $sql)
Determine whether a SQL statement is sensitive to isolation level.
buildGroupConcatField( $delim, $table, $field, $conds='', $join_conds=[])
Build a GROUP_CONCAT or equivalent statement for a query.This is useful for combining a field for sev...
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...
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.This doesn't need to be overridden unless CASE isn...
setSchemaVars( $vars)
Set schema variables to be used when streaming commands from SQL files or stdin.
setCurrentDomain(DatabaseDomain $currentDomain)
isWriteQuery( $sql, $flags)
Determine whether a query writes to the DB.
assertConditionIsNotEmpty( $conds, string $fname, bool $deprecate)
Check type and bounds conditions parameters for update.
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)
tableName( $name, $format='quoted')
Format a table name ready for use in constructing an SQL query.This does two important things: it quo...
addIdentifierQuotes( $s)
Escape a SQL identifier (e.g.table, column, database) for use in a SQL queryDepending on the database...
escapeLikeInternal( $s, $escapeChar='`')
fieldNamesWithAlias( $fields)
Gets an array of aliased field names.
buildSelectSubquery( $table, $vars, $conds='', $fname=__METHOD__, $options=[], $join_conds=[])
Equivalent to IDatabase::selectSQLText() except wraps the result in Subquery.
buildIntegerCast( $field)
string 1.31 in IDatabase, moved to ISQLPlatform in 1.39
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...
unionQueries( $sqls, $all)
Construct a UNION query.This is used for providing overload point for other DB abstractions not compa...
Interface for query language.
foreach( $mmfl['setupFiles'] as $fileName) if($queue) if(empty( $mmfl['quiet'])) $s