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