Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.81% covered (warning)
76.81%
656 / 854
41.00% covered (danger)
41.00%
41 / 100
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQLPlatform
76.81% covered (warning)
76.81%
656 / 854
41.00% covered (danger)
41.00%
41 / 100
2339.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
1.00
 bitNot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 bitAnd
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 bitOr
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addIdentifierQuotes
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getIdentifierQuoteChar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildGreatest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildLeast
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildSuperlative
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
11.56
 buildComparison
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 makeList
85.53% covered (warning)
85.53%
65 / 76
0.00% covered (danger)
0.00%
0 / 1
49.87
 makeWhereFrom2d
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 factorConds
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 factorCondsWithCommonFields
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
10
 buildConcat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 limitResult
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 escapeLikeInternal
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildLike
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 anyChar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 anyString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unionSupportsOrderAndLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unionQueries
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 conditional
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 strreplace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 timestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 timestampOrNull
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getInfinity
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 encodeExpiry
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 decodeExpiry
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 buildSubstring
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 assertBuildSubstringParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 buildStringCast
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildIntegerCast
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 implicitOrderby
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 indexName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIndexAliases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTableAliases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPrefix
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setCurrentDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 selectSQLText
53.75% covered (warning)
53.75%
43 / 80
0.00% covered (danger)
0.00%
0 / 1
126.07
 selectOptionsIncludeLocking
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 selectFieldsOrOptionsAggregate
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
 fieldNamesWithAlias
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fieldNameWithAlias
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 tableNamesWithIndexClauseOrJOIN
87.27% covered (warning)
87.27%
48 / 55
0.00% covered (danger)
0.00%
0 / 1
14.40
 normalizeJoinType
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
10.14
 tableNameWithAlias
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
9.21
 tableName
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 qualifiedTableComponents
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 extractTableNameComponents
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getDatabaseAndTableIdentifier
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 relationSchemaQualifier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tableNamesN
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isQuotedIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 useIndexClause
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ignoreIndexClause
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeSelectOptions
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
13.15
 makeGroupByWithHaving
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 makeOrderBy
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 buildGroupConcatField
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 buildSelectSubquery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 insertSqlText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 makeInsertLists
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
6.49
 insertNonConflictingSqlText
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 makeInsertNonConflictingVerbAndOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 insertSelectNativeSqlText
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 isFlagInOptions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 makeKeyCollisionCondition
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
10.06
 deleteJoinSqlText
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 deleteSqlText
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 scrubArray
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 updateSqlText
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 makeUpdateOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeUpdateOptionsArray
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 normalizeOptions
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 dropTableSqlText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQueryVerb
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isTransactableQuery
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 buildExcludedValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 savepointSqlText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 releaseSavepointSqlText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rollbackToSavepointSqlText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rollbackSqlText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatchingInsertSqlText
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 normalizeRowArray
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 normalizeUpsertParams
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 normalizeConditions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 normalizeUpsertKeys
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
7.39
 assertValidUpsertRowArray
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 assertValidUpsertSetArray
45.45% covered (danger)
45.45%
10 / 22
0.00% covered (danger)
0.00%
0 / 1
30.64
 extractSingleFieldFromList
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 setSchemaVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getSchemaVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultSchemaVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 replaceVars
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
182
 lockSQLText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 lockIsFreeSQLText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unlockSQLText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace Wikimedia\Rdbms\Platform;
