Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.44% covered (warning)
76.44%
652 / 853
40.59% covered (danger)
40.59%
41 / 101
CRAP
0.00% covered (danger)
0.00%
0 / 1
SQLPlatform
76.44% covered (warning)
76.44%
652 / 853
40.59% covered (danger)
40.59%
41 / 101
2436.43
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
85.53% covered (warning)
85.53%
65 / 76
0.00% covered (danger)
0.00%
0 / 1
49.87
 makeWhereFrom2d
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 factorConds
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 factorCondsWithCommonFields
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
10
 buildConcat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 limitResult
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 escapeLikeInternal
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildLike
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 anyChar
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 anyString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unionSupportsOrderAndLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 unionQueries
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 conditional
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 strreplace
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 timestamp
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 timestampOrNull
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getInfinity
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 encodeExpiry
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 decodeExpiry
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 buildSubstring
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 assertBuildSubstringParams
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 buildStringCast
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildIntegerCast
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 implicitOrderby
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 indexName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTableAliases
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIndexAliases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTableAliases
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPrefix
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setCurrentDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentDomain
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 selectSQLText
53.75% covered (warning)
53.75%
43 / 80
0.00% covered (danger)
0.00%
0 / 1
126.07
 selectOptionsIncludeLocking
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 selectFieldsOrOptionsAggregate
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
9.37
 fieldNamesWithAlias
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fieldNameWithAlias
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 tableNamesWithIndexClauseOrJOIN
87.27% covered (warning)
87.27%
48 / 55
0.00% covered (danger)
0.00%
0 / 1
14.40
 normalizeJoinType
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
10.14
 tableNameWithAlias
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%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 qualifiedTableComponents
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 extractTableNameComponents
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 getDatabaseAndTableIdentifier
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 relationSchemaQualifier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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\RawSQLValue;
37use Wikimedia\Rdbms\Subquery;
38use Wikimedia\Timestamp\ConvertibleTimestamp;
39
40/**
41 * Sql abstraction object.
42 * This class nor any of its subclasses shouldn't create a db connection.
43 * It also should not become stateful. The constructor should only rely on addQuotes() method in Database.
44 * Later that should be replaced with an implementation that doesn't use db connections.
45 * @since 1.39
46 */
47class SQLPlatform implements ISQLPlatform {
48    /** @var array[] Current map of (table => (dbname, schema, prefix) map) */
49    protected $tableAliases = [];
50    /** @var string[] Current map of (index alias => index) */
51    protected $indexAliases = [];
52    /** @var DatabaseDomain|null */
53    protected $currentDomain;
54    /** @var array|null Current variables use for schema element placeholders */
55    protected $schemaVars;
56    /** @var DbQuoter */
57    protected $quoter;
58    /** @var LoggerInterface */
59    protected $logger;
60    /** @var callable Error logging callback */
61    protected $errorLogger;
62
63    public function __construct(
64        DbQuoter $quoter,
65        LoggerInterface $logger = null,
66        DatabaseDomain $currentDomain = null,
67        $errorLogger = null
68
69    ) {
70        $this->quoter = $quoter;
71        $this->logger = $logger ?? new NullLogger();
72        $this->currentDomain = $currentDomain ?: DatabaseDomain::newUnspecified();
73        $this->errorLogger = $errorLogger ?? static function ( Throwable $e ) {
74            trigger_error( get_class( $e ) . ': ' . $e->getMessage(), E_USER_WARNING );
75        };
76    }
77
78    public function bitNot( $field ) {
79        return "(~$field)";
80    }
81
82    public function bitAnd( $fieldLeft, $fieldRight ) {
83        return "($fieldLeft & $fieldRight)";
84    }
85
86    public function bitOr( $fieldLeft, $fieldRight ) {
87        return "($fieldLeft | $fieldRight)";
88    }
89
90    public function addIdentifierQuotes( $s ) {
91        if ( strcspn( $s, "\0\"`'." ) !== strlen( $s ) ) {
92            throw new DBLanguageError(
93                "Identifier must not contain quote, dot or null characters"
94            );
95        }
96        $quoteChar = $this->getIdentifierQuoteChar();
97        return $quoteChar . $s . $quoteChar;
98    }
99
100    /**
101     * Get the character used for identifier quoting
102     * @return string
103     */
104    protected function getIdentifierQuoteChar() {
105        return '"';
106    }
107
108    /**
109     * @inheritDoc
110     */
111    public function buildGreatest( $fields, $values ) {
112        return $this->buildSuperlative( 'GREATEST', $fields, $values );
113    }
114
115    /**
116     * @inheritDoc
117     */
118    public function buildLeast( $fields, $values ) {
119        return $this->buildSuperlative( 'LEAST', $fields, $values );
120    }
121
122    /**
123     * Build a superlative function statement comparing columns/values
124     *
125     * Integer and float values in $values will not be quoted
126     *
127     * If $fields is an array, then each value with a string key is treated as an expression
128     * (which must be manually quoted); such string keys do not appear in the SQL and are only
129     * descriptive aliases.
130     *
131     * @param string $sqlfunc Name of a SQL function
132     * @param string|string[] $fields Name(s) of column(s) with values to compare
133     * @param string|int|float|string[]|int[]|float[] $values Values to compare
134     * @return string
135     */
136    protected function buildSuperlative( $sqlfunc, $fields, $values ) {
137        $fields = is_array( $fields ) ? $fields : [ $fields ];
138        $values = is_array( $values ) ? $values : [ $values ];
139
140        $encValues = [];
141        foreach ( $fields as $alias => $field ) {
142            if ( is_int( $alias ) ) {
143                $encValues[] = $this->addIdentifierQuotes( $field );
144            } else {
145                $encValues[] = $field; // expression
146            }
147        }
148        foreach ( $values as $value ) {
149            if ( is_int( $value ) || is_float( $value ) ) {
150                $encValues[] = $value;
151            } elseif ( is_string( $value ) ) {
152                $encValues[] = $this->quoter->addQuotes( $value );
153            } elseif ( $value === null ) {
154                throw new DBLanguageError( 'Null value in superlative' );
155            } else {
156                throw new DBLanguageError( 'Unexpected value type in superlative' );
157            }
158        }
159
160        return $sqlfunc . '(' . implode( ',', $encValues ) . ')';
161    }
162
163    public function buildComparison( string $op, array $conds ): string {
164        if ( !in_array( $op, [ '>', '>=', '<', '<=' ] ) ) {
165            throw new InvalidArgumentException( "Comparison operator must be one of '>', '>=', '<', '<='" );
166        }
167        if ( count( $conds ) === 0 ) {
168            throw new InvalidArgumentException( "Empty input" );
169        }
170
171        // Construct a condition string by starting with the least significant part of the index, and
172        // adding more significant parts progressively to the left of the string.
173        //
174        // For example, given $conds = [ 'a' => 4, 'b' => 7, 'c' => 1 ], this will generate a condition
175        // like this:
176        //
177        //   WHERE  a > 4
178        //      OR (a = 4 AND (b > 7
179        //                 OR (b = 7 AND (c > 1))))
180        //
181        // …which is equivalent to the following, which might be easier to understand:
182        //
183        //   WHERE a > 4
184        //      OR a = 4 AND b > 7
185        //      OR a = 4 AND b = 7 AND c > 1
186        //
187        // …and also equivalent to the following, using tuple comparison syntax, which is most intuitive
188        // but apparently performs worse:
189        //
190        //   WHERE (a, b, c) > (4, 7, 1)
191
192        $sql = '';
193        foreach ( array_reverse( $conds ) as $field => $value ) {
194            if ( is_int( $field ) ) {
195                throw new InvalidArgumentException(
196                    'Non-associative array passed to buildComparison() (typo?)'
197                );
198            }
199            $encValue = $this->quoter->addQuotes( $value );
200            if ( $sql === '' ) {
201                $sql = "$field $op $encValue";
202                // Change '>=' to '>' etc. for remaining fields, as the equality is handled separately
203                $op = rtrim( $op, '=' );
204            } else {
205                $sql = "$field $op $encValue OR ($field = $encValue AND ($sql))";
206            }
207        }
208        return $sql;
209    }
210
211    public function makeList( array $a, $mode = self::LIST_COMMA ) {
212        $first = true;
213        $list = '';
214        $keyWarning = null;
215
216        foreach ( $a as $field => $value ) {
217            if ( $first ) {
218                $first = false;
219            } else {
220                if ( $mode == self::LIST_AND ) {
221                    $list .= ' AND ';
222                } elseif ( $mode == self::LIST_OR ) {
223                    $list .= ' OR ';
224                } else {
225                    $list .= ',';
226                }
227            }
228
229            if ( ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_numeric( $field ) ) {
230                if ( $value instanceof IExpression ) {
231                    $list .= "(" . $value->toSql( $this->quoter ) . ")";
232                } elseif ( is_array( $value ) ) {
233                    throw new InvalidArgumentException( __METHOD__ . ": unexpected array value without key" );
234                } elseif ( $value instanceof RawSQLValue ) {
235                    throw new InvalidArgumentException( __METHOD__ . ": unexpected raw value without key" );
236                } else {
237                    $list .= "($value)";
238                }
239            } elseif ( $value instanceof IExpression ) {
240                if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
241                    throw new InvalidArgumentException( __METHOD__ . ": unexpected key $field for IExpression value" );
242                } else {
243                    throw new InvalidArgumentException( __METHOD__ . ": unexpected IExpression outside WHERE clause" );
244                }
245            } elseif ( $mode == self::LIST_SET && is_numeric( $field ) ) {
246                $list .= "$value";
247            } elseif (
248                ( $mode == self::LIST_AND || $mode == self::LIST_OR ) && is_array( $value )
249            ) {
250                // Remove null from array to be handled separately if found
251                $includeNull = false;
252                foreach ( array_keys( $value, null, true ) as $nullKey ) {
253                    $includeNull = true;
254                    unset( $value[$nullKey] );
255                }
256                if ( count( $value ) == 0 && !$includeNull ) {
257                    throw new InvalidArgumentException(
258                        __METHOD__ . ": empty input for field $field" );
259                } elseif ( count( $value ) == 0 ) {
260                    // only check if $field is null
261                    $list .= "$field IS NULL";
262                } else {
263                    // IN clause contains at least one valid element
264                    if ( $includeNull ) {
265                        // Group subconditions to ensure correct precedence
266                        $list .= '(';
267                    }
268                    if ( count( $value ) == 1 ) {
269                        // Special-case single values, as IN isn't terribly efficient
270                        // (but call makeList() so that warnings are emitted if needed)
271                        $list .= $field . " = " . $this->makeList( $value );
272                    } else {
273                        $list .= $field . " IN (" . $this->makeList( $value ) . ") ";
274                    }
275                    // if null present in array, append IS NULL
276                    if ( $includeNull ) {
277                        $list .= " OR $field IS NULL)";
278                    }
279                }
280            } elseif ( is_array( $value ) ) {
281                throw new InvalidArgumentException( __METHOD__ . ": unexpected nested array" );
282            } elseif ( $value === null ) {
283                if ( $mode == self::LIST_AND || $mode == self::LIST_OR ) {
284                    $list .= "$field IS ";
285                } elseif ( $mode == self::LIST_SET ) {
286                    $list .= "$field = ";
287                } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
288                    $keyWarning ??= [
289                        __METHOD__ . ": array key {key} in list of values ignored",
290                        [ 'key' => $field, 'exception' => new RuntimeException() ]
291                    ];
292                } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
293                    $keyWarning ??= [
294                        __METHOD__ . ": array key {key} in list of fields ignored",
295                        [ 'key' => $field, 'exception' => new RuntimeException() ]
296                    ];
297                }
298                $list .= 'NULL';
299            } else {
300                if (
301                    $mode == self::LIST_AND || $mode == self::LIST_OR || $mode == self::LIST_SET
302                ) {
303                    $list .= "$field = ";
304                } elseif ( $mode === self::LIST_COMMA && !is_numeric( $field ) ) {
305                    $keyWarning ??= [
306                        __METHOD__ . ": array key {key} in list of values ignored",
307                        [ 'key' => $field, 'exception' => new RuntimeException() ]
308                    ];
309                } elseif ( $mode === self::LIST_NAMES && !is_numeric( $field ) ) {
310                    $keyWarning ??= [
311                        __METHOD__ . ": array key {key} in list of fields ignored",
312                        [ 'key' => $field, 'exception' => new RuntimeException() ]
313                    ];
314                }
315                $list .= $mode == self::LIST_NAMES ? $value : $this->quoter->addQuotes( $value );
316            }
317        }
318
319        if ( $keyWarning ) {
320            // Only log one warning about this per function call, to reduce log spam when a dynamically
321            // generated associative array is passed
322            $this->logger->warning( ...$keyWarning );
323        }
324
325        return $list;
326    }
327
328    public function makeWhereFrom2d( $data, $baseKey, $subKey ) {
329        $conds = [];
330        foreach ( $data as $base => $sub ) {
331            if ( count( $sub ) ) {
332                $conds[] = $this->makeList(
333                    [ $baseKey => $base, $subKey => array_map( 'strval', array_keys( $sub ) ) ],
334                    self::LIST_AND
335                );
336            }
337        }
338
339        if ( !$conds ) {
340            throw new InvalidArgumentException( "Data for $baseKey and $subKey must be non-empty" );
341        }
342
343        return $this->makeList( $conds, self::LIST_OR );
344    }
345
346    public function factorConds( $condsArray ) {
347        if ( count( $condsArray ) === 0 ) {
348            throw new InvalidArgumentException(
349                __METHOD__ . ": empty condition array" );
350        }
351        $condsByFieldSet = [];
352        foreach ( $condsArray as $conds ) {
353            if ( !count( $conds ) ) {
354                throw new InvalidArgumentException(
355                    __METHOD__ . ": empty condition subarray" );
356            }
357            $fieldKey = implode( ',', array_keys( $conds ) );
358            $condsByFieldSet[$fieldKey][] = $conds;
359        }
360        $result = '';
361        foreach ( $condsByFieldSet as $conds ) {
362            if ( $result !== '' ) {
363                $result .= ' OR ';
364            }
365            $result .= $this->factorCondsWithCommonFields( $conds );
366        }
367        return $result;
368    }
369
370    /**
371     * Same as factorConds() but with each element in the array having the same
372     * set of array keys. Validation is done by the caller.
373     *
374     * @param array $condsArray
375     * @return string
376     */
377    private function factorCondsWithCommonFields( $condsArray ) {
378        $first = $condsArray[array_key_first( $condsArray )];
379        if ( count( $first ) === 1 ) {
380            // IN clause
381            $field = array_key_first( $first );
382            $values = [];
383            foreach ( $condsArray as $conds ) {
384                $values[] = $conds[$field];
385            }
386            return $this->makeList( [ $field => $values ], self::LIST_AND );
387        }
388
389        $field1 = array_key_first( $first );
390        $nullExpressions = [];
391        $expressionsByField1 = [];
392        foreach ( $condsArray as $conds ) {
393            $value1 = $conds[$field1];
394            unset( $conds[$field1] );
395            if ( $value1 === null ) {
396                $nullExpressions[] = $conds;
397            } else {
398                $expressionsByField1[$value1][] = $conds;
399            }
400
401        }
402        $wrap = false;
403        $result = '';
404        foreach ( $expressionsByField1 as $value1 => $expressions ) {
405            if ( $result !== '' ) {
406                $result .= ' OR ';
407                $wrap = true;
408            }
409            $factored = $this->factorCondsWithCommonFields( $expressions );
410            $result .= "($field1 = " . $this->quoter->addQuotes( $value1 ) .
411                " AND $factored)";
412        }
413        if ( count( $nullExpressions ) ) {
414            $factored = $this->factorCondsWithCommonFields( $nullExpressions );
415            if ( $result !== '' ) {
416                $result .= ' OR ';
417                $wrap = true;
418            }
419            $result .= "($field1 IS NULL AND $factored)";
420        }
421        if ( $wrap ) {
422            return "($result)";
423        } else {
424            return $result;
425        }
426    }
427
428    /**
429     * @inheritDoc
430     * @stable to override
431     */
432    public function buildConcat( $stringList ) {
433        return 'CONCAT(' . implode( ',', $stringList ) . ')';
434    }
435
436    public function limitResult( $sql, $limit, $offset = false ) {
437        if ( !is_numeric( $limit ) ) {
438            throw new DBLanguageError(
439                "Invalid non-numeric limit passed to " . __METHOD__
440            );
441        }
442        // This version works in MySQL and SQLite. It will very likely need to be
443        // overridden for most other RDBMS subclasses.
444        return "$sql LIMIT "
445            . ( ( is_numeric( $offset ) && $offset != 0 ) ? "{$offset}," : "" )
446            . "{$limit} ";
447    }
448
449    /**
450     * @stable to override
451     * @param string $s
452     * @param string $escapeChar
453     * @return string
454     */
455    public function escapeLikeInternal( $s, $escapeChar = '`' ) {
456        return str_replace(
457            [ $escapeChar, '%', '_' ],
458            [ "{$escapeChar}{$escapeChar}", "{$escapeChar}%", "{$escapeChar}_" ],
459            $s
460        );
461    }
462
463    public function buildLike( $param, ...$params ) {
464        if ( is_array( $param ) ) {
465            $params = $param;
466        } else {
467            $params = func_get_args();
468        }
469        // @phan-suppress-next-line PhanParamTooFewUnpack
470        $likeValue = new LikeValue( ...$params );
471
472        return ' LIKE ' . $likeValue->toSql( $this->quoter );
473    }
474
475    public function anyChar() {
476        return new LikeMatch( '_' );
477    }
478
479    public function anyString() {
480        return new LikeMatch( '%' );
481    }
482
483    /**
484     * @inheritDoc
485     * @stable to override
486     */
487    public function unionSupportsOrderAndLimit() {
488        return true; // True for almost every DB supported
489    }
490
491    public function unionQueries( $sqls, $all, $options = [] ) {
492        $glue = $all ? ') UNION ALL (' : ') UNION (';
493
494        $sql = '(' . implode( $glue, $sqls ) . ')';
495        if ( !$this->unionSupportsOrderAndLimit() ) {
496            return $sql;
497        }
498        $sql .= $this->makeOrderBy( $options );
499        $limit = $options['LIMIT'] ?? null;
500        $offset = $options['OFFSET'] ?? false;
501        if ( $limit !== null ) {
502            $sql = $this->limitResult( $sql, $limit, $offset );
503        }
504
505        return $sql;
506    }
507
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
516        return "(CASE WHEN $cond THEN $caseTrueExpression ELSE $caseFalseExpression END)";
517    }
518
519    public function strreplace( $orig, $old, $new ) {
520        return "REPLACE({$orig}{$old}{$new})";
521    }
522
523    public function timestamp( $ts = 0 ) {
524        $t = new ConvertibleTimestamp( $ts );
525        // Let errors bubble up to avoid putting garbage in the DB
526        return $t->getTimestamp( TS_MW );
527    }
528
529    public function timestampOrNull( $ts = null ) {
530        if ( $ts === null ) {
531            return null;
532        } else {
533            return $this->timestamp( $ts );
534        }
535    }
536
537    public function getInfinity() {
538        return 'infinity';
539    }
540
541    public function encodeExpiry( $expiry ) {
542        return ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() )
543            ? $this->getInfinity()
544            : $this->timestamp( $expiry );
545    }
546
547    public function decodeExpiry( $expiry, $format = TS_MW ) {
548        if ( $expiry == '' || $expiry == 'infinity' || $expiry == $this->getInfinity() ) {
549            return 'infinity';
550        }
551
552        return ConvertibleTimestamp::convert( $format, $expiry );
553    }
554
555    /**
556     * @inheritDoc
557     * @stable to override
558     */
559    public function buildSubstring( $input, $startPosition, $length = null ) {
560        $this->assertBuildSubstringParams( $startPosition, $length );
561        $functionBody = "$input FROM $startPosition";
562        if ( $length !== null ) {
563            $functionBody .= " FOR $length";
564        }
565        return 'SUBSTRING(' . $functionBody . ')';
566    }
567
568    /**
569     * Check type and bounds for parameters to self::buildSubstring()
570     *
571     * All supported databases have substring functions that behave the same for
572     * positive $startPosition and non-negative $length, but behaviors differ when
573     * given negative $startPosition or negative $length. The simplest
574     * solution to that is to just forbid those values.
575     *
576     * @param int $startPosition
577     * @param int|null $length
578     * @since 1.31 in Database, moved to SQLPlatform in 1.39
579     */
580    protected function assertBuildSubstringParams( $startPosition, $length ) {
581        if ( $startPosition === 0 ) {
582            // The DBMSs we support use 1-based indexing here.
583            throw new InvalidArgumentException( 'Use 1 as $startPosition for the beginning of the string' );
584        }
585        if ( !is_int( $startPosition ) || $startPosition < 0 ) {
586            throw new InvalidArgumentException(
587                '$startPosition must be a positive integer'
588            );
589        }
590        if ( !( ( is_int( $length ) && $length >= 0 ) || $length === null ) ) {
591            throw new InvalidArgumentException(
592                '$length must be null or an integer greater than or equal to 0'
593            );
594        }
595    }
596
597    public function buildStringCast( $field ) {
598        // In theory this should work for any standards-compliant
599        // SQL implementation, although it may not be the best way to do it.
600        return "CAST( $field AS CHARACTER )";
601    }
602
603    public function buildIntegerCast( $field ) {
604        return 'CAST( ' . $field . ' AS INTEGER )';
605    }
606
607    public function implicitOrderby() {
608        return true;
609    }
610
611    /**
612     * Allows for index remapping in queries where this is not consistent across DBMS
613     *
614     * TODO: Make it protected once all the code is moved over.
615     *
616     * @param string $index
617     * @return string
618     */
619    public function indexName( $index ) {
620        return $this->indexAliases[$index] ?? $index;
621    }
622
623    public function setTableAliases( array $aliases ) {
624        $this->tableAliases = $aliases;
625    }
626
627    public function setIndexAliases( array $aliases ) {
628        $this->indexAliases = $aliases;
629    }
630
631    /**
632     * @return array[]
633     */
634    public function getTableAliases() {
635        return $this->tableAliases;
636    }
637
638    public function setPrefix( $prefix ) {
639        $this->currentDomain = new DatabaseDomain(
640            $this->currentDomain->getDatabase(),
641            $this->currentDomain->getSchema(),
642            $prefix
643        );
644    }
645
646    public function setCurrentDomain( DatabaseDomain $currentDomain ) {
647        $this->currentDomain = $currentDomain;
648    }
649
650    /**
651     * @internal For use by tests
652     * @return DatabaseDomain
653     */
654    public function getCurrentDomain() {
655        return $this->currentDomain;
656    }
657
658    public function selectSQLText(
659        $tables, $vars, $conds = '', $fname = __METHOD__, $options = [], $join_conds = []
660    ) {
661        if ( !is_array( $tables ) ) {