Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.25% covered (warning)
76.25%
655 / 859
40.82% covered (danger)
40.82%
40 / 98
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQLPlatform
76.25% covered (warning)
76.25%
655 / 859
40.82% covered (danger)
40.82%
40 / 98
2484.80
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
 buildGroupConcat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 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
 setTableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
10.27
 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
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
12.04
 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%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 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
72.73% covered (warning)
72.73%
16 / 22
0.00% covered (danger)
0.00%
0 / 1
6.73
 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
7
 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
31.82% covered (danger)
31.82%
7 / 22
0.00% covered (danger)
0.00%
0 / 1
49.35
 extractSingleFieldFromList
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 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
 unlockSQLText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6namespace Wikimedia\Rdbms\Platform;
7
8use InvalidArgumentException;
9use Psr\Log\LoggerInterface;
10use Psr\Log\NullLogger;
11use RuntimeException;
12use Throwable;
13use Wikimedia\Assert\Assert;
14use Wikimedia\Rdbms\Database\DbQuoter;
15use Wikimedia\Rdbms\DatabaseDomain;
16use Wikimedia\Rdbms\DBLanguageError;
17use Wikimedia\Rdbms\IExpression;
18use Wikimedia\Rdbms\LikeMatch;
19use Wikimedia\Rdbms\LikeValue;
20use Wikimedia\Rdbms\Query;
21use Wikimedia\Rdbms\QueryBuilderFromRawSql;
22use Wikimedia\Rdbms\RawSQLValue;
23use Wikimedia\Rdbms\Subquery;
24use Wikimedia\Timestamp\ConvertibleTimestamp;
25use Wikimedia\Timestamp\TimestampFormat as TS;
26
27/**
28 * Sql abstraction object.
29 * This class nor any of its subclasses shouldn't create a db connection.
30 * It also should not become stateful. The constructor should only rely on addQuotes() method in Database.
31 * Later that should be replaced with an implementation that doesn't use db connections.
32 * @since 1.39
33 */
34class SQLPlatform implements ISQLPlatform {
35    /** @var array[] Current map of (table => (dbname, schema, prefix) map) */
36    protected $tableAliases = [];
37    protected DatabaseDomain $currentDomain;
38    /** @var array|null Current variables use for schema element placeholders */
39    protected $schemaVars;
40    protected DbQuoter $quoter;
41    protected LoggerInterface $logger;
42    /** @var callable Error logging callback */
43    protected $errorLogger;
44
45    /**
46     * @param DbQuoter $quoter
47     * @param ?LoggerInterface $logger
48     * @param ?DatabaseDomain $currentDomain
49     * @param callable|null $errorLogger
50     */
51    public function __construct(
52        DbQuoter $quoter,
53        ?LoggerInterface $logger = null,
54        ?DatabaseDomain $currentDomain = null,
55        $errorLogger = null
56    ) {
57        $this->quoter = $quoter;
58        $this->logger = $logger ?? new NullLogger();
59        $this->currentDomain = $currentDomain ?? DatabaseDomain::newUnspecified();
60        $this->errorLogger = $errorLogger ?? static function ( Throwable $e ) {
61            trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
62        };
63    }
64
65    /** @inheritDoc */
66    public function bitNot( $field ) {
67        return "(~$field)";
68    }
69
70    /** @inheritDoc */
71    public function bitAnd( $fieldLeft, $fieldRight ) {
72        return "($fieldLeft & $fieldRight)";
73    }
74
75    /** @inheritDoc */
76    public function bitOr( $fieldLeft, $fieldRight ) {
77        return "($fieldLeft | $fieldRight)";
78    }
79
80    /** @inheritDoc */
81    public function addIdentifierQuotes( $s ) {
82        if ( strcspn( $s, "\0\"`'." ) !== strlen( $s ) ) {
83            throw new DBLanguageError(
84                "Identifier must not contain quote, dot or null characters: got '$s'"
85            );
86        }
87        $quoteChar = $this->getIdentifierQuoteChar();
88        return $quoteChar . $s . $quoteChar;
89    }
90
91    /**
92     * Get the character used for identifier quoting
93     * @return string
94     */
95    protected function getIdentifierQuoteChar() {
96        return '"';
97    }
98
99    /**
100     * @inheritDoc
101     */
102    public function buildGreatest( $fields, $values ) {
103        return $this->buildSuperlative( 'GREATEST', $fields, $values );
104    }
105
106    /**
107     * @inheritDoc
108     */
109    public function buildLeast( $fields, $values ) {
110        return $this->buildSuperlative( 'LEAST', $fields, $values );
111    }
112
113    /**
114     * Build a superlative function statement comparing columns/values
115     *
116     * Integer and float values in $values will not be quoted
117     *
118     * If $fields is an array, then each value with a string key is treated as an expression
119     * (which must be manually quoted); such string keys do not appear in the SQL and are only
120     * descriptive aliases.
121     *
122     * @param string $sqlfunc Name of a SQL function
123     * @param string|string[] $fields Name(s) of column(s) with values to compare
124     * @param string|int|float|string[]|int[]|float[] $values Values to compare
125     * @return string
126     */
127    protected function buildSuperlative( $sqlfunc, $fields, $values ) {
128        $fields = is_array( $fields ) ? $fields : [ $fields ];
129        $values = is_array( $values ) ? $values : [ $values ];
130
131        $encValues = [];
132        foreach ( $fields as $alias => $field ) {
133            if ( is_int( $alias ) ) {
134                $encValues[] = $this->addIdentifierQuotes( $field );
135            } else {
136                $encValues[] = $field; // expression
137            }
138        }
139        foreach ( $values as $value ) {
140            if ( is_int( $value ) || is_float( $value ) ) {
141                $encValues[] = $value;
142            } elseif ( is_string( $value ) ) {
143                $encValues[] = $this->quoter->addQuotes( $value );
144            } elseif ( $value === null ) {
145                throw new DBLanguageError( 'Null value in superlative' );
146            } else {
147                throw new DBLanguageError( 'Unexpected value type in superlative' );
148            }
149        }
150
151        return $sqlfunc . '(' . implode( ',', $encValues ) . ')';
152    }
153
154    public function buildComparison( string $op, array $conds ): string {
155        if ( !in_array( $op, [ '>', '>=', '<', '<=' ] ) ) {
156            throw new InvalidArgumentException( "Comparison operator must be one of '>', '>=', '<', '<='" );
157        }
158        if ( count( $conds ) === 0 ) {
159            throw new InvalidArgumentException( "Empty input" );
160        }
161
162        // Construct a condition string by starting with the least significant part of the index, and
163        // adding more significant parts progressively to the left of the string.
164        //
165        // For example, given $conds = [ 'a' => 4, 'b' => 7, 'c' => 1 ], this will generate a condition
166        // like this:
167        //
168        //   WHERE  a > 4
169        //      OR (a = 4 AND (b > 7
170        //                 OR (b = 7 AND (c > 1))))
171        //
172        // â€¦which is equivalent to the following, which might be easier to understand:
173        //
174        //   WHERE a > 4
175        //      OR a = 4 AND b > 7
176        //      OR a = 4 AND b = 7 AND c > 1
177        //
178        // â€¦and also equivalent to the following, using tuple comparison syntax, which is most intuitive
179        // but apparently performs worse:
180        //
181        //   WHERE (a, b, c) > (4, 7, 1)
182
183        $sql = '';
184        foreach ( array_reverse( $conds ) as $field => $value ) {
185            if ( is_int( $field ) ) {
186                throw new InvalidArgumentException(
187                    'Non-associative array passed to buildComparison() (typo?)'
188                );
189            }
190            $encValue = $this->quoter->addQuotes( $value );
191            if ( $sql === '' ) {
192                $sql = "$field $op $encValue";
193                // Change '>=' to '>' etc. for remaining fields, as the equality is handled separately
194                $op = rtrim( $op, '=' );
195            } else {
196                $sql = "$field $op $encValue OR ($field = $encValue AND ($sql))";
197            }
198        }
199        return $sql;
200    }
201
202    /** @inheritDoc */
203    public function makeList( array $a, $mode = self::LIST_COMMA ) {
204        $first = true;
205        $list = '';
206        $keyWarning = null;
207
208        foreach ( $a as $field => $value ) {
209            if ( $first ) {
210                $first = false;
211            } else {
212                if ( $mode == self::LIST_AND ) {
213                    $list .= ' AND ';
214                } elseif ( $mode == self::LIST_OR ) {
215                    $list .= ' OR ';
216                } else {
217                    $list .= ',';
218                }
219            }
220
221            if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
222                if ( $value instanceof IExpression ) {
223                    $list .= "(" . $value->toSql( $this->quoter ) . ")";
224                } elseif ( is_array( $value ) ) {
225                    throw new InvalidArgumentException( __METHOD__ . ": unexpected array value without key" );
226                } elseif ( $value instanceof RawSQLValue ) {
227                    throw new InvalidArgumentException( __METHOD__ . ": unexpected raw value without key" );
228                } else {
229                    $list .= "($value)";
230                }
231            } elseif ( $value instanceof IExpression ) {
232                if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
233                    throw new InvalidArgumentException( __METHOD__ . ": unexpected key $field for IExpression value" );
234                } else {
235                    throw new InvalidArgumentException( __METHOD__ . ": unexpected IExpression outside WHERE clause" );
236                }
237            } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
238                $list .= "$value";
239            } elseif (
240                ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
241            ) {
242                // Remove null from array to be handled separately if found
243                $includeNull = false;
244                foreach ( array_keys( $value, null, true ) as $nullKey ) {
245                    $includeNull = true;
246                    unset( $value[$nullKey] );
247                }
248                if ( count( $value ) == 0 && !$includeNull ) {
249                    throw new InvalidArgumentException(
250                        __METHOD__ . ": empty input for field $field" );
251                } elseif ( count( $value ) == 0 ) {
252                    // only check if $field is null
253                    $list .= "$field IS NULL";
254                } else {
255                    // IN clause contains at least one valid element
256                    if ( $includeNull ) {
257                        // Group subconditions to ensure correct precedence
258                        $list .= '(';
259                    }
260                    if ( count( $value ) == 1 ) {
261                        // Special-case single values, as IN isn't terribly efficient
262                        // (but call makeList() so that warnings are emitted if needed)
263                        $list .= $field . " = " . $this->makeList( $value );
264                    } else {
265                        $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
266                    }
267                    // if null present in array, append IS NULL
268                    if ( $includeNull ) {
269                        $list .= " OR $field IS NULL)";
270                    }
271                }
272            } elseif ( is_array( $value ) ) {
273                throw new InvalidArgumentException( __METHOD__ . ": unexpected nested array" );
274            } elseif ( $value === null ) {
275                if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
276                    $list .= "$field IS ";
277                } elseif ( $mode == self::LIST_SET ) {
278                    $list .= "$field = ";
279                } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
280                    $keyWarning ??= [
281                        __METHOD__ . ": array key {key} in list of values ignored",
282                        [ 'key' => $field, 'exception' => new RuntimeException() ]
283                    ];
284                } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
285                    $keyWarning ??= [
286                        __METHOD__ . ": array key {key} in list of fields ignored",
287                        [ 'key' => $field, 'exception' => new RuntimeException() ]
288                    ];
289                }
290                $list .= 'NULL';
291            } else {
292                if (
293                    $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
294                ) {
295                    $list .= "$field = ";
296                } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
297                    $keyWarning ??= [
298                        __METHOD__ . ": array key {key} in list of values ignored",
299                        [ 'key' => $field, 'exception' => new RuntimeException() ]
300                    ];
301                } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
302                    $keyWarning ??= [
303                        __METHOD__ . ": array key {key} in list of fields ignored",
304                        [ 'key' => $field, 'exception' => new RuntimeException() ]
305                    ];
306                }
307                $list .= $mode == self::LIST_NAMES ? $value : $this->quoter->addQuotes( $value );
308            }
309        }
310
311        if ( $keyWarning ) {
312            // Only log one warning about this per function call, to reduce log spam when a dynamically
313            // generated associative array is passed
314            $this->logger->warning( ...$keyWarning );
315        }
316
317        return $list;
318    }
319
320    /** @inheritDoc */
321    public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
322        $conds = [];
323        foreach ( $data as $base => $sub ) {
324            if ( count( $sub ) ) {
325                $conds[] = $this->makeList(
326                    [ $baseKey => $base, $subKey => array_map( 'strval', array_keys( $sub ) ) ],
327                    self::LIST_AND
328                );
329            }
330        }
331
332        if ( !$conds ) {
333            throw new InvalidArgumentException( "Data for $baseKey and $subKey must be non-empty" );
334        }
335
336        return $this->makeList( $conds, self::LIST_OR );
337    }
338
339    /** @inheritDoc */
340    public function factorConds( $condsArray ) {
341        if ( count( $condsArray ) === 0 ) {
342            throw new InvalidArgumentException(
343                __METHOD__ . ": empty condition array" );
344        }
345        $condsByFieldSet = [];
346        foreach ( $condsArray as $conds ) {
347            if ( !count( $conds ) ) {
348                throw new InvalidArgumentException(
349                    __METHOD__ . ": empty condition subarray" );
350            }
351            $fieldKey = implode( ',', array_keys( $conds ) );
352            $condsByFieldSet[$fieldKey][] = $conds;
353        }
354        $result = '';
355        foreach ( $condsByFieldSet as $conds ) {
356            if ( $result !== '' ) {
357                $result .= ' OR ';
358            }
359            $result .= $this->factorCondsWithCommonFields( $conds );
360        }
361        return $result;
362    }
363
364    /**
365     * Same as factorConds() but with each element in the array having the same
366     * set of array keys. Validation is done by the caller.
367     *
368     * @param array $condsArray
369     * @return string
370     */
371    private function factorCondsWithCommonFields( $condsArray ) {
372        $first = $condsArray[array_key_first( $condsArray )];
373        if ( count( $first ) === 1 ) {
374            // IN clause
375            $field = array_key_first( $first );
376            $values = [];
377            foreach ( $condsArray as $conds ) {
378                $values[] = $conds[$field];
379            }
380            return $this->makeList( [ $field => $values ], self::LIST_AND );
381        }
382
383        $field1 = array_key_first( $first );
384        $nullExpressions = [];
385        $expressionsByField1 = [];
386        foreach ( $condsArray as $conds ) {
387            $value1 = $conds[$field1];
388            unset( $conds[$field1] );
389            if ( $value1 === null ) {
390                $nullExpressions[] = $conds;
391            } else {
392                $expressionsByField1[$value1][] = $conds;
393            }
394
395        }
396        $wrap = false;
397        $result = '';
398        foreach ( $expressionsByField1 as $value1 => $expressions ) {
399            if ( $result !== '' ) {
400                $result .= ' OR ';
401                $wrap = true;
402            }
403            $factored = $this->factorCondsWithCommonFields( $expressions );
404            $result .= "($field1 = " . $this->quoter->addQuotes( $value1 ) .
405                " AND $factored)";
406        }
407        if ( count( $nullExpressions ) ) {
408            $factored = $this->factorCondsWithCommonFields( $nullExpressions );
409            if ( $result !== '' ) {
410                $result .= ' OR ';
411                $wrap = true;
412            }
413            $result .= "($field1 IS NULL AND $factored)";
414        }
415        if ( $wrap ) {
416            return "($result)";
417        } else {
418            return $result;
419        }
420    }
421
422    /**
423     * @inheritDoc
424     */
425    public function buildConcat( $stringList ) {
426        return 'CONCAT(' . implode( ',', $stringList ) . ')';
427    }
428
429    /** @inheritDoc */
430    public function buildGroupConcat( $field, $delim ): string {
431        return 'GROUP_CONCAT(' . $field . ' SEPARATOR ' . $this->quoter->addQuotes( $delim ) . ')';
432    }
433
434    /** @inheritDoc */
435    public function limitResult( $sql, $limit, $offset = false ) {
436        if ( !is_numeric( $limit ) ) {
437            throw new DBLanguageError(
438                "Invalid non-numeric limit passed to " . __METHOD__
439            );
440        }
441        // This version works in MySQL and SQLite. It will very likely need to be
442        // overridden for most other RDBMS subclasses.
443        return "$sql LIMIT "
444            . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
445            . "{$limit} ";
446    }
447
448    /**
449     * @param string $s
450     * @param string $escapeChar
451     * @return string
452     */
453    public function escapeLikeInternal( $s, $escapeChar = '`' ) {
454        return str_replace(
455            [ $escapeChar, '%', '_' ],
456            [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
457            $s
458        );
459    }
460
461    /** @inheritDoc */
462    public function buildLike( $param, ...$params ) {
463        if ( is_array( $param ) ) {
464            $params = $param;
465            $param = array_shift( $params );
466        }
467        $likeValue = new LikeValue( $param, ...$params );
468
469        return ' LIKE ' . $likeValue->toSql( $this->quoter );
470    }
471
472    /** @inheritDoc */
473    public function anyChar() {
474        return new LikeMatch( '_' );
475    }
476
477    /** @inheritDoc */
478    public function anyString() {
479        return new LikeMatch( '%' );
480    }
481
482    /**
483     * @inheritDoc
484     */
485    public function unionSupportsOrderAndLimit() {
486        return true; // True for almost every DB supported
487    }
488
489    /** @inheritDoc */
490    public function unionQueries( $sqls, $all, $options = [] ) {
491        $glue = $all ? ') UNION ALL (' : ') UNION (';
492
493        $sql = '(' . implode( $glue, $sqls ) . ')';
494        if ( !$this->unionSupportsOrderAndLimit() ) {
495            return $sql;
496        }
497        $sql .= $this->makeOrderBy( $options );
498        $limit = $options['LIMIT'] ?? null;
499        $offset = $options['OFFSET'] ?? false;
500        if ( $limit !== null ) {
501            $sql = $this->limitResult( $sql, $limit, $offset );
502        }
503
504        return $sql;
505    }
506
507    /** @inheritDoc */
508    public function conditional( $cond, $caseTrueExpression, $caseFalseExpression ) {
509        if ( is_array( $cond ) ) {
510            $cond = $this->makeList( $cond, self::LIST_AND );
511        }
512        if ( $cond instanceof IExpression ) {
513            $cond = $cond->toSql( $this->quoter );
514        }
515        if ( $caseTrueExpression instanceof Subquery ) {
516            $caseTrueExpression = (string)$caseTrueExpression;
517        }
518        if ( $caseFalseExpression instanceof Subquery ) {
519            $caseFalseExpression = (string)$caseFalseExpression;
520        }
521
522        return "(CASE WHEN $cond THEN $caseTrueExpression ELSE $caseFalseExpression END)";
523    }
524
525    /** @inheritDoc */
526    public function strreplace( $orig, $old, $new ) {
527        return "REPLACE({$orig}{$old}{$new})";
528    }
529
530    /** @inheritDoc */
531    public function timestamp( $ts = 0 ) {
532        $t = new ConvertibleTimestamp( $ts );
533        // Let errors bubble up to avoid putting garbage in the DB
534        return $t->getTimestamp( TS::MW );
535    }
536
537    /** @inheritDoc */
538    public function timestampOrNull( $ts = null ) {
539        if ( $ts === null ) {
540            return null;
541        } else {
542            return $this->timestamp( $ts );
543        }
544    }
545
546    /** @inheritDoc */
547    public function getInfinity() {
548        return 'infinity';
549    }
550
551    /** @inheritDoc */
552    public function encodeExpiry( $expiry ) {
553        return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
554            ? $this->getInfinity()
555            : $this->timestamp( $expiry );
556    }
557
558    /** @inheritDoc */
559    public function decodeExpiry( $expiry, $format = TS::MW ) {
560        if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
561            return 'infinity';
562        }
563
564        return ConvertibleTimestamp::convert( $format, $expiry );
565    }
566
567    /**
568     * @inheritDoc
569     */
570    public function buildSubstring( $input, $startPosition, $length = null ) {
571        $this->assertBuildSubstringParams( $startPosition, $length );
572        $functionBody = "$input FROM $startPosition";
573        if ( $length !== null ) {
574            $functionBody .= " FOR $length";
575        }
576        return 'SUBSTRING(' . $functionBody . ')';
577    }
578
579    /**
580     * Check type and bounds for parameters to self::buildSubstring()
581     *
582     * All supported databases have substring functions that behave the same for
583     * positive $startPosition and non-negative $length, but behaviors differ when
584     * given negative $startPosition or negative $length. The simplest
585     * solution to that is to just forbid those values.
586     *
587     * @param int $startPosition
588     * @param int|null $length
589     * @since 1.31 in Database, moved to SQLPlatform in 1.39
590     */
591    protected function assertBuildSubstringParams( $startPosition, $length ) {
592        if ( $startPosition === 0 ) {
593            // The DBMSs we support use 1-based indexing here.
594            throw new InvalidArgumentException( 'Use 1 as $startPosition for the beginning of the string' );
595        }
596        if ( !is_int( $startPosition ) || $startPosition < 0 ) {
597            throw new InvalidArgumentException(
598                '$startPosition must be a positive integer'
599            );
600        }
601        if ( !( ( is_int( $length ) && $length >= 0 ) || $length === null ) ) {
602            throw new InvalidArgumentException(
603                '$length must be null or an integer greater than or equal to 0'
604            );
605        }
606    }
607
608    /** @inheritDoc */
609    public function buildStringCast( $field ) {
610        // In theory this should work for any standards-compliant
611        // SQL implementation, although it may not be the best way to do it.
612        return "CAST( $field AS CHARACTER )";
613    }
614
615    /** @inheritDoc */
616    public function buildIntegerCast( $field ) {
617        return 'CAST( ' . $field . ' AS INTEGER )';
618    }
619
620    /** @inheritDoc */
621    public function implicitOrderby() {
622        return true;
623    }
624
625    public function setTableAliases( array $aliases ) {
626        $this->tableAliases = $aliases;
627    }
628
629    /**
630     * @return array[]
631     */
632    public function getTableAliases() {
633        return $this->tableAliases;
634    }
635
636    public function setPrefix( string $prefix ) {
637        $this->currentDomain = new DatabaseDomain(
638            $this->currentDomain->getDatabase(),
639            $this->currentDomain->getSchema(),
640            $prefix
641        );
642    }
643
644    public function setCurrentDomain( DatabaseDomain $currentDomain ) {
645        $this->currentDomain = $currentDomain;
646    }
647
648    /**
649     * @internal For use by tests
650     * @return DatabaseDomain
651     */
652    public function getCurrentDomain() {
653        return $this->currentDomain;
654    }
655
656    /** @inheritDoc */
657    public function selectSQLText(
658        $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
659    ) {
660        if ( !is_array( $tables ) ) {
661            if ( $tables === '' || $tables === null || $tables === false ) {
662                $tables = [];
663            } elseif ( is_string( $tables ) ) {
664                $tables = [ $tables ];
665            } else {
666                throw new DBLanguageError( __METHOD__ . ' called with incorrect table parameter' );
667            }
668        }
669
670        if ( is_array( $vars ) ) {
671            $fields = implode( ',', $this->fieldNamesWithAlias( $vars ) );
672        } else {
673            $fields = $vars;
674        }
675
676        $options = (array)$options;
677
678        $useIndexByTable = $options['USE INDEX'] ?? [];
679        if ( !is_array( $useIndexByTable ) ) {
680            if ( count( $tables ) <= 1 ) {
681                $useIndexByTable = [ reset( $tables ) => $useIndexByTable ];
682            } else {
683                $e = new DBLanguageError( __METHOD__ . " got ambiguous USE INDEX ($fname)" );
684                ( $this->errorLogger )( $e );
685            }
686        }
687
688        $ignoreIndexByTable = $options['IGNORE INDEX'] ?? [];
689        if ( !is_array( $ignoreIndexByTable ) ) {
690            if ( count( $tables ) <= 1 ) {
691                $ignoreIndexByTable = [ reset( $tables ) => $ignoreIndexByTable ];
692            } else {
693                $e = new DBLanguageError( __METHOD__ . " got ambiguous IGNORE INDEX ($fname)" );
694                ( $this->errorLogger )( $e );
695            }
696        }
697
698        if (
699            $this->selectOptionsIncludeLocking( $options ) &&
700            $this->selectFieldsOrOptionsAggregate( $vars, $options )
701        ) {
702            // Some DB types (e.g. postgres) disallow FOR UPDATE with aggregate
703            // functions. Discourage use of such queries to encourage compatibility.
704            $this->logger->warning(
705                __METHOD__ . ": aggregation used with a locking SELECT ($fname)"
706            );
707        }
708
709        if ( count( $tables ) ) {
710            $from = ' FROM ' . $this->tableNamesWithIndexClauseOrJOIN(
711                $tables,
712                $useIndexByTable,
713                $ignoreIndexByTable,
714                $join_conds
715            );
716        } else {
717            $from = '';
718        }
719
720        [ $startOpts, $preLimitTail, $postLimitTail ] = $this->makeSelectOptions( $options );
721
722        if ( is_array( $conds ) ) {
723            $where = $this->makeList( $conds, self::LIST_AND );
724        } elseif ( $conds instanceof IExpression ) {
725            $where = $conds->toSql( $this->quoter );
726        } elseif ( $conds === null || $conds === false ) {
727            $where = '';
728            $this->logger->warning(
729                __METHOD__
730                . ' called from '
731                . $fname
732                . ' with incorrect parameters: $conds must be a string or an array',
733                [ 'db_log_category' => 'sql' ]
734            );
735        } elseif ( is_string( $conds ) ) {
736            $where = $conds;
737        } else {
738            throw new DBLanguageError( __METHOD__ . ' called with incorrect parameters' );
739        }
740
741        // Keep historical extra spaces after FROM to avoid testing failures
742        if ( $where === '' || $where === '*' ) {
743            $sql = "SELECT $startOpts $fields $from   $preLimitTail";
744        } else {
745            $sql = "SELECT $startOpts $fields $from   WHERE $where $preLimitTail";
746        }
747
748        if ( isset( $options['LIMIT'] ) ) {
749            $sql = $this->limitResult( $sql, $options['LIMIT'], $options['OFFSET'] ?? false );
750        }
751        $sql = "$sql $postLimitTail";
752
753        if ( isset( $options['EXPLAIN'] ) ) {
754            $sql = 'EXPLAIN ' . $sql;
755        }
756
757        if (
758            $fname === static::CALLER_UNKNOWN ||
759            str_starts_with( $fname, 'Wikimedia\\Rdbms\\' ) ||
760            $fname === '{closure}'
761        ) {
762            $exception = new RuntimeException();
763
764            // Try to figure out and report the real caller
765            $caller = '';
766            foreach ( $exception->getTrace() as $call ) {
767                if ( str_ends_with( $call['file'] ?? '', 'Test.php' ) ) {
768                    // Don't warn when called directly by test code, adding callers there is pointless
769                    break;
770                } elseif ( str_starts_with( $call['class'] ?? '', 'Wikimedia\\Rdbms\\' ) ) {
771                    // Keep looking for the caller of a rdbms method
772                } elseif ( str_ends_with( $call['class'] ?? '', 'SelectQueryBuilder' ) ) {
773                    // Keep looking for the caller of any custom SelectQueryBuilder
774                } else {
775                    // Warn about the external caller we found
776                    $caller = implode( '::', array_filter( [ $call['class'] ?? null, $call['function'] ] ) );
777                    break;
778                }
779            }
780
781            if ( $fname === '{closure}' ) {
782                // Someone did ->caller( __METHOD__ ) in a local function, e.g. in a callback to
783                // getWithSetCallback(), MWCallableUpdate or doAtomicSection(). That's not very helpful.
784                // Provide a more specific message. The caller has to be provided like this:
785                //   $method = __METHOD__;
786                //   function ( ... ) use ( $method ) { ... }
787                $warning = "SQL query with incorrect caller (__METHOD__ used inside a closure: {caller}): {sql}";
788            } else {
789                $warning = "SQL query did not specify the caller (guessed caller: {caller}): {sql}";
790            }
791
792            $this->logger->warning(
793                $warning,
794                [ 'sql' => $sql, 'caller' => $caller, 'exception' => $exception ]
795            );
796        }
797
798        return $sql;
799    }
800
801    /**
802     * @param string|array $options
803     * @return bool
804     */
805    private function selectOptionsIncludeLocking( $options ) {
806        $options = (array)$options;
807        foreach ( [ 'FOR UPDATE', 'LOCK IN SHARE MODE' ] as $lock ) {
808            if ( in_array( $lock, $options, true ) ) {
809                return true;
810            }
811        }
812
813        return false;
814    }
815
816    /**
817     * @param array|string $fields
818     * @param array|string $options
819     * @return bool
820     */
821    private function selectFieldsOrOptionsAggregate( $fields, $options ) {
822        foreach ( (array)$options as $key => $value ) {
823            if ( is_string( $key ) ) {
824                if ( preg_match( '/^(?:GROUP BY|HAVING)$/i', $key ) ) {
825                    return true;
826                }
827            } elseif ( is_string( $value ) ) {
828                if ( preg_match( '/^(?:DISTINCT|DISTINCTROW)$/i', $value ) ) {
829                    return true;
830                }
831            }
832        }
833
834        $regex = '/^(?:COUNT|MIN|MAX|SUM|GROUP_CONCAT|LISTAGG|ARRAY_AGG)\s*\\(/i';
835        foreach ( (array)$fields as $field ) {
836            if ( is_string( $field ) && preg_match( $regex, $field ) ) {
837                return true;
838            }
839        }
840
841        return false;
842    }
843
844    /**
845     * Gets an array of aliased field names
846     *
847     * @param array $fields [ [alias] => field ]
848     * @return string[] See fieldNameWithAlias()
849     */
850    protected function fieldNamesWithAlias( $fields ) {
851        $retval = [];
852        foreach ( $fields as $alias => $field ) {
853            if ( is_numeric( $alias ) ) {
854                $alias = $field;
855            }
856            $retval[] = $this->fieldNameWithAlias( $field, $alias );
857        }
858
859        return $retval;
860    }
861
862    /**
863     * Get an aliased field name
864     * e.g. fieldName AS newFieldName
865     *
866     * @param string $name Field name
867     * @param string|false $alias Alias (optional)
868     * @return string SQL name for aliased field. Will not alias a field to its own name
869     */
870    public function fieldNameWithAlias( $name, $alias = false ) {
871        if ( !$alias || (string)$alias === (string)$name ) {
872            return $name;
873        } else {
874            return $name . ' AS ' . $this->addIdentifierQuotes( $alias ); // PostgreSQL needs AS
875        }
876    }
877
878    /**
879     * Get the aliased table name clause for a FROM clause
880     * which might have a JOIN and/or USE INDEX or IGNORE INDEX clause
881     *
882     * @param array $tables Array of ([alias] => table reference)
883     * @param array $use_index Same as for select()
884     * @param array $ignore_index Same as for select()
885     * @param array $join_conds Same as for select()
886     * @return string
887     */
888    protected function tableNamesWithIndexClauseOrJOIN(
889        $tables,
890        $use_index = [],
891        $ignore_index = [],
892        $join_conds = []
893    ) {
894        $ret = [];
895        $retJOIN = [];
896        $use_index = (array)$use_index;
897        $ignore_index = (array)$ignore_index;
898        $join_conds = (array)$join_conds;
899
900        foreach ( $tables as $alias => $table ) {
901            if ( !is_string( $alias ) ) {
902                // No alias? Set it equal to the table name
903                $alias = $table;
904            }
905
906            if ( is_array( $table ) ) {
907                // A parenthesized group
908                if ( count( $table ) > 1 ) {
909                    $joinedTable = '(' .
910                        $this->tableNamesWithIndexClauseOrJOIN(
911                            $table, $use_index, $ignore_index, $join_conds ) . ')';
912                } else {
913                    // Degenerate case
914                    $innerTable = reset( $table );
915                    $innerAlias = key( $table );
916                    $joinedTable = $this->tableNameWithAlias(
917                        $innerTable,
918                        is_string( $innerAlias ) ? $innerAlias : $innerTable
919                    );
920                }
921            } else {
922                $joinedTable = $this->tableNameWithAlias( $table, $alias );
923            }
924
925            // Is there a JOIN clause for this table?
926            if ( isset( $join_conds[$alias] ) ) {
927                Assert::parameterType( 'array', $join_conds[$alias], "join_conds[$alias]" );
928                [ $joinType, $conds ] = $join_conds[$alias];
929                $tableClause = $this->normalizeJoinType( $joinType );
930                $tableClause .= ' ' . $joinedTable;
931                if ( isset( $use_index[$alias] ) ) { // has USE INDEX?
932                    $use = $this->useIndexClause( implode( ',', (array)$use_index[$alias] ) );
933                    if ( $use != '' ) {
934                        $tableClause .= ' ' . $use;
935                    }
936                }
937                if ( isset( $ignore_index[$alias] ) ) { // has IGNORE INDEX?
938                    $ignore = $this->ignoreIndexClause(
939                        implode( ',', (array)$ignore_index[$alias] ) );
940                    if ( $ignore != '' ) {
941                        $tableClause .= ' ' . $ignore;
942                    }
943                }
944                $on = $this->makeList( (array)$conds, self::LIST_AND );
945                if ( $on != '' ) {
946                    $tableClause .= ' ON (' . $on . ')';
947                }
948
949                $retJOIN[] = $tableClause;
950            } elseif ( isset( $use_index[$alias] ) ) {
951                // Is there an INDEX clause for this table?
952                $tableClause = $joinedTable;
953                $tableClause .= ' ' . $this->useIndexClause(
954                        implode( ',', (array)$use_index[$alias] )
955                    );
956
957                $ret[] = $tableClause;
958            } elseif ( isset( $ignore_index[$alias] ) ) {
959                // Is there an INDEX clause for this table?
960                $tableClause = $joinedTable;
961                $tableClause .= ' ' . $this->ignoreIndexClause(
962                        implode( ',', (array)$ignore_index[$alias] )
963                    );
964
965                $ret[] = $tableClause;
966            } else {
967                $tableClause = $joinedTable;
968
969                $ret[] = $tableClause;
970            }
971        }
972
973        // We can't separate explicit JOIN clauses with ',', use ' ' for those
974        $implicitJoins = implode( ',', $ret );
975        $explicitJoins = implode( ' ', $retJOIN );
976
977        // Compile our final table clause
978        return implode( ' ', [ $implicitJoins, $explicitJoins ] );
979    }
980
981    /**
982     * Validate and normalize a join type
983     *
984     * Subclasses may override this to add supported join types.
985     *
986     * @param string $joinType
987     * @return string
988     */
989    protected function normalizeJoinType( string $joinType ) {
990        switch ( strtoupper( $joinType ) ) {
991            case 'JOIN':
992            case 'INNER JOIN':
993                return 'JOIN';
994
995            case 'LEFT JOIN':
996                return 'LEFT JOIN';
997
998            case 'STRAIGHT_JOIN':
999            case 'STRAIGHT JOIN':
1000                // MySQL only
1001                return 'JOIN';
1002
1003            default:
1004                return $joinType;
1005        }
1006    }
1007
1008    /**
1009     * Get an aliased table name
1010     *
1011     * This returns strings like "tableName AS newTableName" for aliased tables
1012     * and "(SELECT * from tableA) newTablename" for subqueries (e.g. derived tables)
1013     *
1014     * @see Database::tableName()
1015     * @param string|Subquery $table The unqualified name of a table, or Subquery
1016     * @param string|false $alias Table alias (optional)
1017     * @return string SQL name for aliased table. Will not alias a table to its own name
1018     */
1019    protected function tableNameWithAlias( $table, $alias = false ) {
1020        if ( is_string( $table ) ) {
1021            $quotedTable = $this->tableName( $table );
1022        } elseif ( $table instanceof Subquery ) {
1023            $quotedTable = (string)$table;
1024        } else {
1025            throw new InvalidArgumentException( "Table must be a string or Subquery" );
1026        }
1027
1028        if ( $alias === false ) {
1029            if ( $table instanceof Subquery ) {
1030                throw new InvalidArgumentException( "Subquery table missing alias" );
1031            }
1032            $quotedTableWithAnyAlias = $quotedTable;
1033        } elseif (
1034            $alias === $table &&
1035            (
1036                str_contains( $alias, '.' ) ||
1037                $this->tableName( $alias, 'raw' ) === $table
1038            )
1039        ) {
1040            $quotedTableWithAnyAlias = $quotedTable;
1041        } else {
1042            $quotedTableWithAnyAlias = $quotedTable . ' ' . $this->addIdentifierQuotes( $alias );
1043        }
1044
1045        return $quotedTableWithAnyAlias;
1046    }
1047
1048    /** @inheritDoc */
1049    public function tableName( string $name, $format = 'quoted' ) {
1050        $prefix = $this->currentDomain->getTablePrefix();
1051
1052        // Warn about table names that look qualified
1053        if (
1054            (
1055                str_contains( $name, '.' ) &&
1056                !preg_match( '/^information_schema\.[a-z_0-9]+$/', $name )
1057            ) ||
1058            ( $prefix !== '' && str_starts_with( $name, $prefix ) )
1059        ) {
1060            $this->logger->warning(
1061                __METHOD__ . ' called with qualified table ' . $name,
1062                [ 'db_log_category' => 'sql' ]
1063            );
1064        }
1065
1066        // Extract necessary database, schema, table identifiers and quote them as needed
1067        $formattedComponents = [];
1068        foreach ( $this->qualifiedTableComponents( $name ) as $component ) {
1069            if ( $format === 'quoted' ) {
1070                $formattedComponents[] = $this->addIdentifierQuotes( $component );
1071            } else {
1072                $formattedComponents[] = $component;
1073            }
1074        }
1075
1076        return implode( '.', $formattedComponents );
1077    }
1078
1079    /**
1080     * Get the table components needed for a query given the currently selected database/schema
1081     *
1082     * The resulting array will take one of the follow forms:
1083     *  - <table identifier>
1084     *  - <database identifier>.<table identifier> (e.g. non-Postgres)
1085     *  - <schema identifier>.<table identifier> (e.g. Postgres-only)
1086     *  - <database identifier>.<schema identifier>.<table identifier> (e.g. Postgres-only)
1087     *
1088     * If the provided table name only consists of an unquoted table identifier that has an
1089     * entry in ({@link getTableAliases()}), then, the resulting components will be determined
1090     * from the alias configuration. If such alias configuration does not specify the table
1091     * prefix, then the current DB domain prefix will be prepended to the table identifier.
1092     *
1093     * In all other cases where the provided table name only consists of an unquoted table
1094     * identifier, the current DB domain prefix will be prepended to the table identifier.
1095     *
1096     * Empty database/schema identifiers are omitted from the resulting array.
1097     *
1098     * @param string $name Table name as database.schema.table, database.table, or table
1099     * @return string[] Non-empty list of unquoted identifiers that form the qualified table name
1100     */
1101    public function qualifiedTableComponents( $name ) {
1102        $identifiers = $this->extractTableNameComponents( $name );
1103        if ( count( $identifiers ) > 3 ) {
1104            throw new DBLanguageError( "Too many components in table name '$name'" );
1105        }
1106        // Table alias config and prefixes only apply to unquoted single-identifier names
1107        if ( count( $identifiers ) == 1 && !$this->isQuotedIdentifier( $identifiers[0] ) ) {
1108            [ $table ] = $identifiers;
1109            if ( isset( $this->tableAliases[$table] ) ) {
1110                // This is an "alias" table that uses a different db/schema/prefix scheme
1111                $database = $this->tableAliases[$table]['dbname'];
1112                $schema = is_string( $this->tableAliases[$table]['schema'] )
1113                    ? $this->tableAliases[$table]['schema']
1114                    : $this->relationSchemaQualifier();
1115                $prefix = is_string( $this->tableAliases[$table]['prefix'] )
1116                    ? $this->tableAliases[$table]['prefix']
1117                    : $this->currentDomain->getTablePrefix();
1118            } else {
1119                // Use the current database domain to resolve the schema and prefix
1120                $database = '';
1121                $schema = $this->relationSchemaQualifier();
1122                $prefix = $this->currentDomain->getTablePrefix();
1123            }
1124            $qualifierIdentifiers = [ $database, $schema ];
1125            $tableIdentifier = $prefix . $table;
1126        } else {
1127            $qualifierIdentifiers = array_slice( $identifiers, 0, -1 );
1128            $tableIdentifier = end( $identifiers );
1129        }
1130
1131        $components = [];
1132        foreach ( $qualifierIdentifiers as $identifier ) {
1133            if ( $identifier !== null && $identifier !== '' ) {
1134                $components[] = $this->isQuotedIdentifier( $identifier )
1135                    ? substr( $identifier, 1, -1 )
1136                    : $identifier;
1137            }
1138        }
1139        $components[] = $this->isQuotedIdentifier( $tableIdentifier )
1140            ? substr( $tableIdentifier, 1, -1 )
1141            : $tableIdentifier;
1142
1143        return $components;
1144    }
1145
1146    /**
1147     * Extract the dot-separated components of a table name, preserving identifier quotation
1148     *
1149     * @param string $name Table name, possible qualified with db or db+schema
1150     * @return string[] Non-empty list of the identifiers included in the provided table name
1151     */
1152    public function extractTableNameComponents( string $name ) {
1153        $quoteChar = $this->getIdentifierQuoteChar();
1154        $components = [];
1155        foreach ( explode( '.', $name ) as $component ) {
1156            if ( $this->isQuotedIdentifier( $component ) ) {
1157                $unquotedComponent = substr( $component, 1, -1 );
1158            } else {
1159                $unquotedComponent = $component;
1160            }
1161            if ( str_contains( $unquotedComponent, $quoteChar ) ) {
1162                throw new DBLanguageError(
1163                    'Table name component contains unexpected quote or dot character' );
1164            }
1165            $components[] = $component;
1166        }
1167        return $components;
1168    }
1169
1170    /**
1171     * Get the database identifer and prefixed table name identifier for a table
1172     *
1173     * The table name is assumed to be relative to the current DB domain
1174     *
1175     * This method is useful for TEMPORARY table tracking. In MySQL, temp tables with identical
1176     * names can co-exist on different databases, which can be done via CREATE and USE. Note
1177     * that SQLite/PostgreSQL do not allow changing the database within a session. This method
1178     * omits the schema identifier and set it to empty string for several reasons:
1179     *   - MySQL/MariaDB do not support schemas at all.
1180     *   - SQLite/PostgreSQL put all TEMPORARY tables in the same schema (TEMP and pgtemp,
1181     *     respectively). When these engines resolve a table reference, they first check for
1182     *     a matching table in the temp schema, before checking the current DB domain schema.
1183     *     Note that this breaks table segregation based on the schema component of the DB
1184     *     domain, e.g. a temp table with unqualified name "x" resolves to the same underlying
1185     *     table whether the current DB domain is "my_db-schema1-mw_" or "my_db-schema2-mw_".
1186     *     By ignoring the schema, we can at least account for this.
1187     *   - Exposing the the TEMP/pg_temp schema here would be too leaky of an abstraction,
1188     *     running the risk of unexpected results, such as identifiers that don't match. It is
1189     *     easier to just avoid creating identically-named TEMPORARY tables on different schemas.
1190     *
1191     * @internal only to be used inside rdbms library
1192     * @param string $table Table name
1193     * @return array{0:string,1:string} (unquoted database name, unquoted prefixed table name)
1194     */
1195    public function getDatabaseAndTableIdentifier( string $table ) {
1196        $components = $this->qualifiedTableComponents( $table );
1197        switch ( count( $components ) ) {
1198            case 1:
1199                return [ $this->currentDomain->getDatabase() ?? '', $components[0] ];
1200            case 2:
1201                return $components;
1202            default:
1203                throw new DBLanguageError( 'Too many table components' );
1204        }
1205    }
1206
1207    /**
1208     * @return string|null Schema to use to qualify relations in queries
1209     */
1210    protected function relationSchemaQualifier() {
1211        return $this->currentDomain->getSchema();
1212    }
1213
1214    /** @inheritDoc */
1215    public function tableNamesN( ...$tables ) {
1216        $retVal = [];
1217
1218        foreach ( $tables as $name ) {
1219            $retVal[] = $this->tableName( $name );
1220        }
1221
1222        return $retVal;
1223    }
1224
1225    /**
1226     * Returns if the given identifier looks quoted or not according to
1227     * the database convention for quoting identifiers
1228     *
1229     * @note Do not use this to determine if untrusted input is safe.
1230     *   A malicious user can trick this function.
1231     * @param string $name
1232     * @return bool
1233     */
1234    public function isQuotedIdentifier( $name ) {
1235        $quoteChar = $this->getIdentifierQuoteChar();
1236        return strlen( $name ) > 1 && $name[0] === $quoteChar && $name[-1] === $quoteChar;
1237    }
1238
1239    /**
1240     * USE INDEX clause.
1241     *
1242     * This can be used as optimisation in queries that affect tables with multiple
1243     * indexes if the database does not pick the most optimal one by default.
1244     * The "right" index might vary between database backends and versions thereof,
1245     * as such in practice this is biased toward specifically improving performance
1246     * of large wiki farms that use MySQL or MariaDB (like Wikipedia).
1247     *
1248     * @param string $index
1249     * @return string
1250     */
1251    public function useIndexClause( $index ) {
1252        return '';
1253    }
1254
1255    /**
1256     * IGNORE INDEX clause.
1257     *
1258     * The inverse of Database::useIndexClause.
1259     *
1260     * @param string $index
1261     * @return string
1262     */
1263    public function ignoreIndexClause( $index ) {
1264        return '';
1265    }
1266
1267    /**
1268     * Returns an optional USE INDEX clause to go after the table, and a
1269     * string to go at the end of the query.
1270     *
1271     * @see Database::select()
1272     *
1273     * @param array $options Associative array of options to be turned into
1274     *   an SQL query, valid keys are listed in the function.
1275     * @return string[] (START OPTIONS, PRE-LIMIT TAIL, POST-LIMIT TAIL)
1276     */
1277    protected function makeSelectOptions( array $options ) {
1278        $preLimitTail = $postLimitTail = '';
1279        $startOpts = '';
1280
1281        $noKeyOptions = [];
1282
1283        foreach ( $options as $key => $option ) {
1284            if ( is_numeric( $key ) ) {
1285                $noKeyOptions[$option] = true;
1286            }
1287        }
1288
1289        $preLimitTail .= $this->makeGroupByWithHaving( $options );
1290
1291        $preLimitTail .= $this->makeOrderBy( $options );
1292
1293        if ( isset( $noKeyOptions['FOR UPDATE'] ) ) {
1294            $postLimitTail .= ' FOR UPDATE';
1295        }
1296
1297        if ( isset( $noKeyOptions['LOCK IN SHARE MODE'] ) ) {
1298            $postLimitTail .= ' LOCK IN SHARE MODE';
1299        }
1300
1301        if ( isset( $noKeyOptions['DISTINCT'] ) || isset( $noKeyOptions['DISTINCTROW'] ) ) {
1302            $startOpts .= 'DISTINCT';
1303        }
1304
1305        # Various MySQL extensions
1306        if ( isset( $noKeyOptions['STRAIGHT_JOIN'] ) ) {
1307            $startOpts .= ' /*! STRAIGHT_JOIN */';
1308        }
1309
1310        if ( isset( $noKeyOptions['SQL_BIG_RESULT'] ) ) {
1311            $startOpts .= ' SQL_BIG_RESULT';
1312        }
1313
1314        if ( isset( $noKeyOptions['SQL_BUFFER_RESULT'] ) ) {
1315            $startOpts .= ' SQL_BUFFER_RESULT';
1316        }
1317
1318        if ( isset( $noKeyOptions['SQL_SMALL_RESULT'] ) ) {
1319            $startOpts .= ' SQL_SMALL_RESULT';
1320        }
1321
1322        if ( isset( $noKeyOptions['SQL_CALC_FOUND_ROWS'] ) ) {
1323            $startOpts .= ' SQL_CALC_FOUND_ROWS';
1324        }
1325
1326        return [ $startOpts, $preLimitTail, $postLimitTail ];
1327    }
1328
1329    /**
1330     * Returns an optional GROUP BY with an optional HAVING
1331     *
1332     * @param array $options Associative array of options
1333     * @return string
1334     * @see Database::select()
1335     * @since 1.21
1336     */
1337    protected function makeGroupByWithHaving( $options ) {
1338        $sql = '';
1339        if ( isset( $options['GROUP BY'] ) ) {
1340            $gb = is_array( $options['GROUP BY'] )
1341                ? implode( ',', $options['GROUP BY'] )
1342                : $options['GROUP BY'];
1343            $sql .= ' GROUP BY ' . $gb;
1344        }
1345        if ( isset( $options['HAVING'] ) ) {
1346            if ( $options['HAVING'] instanceof IExpression ) {
1347                $having = $options['HAVING']->toSql( $this->quoter );
1348            } elseif ( is_array( $options['HAVING'] ) ) {
1349                $having = $this->makeList( $options['HAVING'], self::LIST_AND );
1350            } else {
1351                $having = $options['HAVING'];
1352            }
1353
1354            $sql .= ' HAVING ' . $having;
1355        }
1356
1357        return $sql;
1358    }
1359
1360    /**
1361     * Returns an optional ORDER BY
1362     *
1363     * @param array $options Associative array of options
1364     * @return string
1365     * @see Database::select()
1366     * @since 1.21
1367     */
1368    protected function makeOrderBy( $options ) {
1369        if ( isset( $options['ORDER BY'] ) ) {
1370            $ob = is_array( $options['ORDER BY'] )
1371                ? implode( ',', $options['ORDER BY'] )
1372                : $options['ORDER BY'];
1373
1374            return ' ORDER BY ' . $ob;
1375        }
1376
1377        return '';
1378    }
1379
1380    /** @inheritDoc */
1381    public function buildGroupConcatField(
1382        $delim, $tables, $field, $conds = '', $join_conds = []
1383    ) {
1384        $fld = $this->buildGroupConcat( $field, $delim );
1385
1386        return '(' . $this->selectSQLText( $tables, $fld, $conds, static::CALLER_SUBQUERY, [], $join_conds ) . ')';
1387    }
1388
1389    /** @inheritDoc */
1390    public function buildSelectSubquery(
1391        $tables, $vars, $conds = '', $fname = __METHOD__,
1392        $options = [], $join_conds = []
1393    ) {
1394        return new Subquery(
1395            $this->selectSQLText( $tables, $vars, $conds, $fname, $options, $join_conds )
1396        );
1397    }
1398
1399    /**
1400     * @param string $table
1401     * @param array $rows
1402     * @return string[]
1403     */
1404    public function insertSqlText( $table, array $rows ) {
1405        $encTable = $this->tableName( $table );
1406        [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1407
1408        return [
1409            "INSERT INTO $encTable ($sqlColumns) VALUES $sqlTuples",
1410            "INSERT INTO $encTable ($sqlColumns) VALUES '?'"
1411        ];
1412    }
1413
1414    /**
1415     * Make SQL lists of columns, row tuples, and column aliases for INSERT/VALUES expressions
1416     *
1417     * The tuple column order is that of the columns of the first provided row.
1418     * The provided rows must have exactly the same keys and ordering thereof.
1419     *
1420     * @param array[] $rows Non-empty list of (column => value) maps
1421     * @param string $aliasPrefix Optional prefix to prepend to the magic alias names
1422     * @param string[] $typeByColumn Optional map of (column => data type)
1423     * @return array (comma-separated columns, comma-separated tuples, comma-separated aliases)
1424     * @since 1.35
1425     */
1426    public function makeInsertLists( array $rows, $aliasPrefix = '', array $typeByColumn = [] ) {
1427        $firstRow = $rows[0];
1428        if ( !is_array( $firstRow ) || !$firstRow ) {
1429            throw new DBLanguageError( 'Got an empty row list or empty row' );
1430        }
1431        // List of columns that define the value tuple ordering
1432        $tupleColumns = array_keys( $firstRow );
1433
1434        $valueTuples = [];
1435        foreach ( $rows as $row ) {
1436            $rowColumns = array_keys( $row );
1437            // VALUES(...) requires a uniform correspondence of (column => value)
1438            if ( $rowColumns !== $tupleColumns ) {
1439                throw new DBLanguageError(
1440                    'All rows must specify the same columns in multi-row inserts. Found a row with (' .
1441                    implode( ', ', $rowColumns ) . ') ' .
1442                    'instead of expected (' . implode( ', ', $tupleColumns ) . ') as in the first row'
1443                );
1444            }
1445            // Make the value tuple that defines this row
1446            $valueTuples[] = '(' . $this->makeList( array_values( $row ), self::LIST_COMMA ) . ')';
1447        }
1448
1449        $magicAliasFields = [];
1450        foreach ( $tupleColumns as $column ) {
1451            $magicAliasFields[] = $aliasPrefix . $column;
1452        }
1453
1454        return [
1455            $this->makeList( $tupleColumns, self::LIST_NAMES ),
1456            implode( ',', $valueTuples ),
1457            $this->makeList( $magicAliasFields, self::LIST_NAMES )
1458        ];
1459    }
1460
1461    /**
1462     * @param string $table
1463     * @param array $rows
1464     * @return string[]
1465     */
1466    public function insertNonConflictingSqlText( $table, array $rows ) {
1467        $encTable = $this->tableName( $table );
1468        [ $sqlColumns, $sqlTuples ] = $this->makeInsertLists( $rows );
1469        [ $sqlVerb, $sqlOpts ] = $this->makeInsertNonConflictingVerbAndOptions();
1470
1471        return [
1472            rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES $sqlTuples $sqlOpts" ),
1473            rtrim( "$sqlVerb $encTable ($sqlColumns) VALUES '?' $sqlOpts" )
1474        ];
1475    }
1476
1477    /**
1478     * @return string[] ("INSERT"-style SQL verb, "ON CONFLICT"-style clause or "")
1479     * @since 1.35
1480     */
1481    protected function makeInsertNonConflictingVerbAndOptions() {
1482        return [ 'INSERT IGNORE INTO', '' ];
1483    }
1484
1485    /**
1486     * @param string $destTable
1487     * @param string $srcTable
1488     * @param array $varMap
1489     * @param array $conds
1490     * @param string $fname
1491     * @param array $insertOptions
1492     * @param array $selectOptions
1493     * @param array $selectJoinConds
1494     * @return string
1495     */
1496    public function insertSelectNativeSqlText(
1497        $destTable,
1498        $srcTable,
1499        array $varMap,
1500        $conds,
1501        $fname,
1502        array $insertOptions,
1503        array $selectOptions,
1504        $selectJoinConds
1505    ) {
1506        [ $sqlVerb, $sqlOpts ] = $this->isFlagInOptions( 'IGNORE', $insertOptions )
1507            ? $this->makeInsertNonConflictingVerbAndOptions()
1508            : [ 'INSERT INTO', '' ];
1509        $encDstTable = $this->tableName( $destTable );
1510        $sqlDstColumns = implode( ',', array_keys( $varMap ) );
1511        $selectSql = $this->selectSQLText(
1512            $srcTable,
1513            array_values( $varMap ),
1514            $conds,
1515            $fname,
1516            $selectOptions,
1517            $selectJoinConds
1518        );
1519
1520        return rtrim( "$sqlVerb $encDstTable ($sqlDstColumns$selectSql $sqlOpts" );
1521    }
1522
1523    /**
1524     * @param string $option Query option flag (e.g. "IGNORE" or "FOR UPDATE")
1525     * @param array $options Combination option/value map and boolean option list
1526     * @return bool Whether the option appears as an integer-keyed value in the options
1527     * @since 1.35
1528     */
1529    public function isFlagInOptions( $option, array $options ) {
1530        foreach ( array_keys( $options, $option, true ) as $k ) {
1531            if ( is_int( $k ) ) {
1532                return true;
1533            }
1534        }
1535
1536        return false;
1537    }
1538
1539    /**
1540     * Build an SQL condition to find rows with matching key values to those in $rows.
1541     *
1542     * @param array[] $rows Non-empty list of rows
1543     * @param string[] $uniqueKey List of columns that define a single unique index
1544     * @return string
1545     */
1546    public function makeKeyCollisionCondition( array $rows, array $uniqueKey ) {
1547        if ( !$rows ) {
1548            throw new DBLanguageError( "Empty row array" );
1549        } elseif ( !$uniqueKey ) {
1550            throw new DBLanguageError( "Empty unique key array" );
1551        }
1552
1553        if ( count( $uniqueKey ) == 1 ) {
1554            // Use a simple IN(...) clause
1555            $column = reset( $uniqueKey );
1556            $values = array_column( $rows, $column );
1557            if ( count( $values ) !== count( $rows ) ) {
1558                throw new DBLanguageError( "Missing values for unique key ($column)" );
1559            }
1560
1561            return $this->makeList( [ $column => $values ], self::LIST_AND );
1562        }
1563
1564        $nullByUniqueKeyColumn = array_fill_keys( $uniqueKey, null );
1565
1566        $orConds = [];
1567        foreach ( $rows as $row ) {
1568            $rowKeyMap = array_intersect_key( $row, $nullByUniqueKeyColumn );
1569            if ( count( $rowKeyMap ) != count( $uniqueKey ) ) {
1570                throw new DBLanguageError(
1571                    "Missing values for unique key (" . implode( ',', $uniqueKey ) . ")"
1572                );
1573            }
1574            $orConds[] = $this->makeList( $rowKeyMap, self::LIST_AND );
1575        }
1576
1577        return count( $orConds ) > 1
1578            ? $this->makeList( $orConds, self::LIST_OR )
1579            : $orConds[0];
1580    }
1581
1582    /**
1583     * @param string $delTable
1584     * @param string $joinTable
1585     * @param string $delVar
1586     * @param string $joinVar
1587     * @param array|string $conds
1588     * @return string
1589     */
1590    public function deleteJoinSqlText( $delTable, $joinTable, $delVar, $joinVar, $conds ) {
1591        if ( !$conds ) {
1592            throw new DBLanguageError( __METHOD__ . ' called with empty $conds' );
1593        }
1594
1595        $delTable = $this->tableName( $delTable );
1596        $joinTable = $this->tableName( $joinTable );
1597        $sql = "DELETE FROM $delTable WHERE $delVar IN (SELECT $joinVar FROM $joinTable ";
1598        if ( $conds != '*' ) {
1599            $sql .= 'WHERE ' . $this->makeList( $conds, self::LIST_AND );
1600        }
1601        $sql .= ')';
1602
1603        return $sql;
1604    }
1605
1606    /**
1607     * @param string $table The unqualified name of a table
1608     * @param string|array $conds
1609     * @return Query
1610     */
1611    public function deleteSqlText( $table, $conds ) {
1612        $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1613        if ( !$isCondValid ) {
1614            throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1615        }
1616
1617        $encTable = $this->tableName( $table );
1618        $sql = "DELETE FROM $encTable";
1619
1620        $condsSql = '';
1621        $cleanCondsSql = '';
1622        if ( $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1623            $cleanCondsSql = ' WHERE ' . $this->scrubArray( $conds );
1624            if ( is_array( $conds ) ) {
1625                $conds = $this->makeList( $conds, self::LIST_AND );
1626            }
1627            $condsSql .= ' WHERE ' . $conds;
1628        }
1629        return new Query(
1630            $sql . $condsSql,
1631            self::QUERY_CHANGE_ROWS,
1632            'DELETE',
1633            $table,
1634            $sql . $cleanCondsSql
1635        );
1636    }
1637
1638    /**
1639     * @param mixed $array
1640     * @param int $listType
1641     */
1642    private function scrubArray( $array, int $listType = self::LIST_AND ): string {
1643        if ( is_array( $array ) ) {
1644            $scrubbedArray = [];
1645            foreach ( $array as $key => $value ) {
1646                if ( $value instanceof IExpression ) {
1647                    $scrubbedArray[$key] = $value->toGeneralizedSql();
1648                } else {
1649                    $scrubbedArray[$key] = '?';
1650                }
1651            }
1652            return $this->makeList( $scrubbedArray, $listType );
1653        }
1654        return '?';
1655    }
1656
1657    /**
1658     * @param string $table
1659     * @param array $set
1660     * @param string|IExpression|array $conds
1661     * @param string|array $options
1662     * @return Query
1663     */
1664    public function updateSqlText( $table, $set, $conds, $options ) {
1665        $isCondValid = ( is_string( $conds ) || is_array( $conds ) ) && $conds;
1666        if ( !$isCondValid ) {
1667            throw new DBLanguageError( __METHOD__ . ' called with empty conditions' );
1668        }
1669        $encTable = $this->tableName( $table );
1670        $opts = $this->makeUpdateOptions( $options );
1671        $sql = "UPDATE $opts $encTable";
1672        $condsSql = " SET " . $this->makeList( $set, self::LIST_SET );
1673        $cleanCondsSql = " SET " . $this->scrubArray( $set, self::LIST_SET );
1674
1675        if ( $conds !== self::ALL_ROWS && $conds !== [ self::ALL_ROWS ] ) {
1676            $cleanCondsSql .= ' WHERE ' . $this->scrubArray( $conds );
1677            if ( is_array( $conds ) ) {
1678                $conds = $this->makeList( $conds, self::LIST_AND );
1679            }
1680            $condsSql .= ' WHERE ' . $conds;
1681        }
1682        return new Query(
1683            $sql . $condsSql,
1684            self::QUERY_CHANGE_ROWS,
1685            'UPDATE',
1686            $table,
1687            $sql . $cleanCondsSql
1688        );
1689    }
1690
1691    /**
1692     * Make UPDATE options for the Database::update function
1693     *
1694     * @param array $options The options passed to Database::update
1695     * @return string
1696     */
1697    protected function makeUpdateOptions( $options ) {
1698        $opts = $this->makeUpdateOptionsArray( $options );
1699
1700        return implode( ' ', $opts );
1701    }
1702
1703    /**
1704     * Make UPDATE options array for Database::makeUpdateOptions
1705     *
1706     * @param array $options
1707     * @return array
1708     */
1709    protected function makeUpdateOptionsArray( $options ) {
1710        $options = $this->normalizeOptions( $options );
1711
1712        $opts = [];
1713
1714        if ( in_array( 'IGNORE', $options ) ) {
1715            $opts[] = 'IGNORE';
1716        }
1717
1718        return $opts;
1719    }
1720
1721    /**
1722     * @param string|array $options
1723     * @return array Combination option/value map and boolean option list
1724     * @since 1.35, moved to SQLPlatform in 1.39
1725     */
1726    final public function normalizeOptions( $options ) {
1727        if ( is_array( $options ) ) {
1728            return $options;
1729        } elseif ( is_string( $options ) ) {
1730            return ( $options === '' ) ? [] : [ $options ];
1731        } else {
1732            throw new DBLanguageError( __METHOD__ . ': expected string or array' );
1733        }
1734    }
1735
1736    /**
1737     * @param string $table
1738     * @return string
1739     */
1740    public function dropTableSqlText( $table ) {
1741        // https://mariadb.com/kb/en/drop-table/
1742        // https://dev.mysql.com/doc/refman/8.0/en/drop-table.html
1743        // https://www.postgresql.org/docs/9.2/sql-truncate.html
1744        return "DROP TABLE " . $this->tableName( $table ) . " CASCADE";
1745    }
1746
1747    /**
1748     * @param string $sql SQL query
1749     * @return string|null
1750     * @deprecated Since 1.42
1751     */
1752    public function getQueryVerb( $sql ) {
1753        wfDeprecated( __METHOD__, '1.42' );
1754        return QueryBuilderFromRawSql::buildQuery( $sql, 0 )->getVerb();
1755    }
1756
1757    /**
1758     * Determine whether a SQL statement is sensitive to isolation level.
1759     *
1760     * A SQL statement is considered transactable if its result could vary
1761     * depending on the transaction isolation level. Operational commands
1762     * such as 'SET' and 'SHOW' are not considered to be transactable.
1763     *
1764     * Main purpose: Used by query() to decide whether to begin a transaction
1765     * before the current query (in DBO_TRX mode, on by default).
1766     *
1767     * @return bool
1768     */
1769    public function isTransactableQuery( Query $sql ) {
1770        return !in_array(
1771            $sql->getVerb(),
1772            [
1773                'BEGIN',
1774                'ROLLBACK',
1775                'ROLLBACK TO SAVEPOINT',
1776                'COMMIT',
1777                'SET',
1778                'SHOW',
1779                'CREATE',
1780                'ALTER',
1781                'USE',
1782                'SHOW'
1783            ],
1784            true
1785        );
1786    }
1787
1788    /**
1789     * @param string $column
1790     * @return string
1791     */
1792    public function buildExcludedValue( $column ) {
1793        /* @see Database::upsert() */
1794        // This can be treated like a single value since __VALS is a single row table
1795        return "(SELECT __$column FROM __VALS)";
1796    }
1797
1798    /**
1799     * @param string $identifier
1800     * @return string
1801     */
1802    public function savepointSqlText( $identifier ) {
1803        return 'SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1804    }
1805
1806    /**
1807     * @param string $identifier
1808     * @return string
1809     */
1810    public function releaseSavepointSqlText( $identifier ) {
1811        return 'RELEASE SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1812    }
1813
1814    /**
1815     * @param string $identifier
1816     * @return string
1817     */
1818    public function rollbackToSavepointSqlText( $identifier ) {
1819        return 'ROLLBACK TO SAVEPOINT ' . $this->addIdentifierQuotes( $identifier );
1820    }
1821
1822    /**
1823     * @return string
1824     */
1825    public function rollbackSqlText() {
1826        return 'ROLLBACK';
1827    }
1828
1829    /**
1830     * @param string $table
1831     * @param array $rows
1832     * @param array $options
1833     * @return Query|false
1834     */
1835    public function dispatchingInsertSqlText( $table, $rows, $options ) {
1836        $rows = $this->normalizeRowArray( $rows );
1837        if ( !$rows ) {
1838            return false;
1839        }
1840
1841        $options = $this->normalizeOptions( $options );
1842        if ( $this->isFlagInOptions( 'IGNORE', $options ) ) {
1843            [ $sql, $cleanSql ] = $this->insertNonConflictingSqlText( $table, $rows );
1844        } else {
1845            [ $sql, $cleanSql ] = $this->insertSqlText( $table, $rows );
1846        }
1847        return new Query( $sql, self::QUERY_CHANGE_ROWS, 'INSERT', $table, $cleanSql );
1848    }
1849
1850    /**
1851     * @param array $rowOrRows A single (field => value) map or a list of such maps
1852     * @return array[] List of (field => value) maps
1853     * @since 1.35
1854     */
1855    final protected function normalizeRowArray( array $rowOrRows ) {
1856        if ( !$rowOrRows ) {
1857            $rows = [];
1858        } elseif ( isset( $rowOrRows[0] ) ) {
1859            $rows = $rowOrRows;
1860        } else {
1861            $rows = [ $rowOrRows ];
1862        }
1863
1864        foreach ( $rows as $row ) {
1865            if ( !is_array( $row ) ) {
1866                throw new DBLanguageError( "Got non-array in row array" );
1867            } elseif ( !$row ) {
1868                throw new DBLanguageError( "Got empty array in row array" );
1869            }
1870        }
1871
1872        return $rows;
1873    }
1874
1875    /**
1876     * Validate and normalize parameters to upsert() or replace()
1877     *
1878     * @param string|string[]|string[][] $uniqueKeys Unique indexes (only one is allowed)
1879     * @param array[] &$rows The row array, which will be replaced with a normalized version.
1880     * @return string[] List of columns that defines a single unique index
1881     * @since 1.35
1882     */
1883    final public function normalizeUpsertParams( $uniqueKeys, &$rows ) {
1884        $rows = $this->normalizeRowArray( $rows );
1885        if ( !$uniqueKeys ) {
1886            throw new DBLanguageError( 'No unique key specified for upsert/replace' );
1887        }
1888        $uniqueKey = $this->normalizeUpsertKeys( $uniqueKeys );
1889        $this->assertValidUpsertRowArray( $rows, $uniqueKey );
1890
1891        return $uniqueKey;
1892    }
1893
1894    /**
1895     * @param array|string $conds
1896     * @param string $fname
1897     * @return array
1898     * @since 1.31
1899     */
1900    final public function normalizeConditions( $conds, $fname ) {
1901        if ( $conds === null || $conds === false ) {
1902            $this->logger->warning(
1903                __METHOD__
1904                . ' called from '
1905                . $fname
1906                . ' with incorrect parameters: $conds must be a string or an array',
1907                [ 'db_log_category' => 'sql' ]
1908            );
1909            return [];
1910        } elseif ( $conds === '' ) {
1911            return [];
1912        }
1913
1914        return is_array( $conds ) ? $conds : [ $conds ];
1915    }
1916
1917    /**
1918     * @param string|string[]|string[][] $uniqueKeys Unique indexes (only one is allowed)
1919     * @return string[] List of columns that defines a single unique index
1920     * @since 1.35
1921     */
1922    private function normalizeUpsertKeys( $uniqueKeys ) {
1923        if ( is_string( $uniqueKeys ) ) {
1924            return [ $uniqueKeys ];
1925        } elseif ( !is_array( $uniqueKeys ) ) {
1926            throw new DBLanguageError( 'Invalid unique key array' );
1927        } else {
1928            if ( count( $uniqueKeys ) !== 1 || !isset( $uniqueKeys[0] ) ) {
1929                throw new DBLanguageError(
1930                    "The unique key array should contain a single unique index" );
1931            }
1932
1933            $uniqueKey = $uniqueKeys[0];
1934            if ( is_string( $uniqueKey ) ) {
1935                // Passing a list of strings for single-column unique keys is too
1936                // easily confused with passing the columns of composite unique key
1937                $this->logger->warning( __METHOD__ .
1938                    " called with deprecated parameter style: " .
1939                    "the unique key array should be a string or array of string arrays",
1940                    [
1941                        'exception' => new RuntimeException(),
1942                        'db_log_category' => 'sql',
1943                    ] );
1944                return $uniqueKeys;
1945            } elseif ( is_array( $uniqueKey ) ) {
1946                return $uniqueKey;
1947            } else {
1948                throw new DBLanguageError( 'Invalid unique key array entry' );
1949            }
1950        }
1951    }
1952
1953    /**
1954     * @param array<int,array> $rows Normalized list of rows to insert
1955     * @param string[] $uniqueKey Columns of the unique key to UPSERT upon
1956     * @since 1.37
1957     */
1958    final protected function assertValidUpsertRowArray( array $rows, array $uniqueKey ) {
1959        foreach ( $rows as $row ) {
1960            foreach ( $uniqueKey as $column ) {
1961                if ( !isset( $row[$column] ) ) {
1962                    throw new DBLanguageError(
1963                        "NULL/absent values for unique key (" . implode( ',', $uniqueKey ) . ")"
1964                    );
1965                }
1966            }
1967        }
1968    }
1969
1970    /**
1971     * @param array $set Combined column/literal assignment map and SQL assignment list
1972     * @param string[] $uniqueKey Columns of the unique key to UPSERT upon
1973     * @param array<int,array> $rows List of rows to upsert
1974     * @since 1.37
1975     */
1976    final public function assertValidUpsertSetArray(
1977        array $set,
1978        array $uniqueKey,
1979        array $rows
1980    ) {
1981        if ( !$set ) {
1982            throw new DBLanguageError( "Update assignment list can't be empty for upsert" );
1983        }
1984
1985        // Sloppy callers might construct the SET array using the ROW array, leaving redundant
1986        // column definitions for unique key columns. Detect this for backwards compatibility.
1987        $soleRow = ( count( $rows ) == 1 ) ? reset( $rows ) : null;
1988        // Disallow value changes for any columns in the unique key. This avoids additional
1989        // insertion order dependencies that are unwieldy and difficult to implement efficiently
1990        // in PostgreSQL.
1991        foreach ( $set as $k => $v ) {
1992            if ( is_string( $k ) ) {
1993                // Key is a column name and value is a literal (e.g. string, int, null, ...)
1994                if ( in_array( $k, $uniqueKey, true ) ) {
1995                    if ( $soleRow && array_key_exists( $k, $soleRow ) && $soleRow[$k] === $v ) {
1996                        $this->logger->warning(
1997                            __METHOD__ . " called with redundant assignment to column '$k'",
1998                            [
1999                                'exception' => new RuntimeException(),
2000                                'db_log_category' => 'sql',
2001                            ]
2002                        );
2003                    } else {
2004                        throw new DBLanguageError(
2005                            "Cannot reassign column '$k' since it belongs to the provided unique key"
2006                        );
2007                    }
2008                }
2009            } elseif ( preg_match( '/^([a-zA-Z0-9_]+)\s*=/', $v, $m ) ) {
2010                // Value is of the form "<unquoted alphanumeric column> = <SQL expression>"
2011                if ( in_array( $m[1], $uniqueKey, true ) ) {
2012                    throw new DBLanguageError(
2013                        "Cannot reassign column '{$m[1]}' since it belongs to the provided unique key"
2014                    );
2015                }
2016            }
2017        }
2018    }
2019
2020    /**
2021     * @param array|string $var Field parameter in the style of select()
2022     * @return string|null Column name or null; ignores aliases
2023     */
2024    final public function extractSingleFieldFromList( $var ) {
2025        if ( is_array( $var ) ) {
2026            if ( !$var ) {
2027                $column = null;
2028            } elseif ( count( $var ) == 1 ) {
2029                $column = $var[0] ?? reset( $var );
2030            } else {
2031                throw new DBLanguageError( __METHOD__ . ': got multiple columns' );
2032            }
2033        } else {
2034            $column = $var;
2035        }
2036
2037        return $column;
2038    }
2039
2040    /** @inheritDoc */
2041    public function setSchemaVars( $vars ) {
2042        $this->schemaVars = is_array( $vars ) ? $vars : null;
2043    }
2044
2045    /**
2046     * Get schema variables. If none have been set via setSchemaVars(), then
2047     * use some defaults from the current object.
2048     *
2049     * @return array
2050     */
2051    protected function getSchemaVars() {
2052        return $this->schemaVars ?? $this->getDefaultSchemaVars();
2053    }
2054
2055    /**
2056     * Get schema variables to use if none have been set via setSchemaVars().
2057     *
2058     * Override this in derived classes to provide variables for SQL schema
2059     * and patch files.
2060     *
2061     * @return array
2062     */
2063    protected function getDefaultSchemaVars() {
2064        return [];
2065    }
2066
2067    /**
2068     * Database-independent variable replacement. Replaces a set of variables
2069     * in an SQL statement with their contents as given by $this->getSchemaVars().
2070     *
2071     * Supports '{$var}' `{$var}` and / *$var* / (without the spaces) style variables.
2072     *
2073     * - '{$var}' should be used for text and is passed through the database's
2074     *   addQuotes method.
2075     * - `{$var}` should be used for identifiers (e.g. table and database names).
2076     *   It is passed through the database's addIdentifierQuotes method which
2077     *   can be overridden if the database uses something other than backticks.
2078     * - / *_* / or / *$wgDBprefix* / passes the name that follows through the
2079     *   database's tableName method.
2080     * - / *i* / passes the name that follows through the database's indexName method.
2081     * - In all other cases, / *$var* / is left unencoded. Except for table options,
2082     *   its use should be avoided. In 1.24 and older, string encoding was applied.
2083     *
2084     * @param string $ins SQL statement to replace variables in
2085     * @return string The new SQL statement with variables replaced
2086     */
2087    public function replaceVars( $ins ) {
2088        $vars = $this->getSchemaVars();
2089        return preg_replace_callback(
2090            '!
2091                /\* (\$wgDBprefix|[_i]) \*/ (\w*) | # 1-2. tableName, indexName
2092                \'\{\$ (\w+) }\'                  | # 3. addQuotes
2093                `\{\$ (\w+) }`                    | # 4. addIdentifierQuotes
2094                /\*\$ (\w+) \*/                     # 5. leave unencoded
2095            !x',
2096            function ( $m ) use ( $vars ) {
2097                // Note: Because of <https://bugs.php.net/bug.php?id=51881>,
2098                // check for both nonexistent keys *and* the empty string.
2099                if ( isset( $m[1] ) && $m[1] !== '' ) {
2100                    if ( $m[1] === 'i' ) {
2101                        return $m[2];
2102                    } else {
2103                        return $this->tableName( $m[2] );
2104                    }
2105                } elseif ( isset( $m[3] ) && $m[3] !== '' && array_key_exists( $m[3], $vars ) ) {
2106                    return $this->quoter->addQuotes( $vars[$m[3]] );
2107                } elseif ( isset( $m[4] ) && $m[4] !== '' && array_key_exists( $m[4], $vars ) ) {
2108                    return $this->addIdentifierQuotes( $vars[$m[4]] );
2109                } elseif ( isset( $m[5] ) && $m[5] !== '' && array_key_exists( $m[5], $vars ) ) {
2110                    return $vars[$m[5]];
2111                } else {
2112                    return $m[0];
2113                }
2114            },
2115            $ins
2116        );
2117    }
2118
2119    /**
2120     * @param string $lockName
2121     * @param float $timeout
2122     * @return string
2123     */
2124    public function lockSQLText( $lockName, $timeout ) {
2125        throw new RuntimeException( 'locking must be implemented in subclasses' );
2126    }
2127
2128    /**
2129     * @param string $lockName
2130     * @return string
2131     */
2132    public function unlockSQLText( $lockName ) {
2133        throw new RuntimeException( 'locking must be implemented in subclasses' );
2134    }
2135}