21
22use InvalidArgumentException;
23use Psr\Log\LoggerInterface;
24use Psr\Log\NullLogger;
25use RuntimeException;
26use Throwable;
27use Wikimedia\Assert\Assert;
28use Wikimedia\Rdbms\Database\DbQuoter;
29use Wikimedia\Rdbms\DatabaseDomain;
30use Wikimedia\Rdbms\DBLanguageError;
31use Wikimedia\Rdbms\IExpression;
32use Wikimedia\Rdbms\LikeMatch;
33use Wikimedia\Rdbms\LikeValue;
34use Wikimedia\Rdbms\Query;
35use Wikimedia\Rdbms\QueryBuilderFromRawSql;
36use Wikimedia\Rdbms\RawSQLValue;
37use Wikimedia\Rdbms\Subquery;
38use Wikimedia\Timestamp\ConvertibleTimestamp;
39
40/**
41 * Sql abstraction object.
42 * This class nor any of its subclasses shouldn't create a db connection.
43 * It also should not become stateful. The constructor should only rely on addQuotes() method in Database.
44 * Later that should be replaced with an implementation that doesn't use db connections.
45 * @since 1.39
46 */
47class SQLPlatform implements ISQLPlatform {
48    /** @var array[] Current map of (table => (dbname, schema, prefix) map) */
49    protected $tableAliases = [];
50    /** @var string[] Current map of (index alias => index) */
51    protected $indexAliases = [];
52    protected DatabaseDomain $currentDomain;
53    /** @var array|null Current variables use for schema element placeholders */
54    protected $schemaVars;
55    protected DbQuoter $quoter;
56    protected LoggerInterface $logger;
57    /** @var callable Error logging callback */
58    protected $errorLogger;
59
60    public function __construct(
61        DbQuoter $quoter,
62        ?LoggerInterface $logger = null,
63        ?DatabaseDomain $currentDomain = 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
96    /**
97     * Get the character used for identifier quoting
98     * @return string
99     */
100    protected function getIdentifierQuoteChar() {
101        return '"';
102    }
103
104    /**
105     * @inheritDoc
106     */
107    public function buildGreatest( $fields, $values ) {
108        return $this->buildSuperlative( 'GREATEST', $fields, $values );
109    }
110
111    /**
112     * @inheritDoc
113     */
114    public function buildLeast( $fields, $values ) {
115        return $this->buildSuperlative( 'LEAST', $fields, $values );
116    }
117
118    /**
119     * Build a superlative function statement comparing columns/values
120     *
121     * Integer and float values in $values will not be quoted
122     *
123     * If $fields is an array, then each value with a string key is treated as an expression
124     * (which must be manually quoted); such string keys do not appear in the SQL and are only
125     * descriptive aliases.
126     *
127     * @param string $sqlfunc Name of a SQL function
128     * @param string|string[] $fields Name(s) of column(s) with values to compare
129     * @param string|int|float|string[]|int[]|float[] $values Values to compare
130     * @return string
131     */
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
366    /**
367     * Same as factorConds() but with each element in the array having the same
368     * set of array keys. Validation is done by the caller.
369     *
370     * @param array $condsArray
371     * @return string
372     */
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
424    /**
425     * @inheritDoc
426     */
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
444    /**
445     * @param string $s
446     * @param string $escapeChar
447     * @return string
448     */
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
475    /**
476     * @inheritDoc
477     */
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
546    /**
547     * @inheritDoc
548     */
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
558    /**
559     * Check type and bounds for parameters to self::buildSubstring()
560     *
561     * All supported databases have substring functions that behave the same for
562     * positive $startPosition and non-negative $length, but behaviors differ when
563     * given negative $startPosition or negative $length. The simplest
564     * solution to that is to just forbid those values.
565     *
566     * @param int $startPosition
567     * @param int|null $length
568     * @since 1.31 in Database, moved to SQLPlatform in 1.39
569     */
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
601    /**
602     * Allows for index remapping in queries where this is not consistent across DBMS
603     *
604     * TODO: Make it protected once all the code is moved over.
605     *
606     * @param string $index
607     * @return string
608     */
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
621    /**
622     * @return array[]
623     */
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
640    /**
641     * @internal For use by tests
642     * @return DatabaseDomain
643     */
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
792    /**
793     * @param string|array $options
794     * @return bool
795     */
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
807    /**
808     * @param array|string $fields
809     * @param array|string $options
810     * @return bool
811     */
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
835    /**
836     * Gets an array of aliased field names
837     *
838     * @param array $fields [ [alias] => field ]
839     * @return string[] See fieldNameWithAlias()
840     */
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
853    /**
854     * Get an aliased field name
855     * e.g. fieldName AS newFieldName
856     *
857     * @param string $name Field name
858     * @param string|false $alias Alias (optional)
859     * @return string SQL name for aliased field. Will not alias a field to its own name
860     */
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
869    /**
870     * Get the aliased table name clause for a FROM clause
871     * which might have a JOIN and/or USE INDEX or IGNORE INDEX clause
872     *
873     * @param array $tables Array of ([alias] => table reference)
874     * @param array $use_index Same as for select()
875     * @param array $ignore_index Same as for select()
876     * @param array $join_conds Same as for select()
877     * @return string
878     */
879    protected function tableNamesWithIndexClauseOrJOIN(
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
972    /**
973     * Validate and normalize a join type
974     *
975     * Subclasses may override this to add supported join types.
976     *
977     * @param string $joinType
978     * @return string
979     */
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
999    /**
1000     * Get an aliased table name
1001     *
1002     * This returns strings like "tableName AS newTableName" for aliased tables
1003     * and "(SELECT * from tableA) newTablename" for subqueries (e.g. derived tables)
1004     *
1005     * @see Database::tableName()
1006     * @param string|Subquery $table The unqualified name of a table, or Subquery
1007     * @param string|false $alias Table alias (optional)
1008     * @return string SQL name for aliased table. Will not alias a table to its own name
1009     */
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
1069    /**
1070     * Get the table components needed for a query given the currently selected database/schema
1071     *
1072     * The resulting array will take one of the follow forms:
1073     *  - <table identifier>
1074     *  - <database identifier>.<table identifier> (e.g. non-Postgres)
1075     *  - <schema identifier>.<table identifier> (e.g. Postgres-only)
1076     *  - <database identifier>.<schema identifier>.<table identifier> (e.g. Postgres-only)
1077     *
1078     * If the provided table name only consists of an unquoted table identifier that has an
1079     * entry in ({@link getTableAliases()}), then, the resulting components will be determined
1080     * from the alias configuration. If such alias configuration does not specify the table
1081     * prefix, then the current DB domain prefix will be prepended to the table identifier.
1082     *
1083     * In all other cases where the provided table name only consists of an unquoted table
1084     * identifier, the current DB domain prefix will be prepended to the table identifier.
1085     *
1086     * Empty database/schema identifiers are omitted from the resulting array.
1087     *
1088     * @param string $name Table name as database.schema.table, database.table, or table
1089     * @return string[] Non-empty list of unquoted identifiers that form the qualified table name
1090     */
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
1136    /**
1137     * Extract the dot-separated components of a table name, preserving identifier quotation
1138     *
1139     * @param string $name Table name, possible qualified with db or db+schema
1140     * @return string[] Non-empty list of the identifiers included in the provided table name
1141     */
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
1160    /**
1161     * Get the database identifer and prefixed table name identifier for a table
1162     *
1163     * The table name is assumed to be relative to the current DB domain
1164     *
1165     * This method is useful for TEMPORARY table tracking. In MySQL, temp tables with identical
1166     * names can co-exist on different databases, which can be done via CREATE and USE. Note
1167     * that SQLite/PostgreSQL do not allow changing the database within a session. This method
1168     * omits the schema identifier for several reasons:
1169     *   - MySQL/MariaDB do not support schemas at all.
1170     *   - SQLite/PostgreSQL put all TEMPORARY tables in the same schema (TEMP and pgtemp,
1171     *     respectively). When these engines resolve a table reference, they first check for
1172     *     a matching table in the temp schema, before checking the current DB domain schema.
1173     *     Note that this breaks table segregation based on the schema component of the DB
1174     *     domain, e.g. a temp table with unqualified name "x" resolves to the same underlying
1175     *     table whether the current DB domain is "my_db-schema1-mw_" or "my_db-schema2-mw_".
1176     *     By ignoring the schema, we can at least account for this.
1177     *   - Exposing the the TEMP/pg_temp schema here would be too leaky of an abstraction,
1178     *     running the risk of unexpected results, such as identifiers that don't match. It is
1179     *     easier to just avoid creating identically-named TEMPORARY tables on different schemas.
1180     *
1181     * @internal only to be used inside rdbms library
1182     * @param string $table Table name
1183     * @return array{0:string|null,1:string} (unquoted database name, unquoted prefixed table name)
1184     */
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
1197    /**
1198     * @return string|null Schema to use to qualify relations in queries
1199     */
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
1214    /**
1215     * Returns if the given identifier looks quoted or not according to
1216     * the database convention for quoting identifiers
1217     *
1218     * @note Do not use this to determine if untrusted input is safe.
1219     *   A malicious user can trick this function.
1220     * @param string $name
1221     * @return bool
1222     */
1223    public function isQuotedIdentifier( $name ) {
1224        $quoteChar = $this->getIdentifierQuoteChar();
1225        return strlen( $name ) > 1 && $name[0] === $quoteChar && $name[-1] === $quoteChar;
1226    }
1227
1228    /**
1229     * USE INDEX clause.
1230     *
1231     * This can be used as optimisation in queries that affect tables with multiple
1232     * indexes if the database does not pick the most optimal one by default.
1233     * The "right" index might vary between database backends and versions thereof,
1234     * as such in practice this is biased toward specifically improving performance
1235     * of large wiki farms that use MySQL or MariaDB (like Wikipedia).
1236     *
1237     * @param string $index
1238     * @return string
1239     */
1240    public function useIndexClause( $index ) {
1241        return '';
1242    }
1243
1244    /**
1245     * IGNORE INDEX clause.
1246     *
1247     * The inverse of Database::useIndexClause.
1248     *
1249     * @param string $index
1250     * @return string
1251     */
1252    public function ignoreIndexClause( $index ) {
1253        return '';
1254    }
1255
1256    /**
1257     * Returns an optional USE INDEX clause to go after the table, and a
1258     * string to go at the end of the query.
1259     *
1260     * @see Database::select()
1261     *
1262     * @param array $options Associative array of options to be turned into
1263     *   an SQL query, valid keys are listed in the function.
1264     * @return string[] (START OPTIONS, PRE-LIMIT TAIL, POST-LIMIT TAIL)
1265     */
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
1318    /**
1319     * Returns an optional GROUP BY with an optional HAVING
1320     *
1321     * @param array $options Associative array of options
1322     * @return string
1323     * @see Database::select()
1324     * @since 1.21
1325     */
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
1344    /**
1345     * Returns an optional ORDER BY
1346     *
1347     * @param array $options Associative array of options
1348     * @return string
1349     * @see Database::select()
1350     * @since 1.21
1351     */
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
1391    /**
1392     * Make SQL lists of columns, row tuples, and column aliases for INSERT/VALUES expressions
1393     *
1394     * The tuple column order is that of the columns of the first provided row.
1395     * The provided rows must have exactly the same keys and ordering thereof.
1396     *
1397     * @param array[] $rows Non-empty list of (column => value) maps
1398     * @param string $aliasPrefix Optional prefix to prepend to the magic alias names
1399     * @param string[] $typeByColumn Optional map of (column => data type)
1400     * @return array (comma-separated columns, comma-separated tuples, comma-separated aliases)
1401     * @since 1.35
1402     */
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
1448    /**
1449     * @return string[] ("INSERT"-style SQL verb, "ON CONFLICT"-style clause or "")
1450     * @since 1.35
1451     */
1452    protected function makeInsertNonConflictingVerbAndOptions() {
1453        return [ 'INSERT IGNORE INTO', '' ];
1454    }
1455
1456    public function insertSelectNativeSqlText(
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
1483    /**
1484     * @param string $option Query option flag (e.g. "IGNORE" or "FOR UPDATE")
1485     * @param array $options Combination option/value map and boolean option list
1486     * @return bool Whether the option appears as an integer-keyed value in the options
1487     * @since 1.35
1488     */
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
1499    /**
1500     * Build an SQL condition to find rows with matching key values to those in $rows.
1501     *
1502     * @param array[] $rows Non-empty list of rows
1503     * @param string[] $uniqueKey List of columns that define a single unique index
1504     * @return string
1505     */
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
1558    /**
1559     * @param string $table The unqualified name of a table
1560     * @param string|array $conds
1561     * @return Query
1562     */
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
1590    /**
1591     * @param mixed $array
1592     * @param int $listType
1593     */
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
1636    /**
1637     * Make UPDATE options for the Database::update function
1638     *
1639     * @param array $options The options passed to Database::update
1640     * @return string
1641     */
1642    protected function makeUpdateOptions( $options ) {
1643        $opts = $this->makeUpdateOptionsArray( $options );
1644
1645        return implode( ' ', $opts );
1646    }
1647
1648    /**
1649     * Make UPDATE options array for Database::makeUpdateOptions
1650     *
1651     * @param array $options
1652     * @return array
1653     */
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
1666    /**
1667     * @param string|array $options
1668     * @return array Combination option/value map and boolean option list
1669     * @since 1.35, moved to SQLPlatform in 1.39
1670     */
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
1688    /**
1689     * @param string $sql SQL query
1690     * @return string|null
1691     * @deprecated Since 1.42
1692     */
1693    public function getQueryVerb( $sql ) {
1694        wfDeprecated( __METHOD__, '1.42' );
1695        return QueryBuilderFromRawSql::buildQuery( $sql, 0 )->getVerb();
1696    }
1697
1698    /**
1699     * Determine whether a SQL statement is sensitive to isolation level.
1700     *
1701     * A SQL statement is considered transactable if its result could vary
1702     * depending on the transaction isolation level. Operational commands
1703     * such as 'SET' and 'SHOW' are not considered to be transactable.
1704     *
1705     * Main purpose: Used by query() to decide whether to begin a transaction
1706     * before the current query (in DBO_TRX mode, on by default).
1707     *
1708     * @return bool
1709     */
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
1766    /**
1767     * @param array $rowOrRows A single (field => value) map or a list of such maps
1768     * @return array[] List of (field => value) maps
1769     * @since 1.35
1770     */
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
1791    /**
1792     * Validate and normalize parameters to upsert() or replace()
1793     *
1794     * @param string|string[]|string[][] $uniqueKeys Unique indexes (only one is allowed)
1795     * @param array[] &$rows The row array, which will be replaced with a normalized version.
1796     * @return string[] List of columns that defines a single unique index
1797     * @since 1.35
1798     */
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
1810    /**
1811     * @param array|string $conds
1812     * @param string $fname
1813     * @return array
1814     * @since 1.31
1815     */
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
1833    /**
1834     * @param string|string[]|string[][] $uniqueKeys Unique indexes (only one is allowed)
1835     * @return string[] List of columns that defines a single unique index
1836     * @since 1.35
1837     */
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
1869    /**
1870     * @param array<int,array> $rows Normalized list of rows to insert
1871     * @param string[] $uniqueKey Columns of the unique key to UPSERT upon
1872     * @since 1.37
1873     */
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
1886    /**
1887     * @param array $set Combined column/literal assignment map and SQL assignment list
1888     * @param string[] $uniqueKey Columns of the unique key to UPSERT upon
1889     * @param array<int,array> $rows List of rows to upsert
1890     * @since 1.37
1891     */
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
1936    /**
1937     * @param array|string $var Field parameter in the style of select()
1938     * @return string|null Column name or null; ignores aliases
1939     */
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
1960    /**
1961     * Get schema variables. If none have been set via setSchemaVars(), then
1962     * use some defaults from the current object.
1963     *
1964     * @return array
1965     */
1966    protected function getSchemaVars() {
1967        return $this->schemaVars ?? $this->getDefaultSchemaVars();
1968    }
1969
1970    /**
1971     * Get schema variables to use if none have been set via setSchemaVars().
1972     *
1973     * Override this in derived classes to provide variables for SQL schema
1974     * and patch files.
1975     *
1976     * @return array
1977     */
1978    protected function getDefaultSchemaVars() {
1979        return [];
1980    }
1981
1982    /**
1983     * Database-independent variable replacement. Replaces a set of variables
1984     * in an SQL statement with their contents as given by $this->getSchemaVars().
1985     *
1986     * Supports '{$var}' `{$var}` and / *$var* / (without the spaces) style variables.
1987     *
1988     * - '{$var}' should be used for text and is passed through the database's
1989     *   addQuotes method.
1990     * - `{$var}` should be used for identifiers (e.g. table and database names).
1991     *   It is passed through the database's addIdentifierQuotes method which
1992     *   can be overridden if the database uses something other than backticks.
1993     * - / *_* / or / *$wgDBprefix* / passes the name that follows through the
1994     *   database's tableName method.
1995     * - / *i* / passes the name that follows through the database's indexName method.
1996     * - In all other cases, / *$var* / is left unencoded. Except for table options,
1997     *   its use should be avoided. In 1.24 and older, string encoding was applied.
1998     *
1999     * @param string $ins SQL statement to replace variables in
2000     * @return string The new SQL statement with variables replaced
2001     */
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}