Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 976
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
CargoSQLQuery
0.00% covered (danger)
0.00%
0 / 976
0.00% covered (danger)
0.00%
0 / 31
122150
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 newFromValues
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 validateValues
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
210
 getAliasForFieldString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setAliasedFieldNames
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
156
 setAliasedTableNames
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 setCargoJoinConds
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
552
 mwUsesOldDBAliasing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMWJoinConds
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
156
 setOrderBy
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
182
 setGroupBy
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 getAndValidateSQLFunctions
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getDescriptionAndTableNameForField
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
1892
 setDescriptionsAndTableNamesForFields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 addToCargoJoinConds
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
132
 addFieldTableToTableNames
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 fieldTableIsIncluded
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 substVirtualFieldName
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 handleVirtualFields
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 1
2352
 handleVirtualCoordinateFields
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
552
 handleHierarchyFields
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
380
 distanceToDegrees
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 handleDateFields
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
182
 handleSearchTextFields
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
110
 addTablePrefixesToAll
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 run
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
342
 addTablePrefixes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 addQuotes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 determineDateFields
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
756
 getMainStartAndEndDateFields
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 isAggregating
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * CargoSQLQuery - a wrapper class around SQL queries, that also handles
4 * the special Cargo keywords like "HOLDS" and "NEAR".
5 *
6 * @author Yaron Koren
7 * @ingroup Cargo
8 */
9
10class CargoSQLQuery {
11
12    private $mCargoDB;
13    public $mTablesStr;
14    public $mAliasedTableNames;
15    public $mFieldsStr;
16    public $mOrigWhereStr;
17    public $mWhereStr;
18    public $mJoinOnStr;
19    public $mCargoJoinConds;
20    public $mJoinConds;
21    public $mAliasedFieldNames;
22    public $mOrigAliasedFieldNames;
23    public $mFieldStringAliases;
24    public $mTableSchemas;
25    public $mFieldDescriptions;
26    public $mFieldTables;
27    public $mOrigGroupByStr;
28    public $mGroupByStr;
29    public $mOrigHavingStr;
30    public $mHavingStr;
31    public $mOrigOrderBy;
32    public $mOrderBy;
33    public $mQueryLimit;
34    public $mOffset;
35    public $mSearchTerms = [];
36    public $mDateFieldPairs = [];
37
38    public function __construct() {
39        $this->mCargoDB = CargoUtils::getDB();
40    }
41
42    /**
43     * This is newFromValues() instead of __construct() so that an
44     * object can be created without any values.
45     */
46    public static function newFromValues( $tablesStr, $fieldsStr, $whereStr, $joinOnStr, $groupByStr,
47        $havingStr, $orderByStr, $limitStr, $offsetStr, $allowFieldEscaping = false ) {
48        global $wgCargoDefaultQueryLimit, $wgCargoMaxQueryLimit;
49
50        // "table(s)" is the only mandatory value.
51        if ( $tablesStr == '' ) {
52            throw new MWException( "At least one table must be specified." );
53        }
54
55        // Needed to avoid various warnings.
56        if ( $whereStr === null ) {
57            $whereStr = '';
58        }
59
60        self::validateValues( $tablesStr, $fieldsStr, $whereStr, $joinOnStr, $groupByStr,
61            $havingStr, $orderByStr, $limitStr, $offsetStr, $allowFieldEscaping );
62
63        $sqlQuery = new CargoSQLQuery();
64        $sqlQuery->mCargoDB = CargoUtils::getDB();
65        $sqlQuery->mTablesStr = $tablesStr;
66        $sqlQuery->setAliasedTableNames();
67        $sqlQuery->mFieldsStr = $fieldsStr;
68        // This _decode() call is necessary because the "where="
69        // clause can (and often does) include a call to {{PAGENAME}},
70        // which HTML-encodes certain characters, notably single quotes.
71        $sqlQuery->mOrigWhereStr = htmlspecialchars_decode( $whereStr, ENT_QUOTES );
72        $sqlQuery->mWhereStr = $sqlQuery->mOrigWhereStr;
73        $sqlQuery->mJoinOnStr = $joinOnStr;
74        $sqlQuery->setCargoJoinConds( $joinOnStr );
75        $sqlQuery->setAliasedFieldNames();
76        $sqlQuery->mTableSchemas = CargoUtils::getTableSchemas( $sqlQuery->mAliasedTableNames );
77        $sqlQuery->mOrigOrderBy = $orderByStr;
78        $sqlQuery->setOrderBy( $orderByStr );
79        $sqlQuery->setGroupBy( $groupByStr );
80        $sqlQuery->mOrigHavingStr = $havingStr;
81        $sqlQuery->mHavingStr = $sqlQuery->mOrigHavingStr;
82        $sqlQuery->setDescriptionsAndTableNamesForFields();
83        $sqlQuery->handleHierarchyFields();
84        $sqlQuery->handleVirtualFields();
85        $sqlQuery->handleVirtualCoordinateFields();
86        $sqlQuery->handleDateFields();
87        $sqlQuery->handleSearchTextFields();
88        $sqlQuery->setMWJoinConds();
89        $sqlQuery->mQueryLimit = $wgCargoDefaultQueryLimit;
90        if ( $limitStr != '' ) {
91            $sqlQuery->mQueryLimit = min( $limitStr, $wgCargoMaxQueryLimit );
92        }
93        $sqlQuery->mOffset = $offsetStr;
94        $sqlQuery->addTablePrefixesToAll();
95
96        return $sqlQuery;
97    }
98
99    /**
100     * Throw an error if there are forbidden values in any of the
101     * #cargo_query parameters - some or all of them are potential
102     * security risks.
103     *
104     * It could be that, given the way #cargo_query is structured, only
105     * some of the parameters need to be checked for these strings,
106     * but we might as well validate all of them.
107     *
108     * The function CargoUtils::getTableSchemas() also does specific
109     * validation of the "tables" parameter, while this class's
110     * setDescriptionsAndTableNameForFields() does validation of the
111     * "fields=" parameter.
112     */
113    public static function validateValues( $tablesStr, $fieldsStr, $whereStr, $joinOnStr, $groupByStr,
114        $havingStr, $orderByStr, $limitStr, $offsetStr, $allowFieldEscaping ) {
115        // Remove quoted strings from "where" parameter, to avoid
116        // unnecessary false positives from words like "from"
117        // being included in string comparisons.
118        // However, before we do that, check for certain strings that
119        // shouldn't be in quote marks either.
120        $whereStrRegexps = [
121            '/\-\-/' => '--',
122            '/#/' => '#',
123        ];
124
125        // HTML-decode the string - this is necessary if the query
126        // contains a call to {{PAGENAME}} and the page name has any
127        // special characters, because {{PAGENAME]] unfortunately
128        // HTML-encodes the value, which leads to a '#' in the string.
129        $decodedWhereStr = html_entity_decode( $whereStr, ENT_QUOTES );
130        foreach ( $whereStrRegexps as $regexp => $displayString ) {
131            if ( preg_match( $regexp, $decodedWhereStr ) ) {
132                throw new MWException( "Error in \"where\" parameter: the string \"$displayString\" cannot be used within #cargo_query." );
133            }
134        }
135        $noQuotesFieldsStr = CargoUtils::removeQuotedStrings( $fieldsStr );
136        $noQuotesWhereStr = CargoUtils::removeQuotedStrings( $decodedWhereStr );
137        $noQuotesJoinOnStr = CargoUtils::removeQuotedStrings( $joinOnStr );
138        $noQuotesGroupByStr = CargoUtils::removeQuotedStrings( $groupByStr );
139        $noQuotesHavingStr = CargoUtils::removeQuotedStrings( $havingStr );
140        $noQuotesOrderByStr = CargoUtils::removeQuotedStrings( $orderByStr );
141
142        $regexps = [
143            '/\bselect\b/i' => 'SELECT',
144            '/\binto\b/i' => 'INTO',
145            '/\bfrom\b/i' => 'FROM',
146            '/\bunion\b/i' => 'UNION',
147            '/;/' => ';',
148            '/@/' => '@',
149            '/\<\?/' => '<?',
150            '/\-\-/' => '--',
151            '/\/\*/' => '/*',
152            '/#/' => '#',
153        ];
154        // Bypass this particular check, for Special:Drilldown and possibly
155        // other query locations.
156        if ( !$allowFieldEscaping ) {
157            // Temporarily removed.
158            // $regexps['/`/'] = '`';
159        }
160        foreach ( $regexps as $regexp => $displayString ) {
161            if ( preg_match( $regexp, $tablesStr ) ||
162                preg_match( $regexp, $noQuotesFieldsStr ) ||
163                preg_match( $regexp, $noQuotesWhereStr ) ||
164                preg_match( $regexp, $noQuotesJoinOnStr ) ||
165                preg_match( $regexp, $noQuotesGroupByStr ) ||
166                preg_match( $regexp, $noQuotesHavingStr ) ||
167                preg_match( $regexp, $noQuotesOrderByStr ) ||
168                preg_match( $regexp, (string)$limitStr ) ||
169                preg_match( $regexp, (string)$offsetStr ) ) {
170                throw new MWException( "Error: the string \"$displayString\" cannot be used within #cargo_query." );
171            }
172        }
173
174        self::getAndValidateSQLFunctions( $noQuotesWhereStr );
175        self::getAndValidateSQLFunctions( $noQuotesJoinOnStr );
176        self::getAndValidateSQLFunctions( $noQuotesGroupByStr );
177        self::getAndValidateSQLFunctions( $noQuotesHavingStr );
178        self::getAndValidateSQLFunctions( $noQuotesOrderByStr );
179        self::getAndValidateSQLFunctions( $limitStr );
180        self::getAndValidateSQLFunctions( $offsetStr );
181    }
182
183    /**
184     * Gets a mapping of original field name strings to their field name aliases
185     * as they appear in the query result
186     */
187    public function getAliasForFieldString( $fieldString ) {
188        return $this->mFieldStringAliases[$fieldString];
189    }
190
191    /**
192     * Gets an array of field names and their aliases from the passed-in
193     * SQL fragment.
194     */
195    private function setAliasedFieldNames() {
196        $this->mAliasedFieldNames = [];
197        $fieldStrings = CargoUtils::smartSplit( ',', $this->mFieldsStr );
198        // Default is "_pageName".
199        if ( count( $fieldStrings ) == 0 ) {
200            $fieldStrings[] = '_pageName';
201        }
202
203        // Quick error-checking: for now, just disallow "DISTINCT",
204        // and require "GROUP BY" instead.
205        foreach ( $fieldStrings as $fieldString ) {
206            if ( strtolower( substr( $fieldString, 0, 9 ) ) == 'distinct ' ) {
207                throw new MWException( "Error: The DISTINCT keyword is not allowed by Cargo; "
208                . "please use \"group by=\" instead." );
209            }
210        }
211
212        // Because aliases are used as keys, we can't have more than
213        // one blank alias - so replace blank aliases with the name
214        // "Blank value X" - it will get replaced back before being
215        // displayed.
216        $blankAliasCount = 0;
217        foreach ( $fieldStrings as $fieldString ) {
218            $fieldStringParts = CargoUtils::smartSplit( '=', $fieldString, true );
219            if ( count( $fieldStringParts ) == 2 ) {
220                $fieldName = trim( $fieldStringParts[0] );
221                $alias = trim( $fieldStringParts[1] );
222                // Validate alias.
223                if ( strpos( $alias, '.' ) !== false || strpos( $alias, '"' ) !== false || strpos( $alias, '\'' ) !== false ) {
224                    throw new MWException( "Error: invalid field alias \"$alias\"; aliases cannot contain dots or quotes." );
225                }
226            } else {
227                $fieldName = $fieldString;
228                // Might as well change underscores to spaces
229                // by default - but for regular field names,
230                // not the special ones.
231                // "Real" field = with the table name removed.
232                if ( strpos( $fieldName, '.' ) !== false ) {
233                    [ $tableName, $realFieldName ] = explode( '.', $fieldName, 2 );
234                } else {
235                    $realFieldName = $fieldName;
236                }
237                if ( $realFieldName[0] != '_' ) {
238                    $alias = str_replace( '_', ' ', $realFieldName );
239                } else {
240                    $alias = $realFieldName;
241                }
242                // If this is just the field name being used as
243                // the alias, and it contains forbidden
244                // characters, don't throw an error - just
245                // replace those characters with spaces.
246                $alias = str_replace( [ '.', '"', '\'' ], ' ', $alias );
247            }
248            if ( !$alias ) {
249                $blankAliasCount++;
250                $alias = "Blank value $blankAliasCount";
251            }
252            $this->mAliasedFieldNames[$alias] = $fieldName;
253            $this->mFieldStringAliases[$fieldString] = $alias;
254        }
255        $this->mOrigAliasedFieldNames = $this->mAliasedFieldNames;
256    }
257
258    private function setAliasedTableNames() {
259        $this->mAliasedTableNames = [];
260        $tableStrings = CargoUtils::smartSplit( ',', $this->mTablesStr );
261
262        foreach ( $tableStrings as $tableString ) {
263            $tableStringParts = CargoUtils::smartSplit( '=', $tableString );
264            if ( count( $tableStringParts ) == 2 ) {
265                $tableName = trim( $tableStringParts[0] );
266                $alias = trim( $tableStringParts[1] );
267            } else {
268                $tableName = $tableString;
269                $alias = $tableString;
270            }
271            if ( !$alias ) {
272                throw new MWException( "Error: blank table aliases cannot be set." );
273            }
274            $this->mAliasedTableNames[$alias] = $tableName;
275        }
276    }
277
278    /**
279     * This does double duty: it both creates a "join conds" array
280     * from the string, and validates the set of join conditions
281     * based on the set of table names - making sure each table is
282     * joined.
283     *
284     * The "join conds" array created is not of the format that
285     * MediaWiki's database query() method requires - it is more
286     * structured and does not contain the necessary table prefixes yet.
287     */
288    private function setCargoJoinConds( $joinOnStr ) {
289        // This string is needed for "deferred" queries.
290        $this->mJoinOnStr = $joinOnStr;
291
292        $this->mCargoJoinConds = [];
293
294        if ( $joinOnStr === null || trim( $joinOnStr ) === '' ) {
295            if ( count( $this->mAliasedTableNames ) > 1 ) {
296                throw new MWException( "Error: join conditions must be set for tables." );
297            }
298            return;
299        }
300
301        $joinStrings = explode( ',', $joinOnStr );
302        // 'HOLDS' must be all-caps for now.
303        $allowedJoinOperators = [ '=', ' HOLDS ', '<=', '>=', '<', '>' ];
304        $joinOperator = null;
305
306        foreach ( $joinStrings as $joinString ) {
307            $foundValidOperator = false;
308            foreach ( $allowedJoinOperators as $allowedOperator ) {
309                if ( strpos( $joinString, $allowedOperator ) === false ) {
310                    continue;
311                }
312                $foundValidOperator = true;
313                $joinOperator = $allowedOperator;
314                break;
315            }
316
317            if ( !$foundValidOperator ) {
318                throw new MWException( "No valid operator found in join condition ($joinString)." );
319            }
320
321            $joinParts = explode( $joinOperator, $joinString );
322            $joinPart1 = trim( $joinParts[0] );
323            $tableAndField1 = explode( '.', $joinPart1 );
324            if ( count( $tableAndField1 ) != 2 ) {
325                throw new MWException( "Table and field name must both be specified in '$joinPart1'." );
326            }
327            [ $table1, $field1 ] = $tableAndField1;
328            $joinPart2 = trim( $joinParts[1] );
329            $tableAndField2 = explode( '.', $joinPart2 );
330            if ( count( $tableAndField2 ) != 2 ) {
331                throw new MWException( "Table and field name must both be specified in '$joinPart2'." );
332            }
333            [ $table2, $field2 ] = $tableAndField2;
334
335            $joinCond = [
336                'joinType' => 'LEFT OUTER JOIN',
337                'table1' => $table1,
338                'field1' => $field1,
339                'table2' => $table2,
340                'field2' => $field2,
341                'joinOperator' => $joinOperator
342            ];
343            $this->mCargoJoinConds[] = $joinCond;
344        }
345
346        // Now validate, to make sure that all the tables
347        // are "joined" together. There's probably some more
348        // efficient network algorithm for this sort of thing, but
349        // oh well.
350        $numUnmatchedTables = count( $this->mAliasedTableNames );
351        $firstJoinCond = current( $this->mCargoJoinConds );
352        $firstTableInJoins = $firstJoinCond['table1'];
353        $matchedTables = [ $firstTableInJoins ];
354        // We will check against aliases, not table names.
355        $allPossibleTableAliases = [];
356        foreach ( $this->mAliasedTableNames as $tableAlias => $tableName ) {
357            $allPossibleTableAliases[] = $tableAlias;
358            // This is useful for at least PostgreSQL.
359            $allPossibleTableAliases[] = $this->mCargoDB->addIdentifierQuotes( $tableAlias );
360        }
361        do {
362            $previousNumUnmatchedTables = $numUnmatchedTables;
363            foreach ( $this->mCargoJoinConds as $joinCond ) {
364                $table1 = $joinCond['table1'];
365                $table2 = $joinCond['table2'];
366                if ( !in_array( $table1, $allPossibleTableAliases ) ) {
367                    throw new MWException( "Error: table \"$table1\" is not in list of table names or aliases." );
368                }
369                if ( !in_array( $table2, $allPossibleTableAliases ) ) {
370                    throw new MWException( "Error: table \"$table2\" is not in list of table names or aliases." );
371                }
372
373                if ( in_array( $table1, $matchedTables ) && !in_array( $table2, $matchedTables ) ) {
374                    $matchedTables[] = $table2;
375                    $numUnmatchedTables--;
376                }
377                if ( in_array( $table2, $matchedTables ) && !in_array( $table1, $matchedTables ) ) {
378                    $matchedTables[] = $table1;
379                    $numUnmatchedTables--;
380                }
381            }
382        } while ( $numUnmatchedTables > 0 && $numUnmatchedTables > $previousNumUnmatchedTables );
383
384        if ( $numUnmatchedTables > 0 ) {
385            foreach ( array_keys( $this->mAliasedTableNames ) as $tableAlias ) {
386                $escapedTableAlias = $this->mCargoDB->addIdentifierQuotes( $tableAlias );
387                if ( !in_array( $tableAlias, $matchedTables ) &&
388                    !in_array( $escapedTableAlias, $matchedTables ) ) {
389                    throw new MWException( "Error: Table \"$tableAlias\" is not included within the "
390                    . "join conditions." );
391                }
392            }
393        }
394    }
395
396    /**
397     * The handling of DB table aliases in SQL queries was changed in
398     * MW 1.44, for reasons I don't really understand; because of it,
399     * some of the Cargo tables need to be called in the SQL query without
400     * their "cargo__" prefix. This method tries to determine wwhether
401     * the currently-used version of MediaWiki uses the old appraoch or
402     * not.
403     *
404     * The change to the handling was made in this revision, which, though
405     * long, unfortunately does not seem to contain any checkable changes,
406     * like the addition of a new class or method:
407     *
408     * https://phabricator.wikimedia.org/rMW1aa1b7d0384c298afed672400ccb01f700887ccf
409     *
410     * So instead we check for the existence of a method that was removed
411     * in a revision made just a day later:
412     *
413     * https://phabricator.wikimedia.org/rMWa14b0e646443c94bd21747682612279b39bd213f
414     *
415     * Hopefully this is close enough.
416     */
417    public static function mwUsesOldDBAliasing() {
418        return method_exists( 'MediaWiki\DB\AbstractSchemaValidator', 'checkDependencies' );
419    }
420
421    /**
422     * Turn the very structured format that Cargo uses for join
423     * conditions into the one that MediaWiki uses - this includes
424     * adding the database prefix to each table name.
425     */
426    private function setMWJoinConds() {
427        if ( $this->mCargoJoinConds == null ) {
428            return;
429        }
430
431        $this->mJoinConds = [];
432        foreach ( $this->mCargoJoinConds as $cargoJoinCond ) {
433            // Only add the DB prefix to the table names if
434            // they're true table names and not aliases.
435            $oldAliasing = self::mwUsesOldDBAliasing();
436            $table1 = $cargoJoinCond['table1'];
437            if ( $oldAliasing && ( !array_key_exists( $table1, $this->mAliasedTableNames ) || $this->mAliasedTableNames[$table1] == $table1 ) ) {
438                $cargoTable1 = $this->mCargoDB->tableName( $table1 );
439            } else {
440                $cargoTable1 = $this->mCargoDB->addIdentifierQuotes( $table1 );
441            }
442            $table2 = $cargoJoinCond['table2'];
443            if ( $oldAliasing && ( !array_key_exists( $table2, $this->mAliasedTableNames ) || $this->mAliasedTableNames[$table2] == $table2 ) ) {
444                $cargoTable2 = $this->mCargoDB->tableName( $table2 );
445            } else {
446                $cargoTable2 = $this->mCargoDB->addIdentifierQuotes( $table2 );
447            }
448            if ( array_key_exists( 'joinOperator', $cargoJoinCond ) ) {
449                $joinOperator = $cargoJoinCond['joinOperator'];
450            } else {
451                $joinOperator = '=';
452            }
453
454            $field1 = $this->mCargoDB->addIdentifierQuotes( $cargoJoinCond['field1'] );
455            $field2 = $this->mCargoDB->addIdentifierQuotes( $cargoJoinCond['field2'] );
456            $joinCondConds = [
457                $cargoTable1 . '.' . $field1 . $joinOperator .
458                $cargoTable2 . '.' . $field2
459            ];
460            if ( array_key_exists( 'extraCond', $cargoJoinCond ) ) {
461                $joinCondConds[] = $cargoJoinCond['extraCond'];
462            }
463            if ( !array_key_exists( $table2, $this->mJoinConds ) ) {
464                $this->mJoinConds[$table2] = [
465                    $cargoJoinCond['joinType'],
466                    $joinCondConds
467                ];
468            } else {
469                $this->mJoinConds[$table2][1] = array_merge(
470                    $this->mJoinConds[$table2][1],
471                    $joinCondConds
472                );
473            }
474        }
475    }
476
477    public function setOrderBy( $orderByStr = null ) {
478        $this->mOrderBy = [];
479        if ( $orderByStr != '' ) {
480            $orderByElements = CargoUtils::smartSplit( ',', $orderByStr );
481            foreach ( $orderByElements as $elem ) {
482                // Get rid of "ASC" - it's never needed.
483                if ( strtolower( substr( $elem, -4 ) ) == ' asc' ) {
484                    $elem = trim( substr( $elem, 0, strlen( $elem ) - 4 ) );
485                }
486                // If it has "DESC" at the end, remove it, then
487                // add it back in later.
488                $hasDesc = ( strtolower( substr( $elem, -5 ) ) == ' desc' );
489                if ( $hasDesc ) {
490                    $elem = trim( substr( $elem, 0, strlen( $elem ) - 5 ) );
491                }
492                if ( strpos( $elem, '(' ) === false && strpos( $elem, '.' ) === false && !$this->mCargoDB->isQuotedIdentifier( $elem ) ) {
493                    $elem = $this->mCargoDB->addIdentifierQuotes( $elem );
494                }
495                if ( $hasDesc ) {
496                    $elem .= ' DESC';
497                }
498                $this->mOrderBy[] = $elem;
499            }
500        } else {
501            // By default, sort on up to the first five fields, in
502            // the order in which they're defined. Five seems like
503            // enough to make sure everything is in the right order,
504            // no? Or should it always be all the fields?
505            $fieldNum = 1;
506            foreach ( $this->mAliasedFieldNames as $fieldName ) {
507                if ( strpos( $fieldName, '(' ) === false && strpos( $fieldName, '.' ) === false ) {
508                    $this->mOrderBy[] = $this->mCargoDB->addIdentifierQuotes( $fieldName );
509                } else {
510                    $this->mOrderBy[] = $fieldName;
511                }
512                $fieldNum++;
513                if ( $fieldNum > 5 ) {
514                    break;
515                }
516            }
517        }
518    }
519
520    public function setGroupBy( $groupByStr ) {
521        // @TODO - $mGroupByStr should turn into an array named
522        // $mGroupBy for better handling of mulitple values, as was
523        // done with $mOrderBy.
524        $this->mOrigGroupByStr = $groupByStr;
525        if ( $groupByStr == '' ) {
526            $this->mGroupByStr = '';
527        } elseif ( strpos( $groupByStr, '(' ) === false && strpos( $groupByStr, '.' ) === false && strpos( $groupByStr, ',' ) === false ) {
528            $this->mGroupByStr = $this->mCargoDB->addIdentifierQuotes( $groupByStr );
529        } else {
530            $this->mGroupByStr = $groupByStr;
531        }
532    }
533
534    private static function getAndValidateSQLFunctions( $str ) {
535        global $wgCargoAllowedSQLFunctions;
536
537        if ( $str === null ) {
538            return [];
539        }
540
541        $sqlFunctionMatches = [];
542        $sqlFunctionRegex = '/(\b|\W|`)(\w*?)(\s*|`)\(/';
543        preg_match_all( $sqlFunctionRegex, $str, $sqlFunctionMatches );
544        $sqlFunctions = array_map( 'strtoupper', $sqlFunctionMatches[2] );
545        $sqlFunctions = array_map( 'trim', $sqlFunctions );
546        // Throw an error if any of these functions
547        // are not in our "whitelist" of SQL functions.
548        // Also add to this whitelist SQL operators like AND, OR, NOT,
549        // etc., because the parsing can mistake these for functions.
550        $logicalOperators = [ 'AND', 'OR', 'NOT', 'IN' ];
551        $allowedFunctions = array_merge( $wgCargoAllowedSQLFunctions, $logicalOperators );
552        foreach ( $sqlFunctions as $sqlFunction ) {
553            // @TODO - fix the original regexp to avoid blank
554            // strings, so that this check is not necessary.
555            if ( $sqlFunction == '' ) {
556                continue;
557            }
558            if ( !in_array( $sqlFunction, $allowedFunctions ) ) {
559                throw new MWException( wfMessage( "cargo-query-badsqlfunction", "$sqlFunction()" )->parse() );
560            }
561        }
562
563        return $sqlFunctions;
564    }
565
566    /**
567     * Attempts to get the "field description" (type, etc.), as well as the
568     * table name, of a single field specified in a SELECT call (via a
569     * #cargo_query call), using the set of schemas for all data tables.
570     *
571     * Also does some validation of table names, field names, and any SQL
572     * functions contained in this clause.
573     */
574    private function getDescriptionAndTableNameForField( $origFieldName ) {
575        $tableName = null;
576        $fieldName = null;
577        $description = new CargoFieldDescription();
578
579        // We use "\p{L}0-9" instead of \w here in order to
580        // handle accented and other non-ASCII characters in
581        // table and field names.
582        $fieldPattern = '/^([-_\p{L}0-9$]+)([.]([-_\p{L}0-9$]+))?$/u';
583        $fieldPatternFound = preg_match( $fieldPattern, $origFieldName, $fieldPatternMatches );
584        $stringPatternFound = false;
585        $hasFunctionCall = false;
586
587        if ( $fieldPatternFound ) {
588            switch ( count( $fieldPatternMatches ) ) {
589                case 2:
590                    $fieldName = $fieldPatternMatches[1];
591                    break;
592                case 4:
593                    $tableName = $fieldPatternMatches[1];
594                    $fieldName = $fieldPatternMatches[3];
595                    break;
596            }
597        } else {
598            $stringPattern = '/^(([\'"]).*?\2)(.+)?$/';
599            $stringPatternFound = preg_match( $stringPattern, $origFieldName, $stringPatternMatches );
600            if ( $stringPatternFound ) {
601                // If the count is 3 we have a single quoted string
602                // If the count is 4 we have stuff after it
603                $stringPatternFound = count( $stringPatternMatches ) == 3;
604            }
605
606            if ( !$stringPatternFound ) {
607                $noQuotesOrigFieldName = CargoUtils::removeQuotedStrings( $origFieldName );
608
609                $functionCallPattern = '/\p{L}\s*\(/';
610                $hasFunctionCall = preg_match( $functionCallPattern, $noQuotesOrigFieldName );
611            }
612        }
613        // If it's a pre-defined field, we probably know its type.
614        if ( $fieldName == '_ID' || $fieldName == '_rowID' || $fieldName == '_pageID' || $fieldName == '_pageNamespace' || $fieldName == '_position' ) {
615            $description->mType = 'Integer';
616        } elseif ( $fieldName == '_pageTitle' ) {
617            // It's a string - do nothing.
618        } elseif ( $fieldName == '_pageName' ) {
619            $description->mType = 'Page';
620        } elseif ( $stringPatternFound ) {
621            // It's a quoted, literal string - do nothing.
622        } elseif ( $hasFunctionCall ) {
623            $sqlFunctions = self::getAndValidateSQLFunctions( $noQuotesOrigFieldName );
624            $firstFunction = $sqlFunctions[0];
625            // 'ROUND' is in neither the Integer nor Float
626            // lists because it sometimes returns an
627            // integer, sometimes a float - for formatting
628            // purposes, we'll just treat it as a string.
629            if ( in_array( $firstFunction, [ 'COUNT', 'FLOOR', 'CEIL' ] ) ) {
630                $description->mType = 'Integer';
631            } elseif ( in_array( $firstFunction, [ 'SUM', 'POWER', 'LN', 'LOG' ] ) ) {
632                $description->mType = 'Float';
633            } elseif ( in_array( $firstFunction,
634                    [ 'DATE', 'DATE_ADD', 'DATE_SUB', 'DATE_DIFF' ] ) ) {
635                $description->mType = 'Date';
636            } elseif ( in_array( $firstFunction, [ 'TRIM' ] ) ) {
637                // @HACK - allow users one string function
638                // (TRIM()) that will return a String type, and
639                // thus won't have its value parsed as wikitext.
640                // Hopefully this won't cause problems for those
641                // just wanting to call TRIM(). (In that case,
642                // they can wrap the call in CONCAT().)
643                $description->mType = 'String';
644            } elseif ( in_array( $firstFunction, [ 'MAX', 'MIN', 'AVG' ] ) ) {
645                // These are special functions in that the type
646                // of their output is not fixed, but rather
647                // matches the type of their input. So we find
648                // what's inside the function call and call
649                // *this* function recursively on that.
650                $startParenPos = strpos( $origFieldName, '(' );
651                $lastParenPos = strrpos( $origFieldName, ')' );
652                $innerFieldName = substr( $origFieldName, $startParenPos + 1, ( $lastParenPos - $startParenPos - 1 ) );
653                [ $innerDesc, $innerTableName ] = $this->getDescriptionAndTableNameForField( $innerFieldName );
654                if ( $firstFunction == 'AVG' && $innerDesc->mType == 'Integer' ) {
655                    // In practice, handling of AVG() is here
656                    // so that calling it on a Rating
657                    // field will keep it as Rating.
658                    $description->mType = 'Float';
659                } else {
660                    return [ $innerDesc, $innerTableName ];
661                }
662            }
663            // If it's anything else ('CONCAT', 'SUBSTRING',
664            // etc. etc.), we don't have to do anything.
665        } else {
666            // It's a standard field - though if it's '_value',
667            // or ends in '__full', it's actually the type of its
668            // corresponding field.
669            $useListTable = ( $fieldName == '_value' );
670            if ( $useListTable ) {
671                if ( $tableName != null ) {
672                    if ( strpos( $tableName, '__' ) !== false ) {
673                        [ $tableName, $fieldName ] = explode( '__', $tableName, 2 );
674                    } else {
675                        // Support directly operating on list table fields
676                        $fieldName = null;
677                    }
678                } else {
679                    // We'll assume that there's exactly one
680                    // "field table" in the list of tables -
681                    // otherwise a standalone call to
682                    // "_value" will presumably crash the
683                    // SQL call.
684                    foreach ( $this->mAliasedTableNames as $curTable ) {
685                        if ( strpos( $curTable, '__' ) !== false ) {
686                            [ $tableName, $fieldName ] = explode( '__', $curTable );
687                            break;
688                        }
689                    }
690                }
691            } elseif ( strlen( $fieldName ) > 6 &&
692                strpos( $fieldName, '__full', strlen( $fieldName ) - 6 ) !== false ) {
693                $fieldName = substr( $fieldName, 0, strlen( $fieldName ) - 6 );
694            }
695            if ( $tableName != null && !$useListTable ) {
696                if ( !array_key_exists( $tableName, $this->mAliasedTableNames ) ) {
697                    throw new MWException( wfMessage( "cargo-query-badalias", $tableName )->parse() );
698                }
699                $actualTableName = $this->mAliasedTableNames[$tableName];
700                if ( !array_key_exists( $actualTableName, $this->mTableSchemas ) ) {
701                    throw new MWException( wfMessage( "cargo-query-unknowndbtable", $actualTableName )->parse() );
702                } elseif ( !array_key_exists( $fieldName, $this->mTableSchemas[$actualTableName]->mFieldDescriptions ) ) {
703                    throw new MWException( wfMessage( "cargo-query-unknownfieldfortable", $fieldName, $actualTableName )->parse() );
704                } else {
705                    $description = $this->mTableSchemas[$actualTableName]->mFieldDescriptions[$fieldName];
706                }
707            } elseif ( substr( $fieldName, -5 ) == '__lat' || substr( $fieldName, -5 ) == '__lon' ) {
708                // Special handling for lat/lon helper fields.
709                $description->mType = 'Coordinates part';
710                $tableName = '';
711            } elseif ( substr( $fieldName, -11 ) == '__precision' ) {
712                // Special handling for lat/lon helper fields.
713                // @TODO - we need validation on
714                // __lat, __lon and __precision fields,
715                // to make sure that they exist.
716                $description->mType = 'Date precision';
717                $tableName = '';
718            } else {
719                // Go through all the fields, until we find the
720                // one matching this one.
721                foreach ( $this->mTableSchemas as $curTableName => $tableSchema ) {
722                    if ( array_key_exists( $fieldName, $tableSchema->mFieldDescriptions ) ) {
723                        $description = $tableSchema->mFieldDescriptions[$fieldName];
724                        foreach ( $this->mAliasedTableNames as $tableAlias => $tableName1 ) {
725                            if ( $tableName1 == $curTableName ) {
726                                $tableName = $tableAlias;
727                                break;
728                            }
729                        }
730                        break;
731                    }
732                }
733
734                // If we couldn't find a table name, throw an error.
735                if ( $tableName == '' ) {
736                    // There's a good chance that
737                    // $fieldName is blank too.
738                    if ( $fieldName == '' ) {
739                        $fieldName = $origFieldName;
740                    }
741                    throw new MWException( wfMessage( "cargo-query-unknownfield", $fieldName )->parse() );
742                }
743            }
744        }
745
746        return [ $description, $tableName ];
747    }
748
749    /**
750     * Attempts to get the "field description" (type, etc.), as well as
751     * the table name, of each field specified in a SELECT call (via a
752     * #cargo_query call), using the set of schemas for all data tables.
753     */
754    public function setDescriptionsAndTableNamesForFields() {
755        $this->mFieldDescriptions = [];
756        $this->mFieldTables = [];
757        foreach ( $this->mAliasedFieldNames as $alias => $origFieldName ) {
758            [ $description, $tableName ] = $this->getDescriptionAndTableNameForField( $origFieldName );
759
760            // Fix alias.
761            $alias = trim( $alias );
762            $this->mFieldDescriptions[$alias] = $description;
763            $this->mFieldTables[$alias] = $tableName;
764        }
765    }
766
767    public function addToCargoJoinConds( $newCargoJoinConds ) {
768        foreach ( $newCargoJoinConds as $newCargoJoinCond ) {
769            // Go through to make sure it's not there already.
770            $foundMatch = false;
771            foreach ( $this->mCargoJoinConds as $cargoJoinCond ) {
772                if ( $cargoJoinCond['table1'] == $newCargoJoinCond['table1'] &&
773                    $cargoJoinCond['field1'] == $newCargoJoinCond['field1'] &&
774                    $cargoJoinCond['table2'] == $newCargoJoinCond['table2'] &&
775                    $cargoJoinCond['field2'] == $newCargoJoinCond['field2'] ) {
776                    $foundMatch = true;
777                    continue;
778                }
779            }
780            if ( !$foundMatch ) {
781                // If this join references another table, insert
782                // this one before the join for that one.
783                $inserted = false;
784                foreach ( $this->mCargoJoinConds as $i => $curJoinCond ) {
785                    if ( $newCargoJoinCond['table2'] == $curJoinCond['table1'] ) {
786                        array_splice( $this->mCargoJoinConds, $i, 0, [ $newCargoJoinCond ] );
787                        $inserted = true;
788                        break;
789                    }
790                }
791                if ( !$inserted ) {
792                    array_unshift( $this->mCargoJoinConds, $newCargoJoinCond );
793                }
794            }
795        }
796    }
797
798    public function addFieldTableToTableNames( $fieldTableName, $fieldTableAlias, $tableAlias ) {
799        // Add it in in the correct place, if it should be added at all.
800        if ( array_key_exists( $fieldTableAlias, $this->mAliasedTableNames ) ) {
801            return;
802        }
803        if ( !array_key_exists( $tableAlias, $this->mAliasedTableNames ) ) {
804            // Show an error message here?
805            return;
806        }
807
808        // array_splice() for an associative array - copied from
809        // http://stackoverflow.com/a/1783125
810        $indexOfMainTable = array_search( $tableAlias, array_keys( $this->mAliasedTableNames ) );
811        $offset = $indexOfMainTable + 1;
812        $this->mAliasedTableNames = array_slice( $this->mAliasedTableNames, 0, $offset, true ) +
813            [ $fieldTableAlias => $fieldTableName ] +
814            array_slice( $this->mAliasedTableNames, $offset, null, true );
815    }
816
817    /**
818     * Helper function for handleVirtualFields() - for the query's
819     * "fields" and "order by" values, the right replacement for "virtual
820     * fields" depends on whether the separate table for that field has
821     * been included in the query.
822     */
823    public function fieldTableIsIncluded( $fieldTableAlias ) {
824        foreach ( $this->mCargoJoinConds as $cargoJoinCond ) {
825            if ( $cargoJoinCond['table1'] == $fieldTableAlias ||
826                $cargoJoinCond['table2'] == $fieldTableAlias ) {
827                return true;
828            }
829        }
830        return false;
831    }
832
833    /**
834     * Provides HOLDS functionality to WHERE clause by replacing $pattern
835     * in $subject with suitable subquery and setting $found to true if
836     * successful (leaves it untouched otehrwise). Includes modifying
837     * the regex beginning from a non-valid identifier character to word
838     * boundary.
839     */
840    public function substVirtualFieldName( &$subject, $rootPattern, $tableAlias, $notOperation, $fieldTableName, $compareOperator, &$found ) {
841        $notOperator = $notOperation ? 'NOT' : '';
842        $patternMatch = [];
843        // Match HOLDS syntax with values in single quotes
844        if ( preg_match_all( $rootPattern . '\s*(\'.*?[^\\\\\']\')/i', $subject, $matches ) ) {
845            $pattern = $rootPattern . '\s*(\'.*?[^\\\\\']\')/i';
846            $patternMatch[$pattern] = $matches;
847        }
848        // Match HOLDS syntax with values in double quotes
849        if ( preg_match_all( $rootPattern . '\s*(\".*?[^\\\"]\")/i', $subject, $matches ) ) {
850            $pattern = $rootPattern . '\s*(\".*?[^\\\"]\")/i';
851            $patternMatch[$pattern] = $matches;
852        }
853        // Match HOLDS syntax with fieldnames without quotes.
854        // Fieldnames are expected to be single words without spaces.
855        if ( preg_match_all( $rootPattern . '\s*([^\'"\s]+\s*)/i', $subject, $matches ) ) {
856            $pattern = $rootPattern . '\s*([^\'"\s]*\s*)/i';
857            $patternMatch[$pattern] = $matches;
858        }
859        // If any match is found, replace it with a subquery.
860        if ( $patternMatch ) {
861            foreach ( $patternMatch as $pattern => $matches ) {
862                $pattern = str_replace( '([^\w$,]|^)', '\b', $pattern );
863                $pattern = str_replace( '([^\w$.,]|^)', '\b', $pattern );
864                foreach ( $matches[2] as $match ) {
865                    // _ID need not be quoted here.
866                    // This being attached with a table name is handled
867                    // in the function addTablePrefixesToAll, like other fields.
868                    $replacement =
869                        $tableAlias . "._ID " .
870                        $notOperator .
871                        " IN (SELECT " . $this->mCargoDB->addIdentifierQuotes( "_rowID" ) . " FROM " .
872                        $this->mCargoDB->tableName( $fieldTableName ) .
873                        " WHERE " . $this->mCargoDB->addIdentifierQuotes( "_value" ) .
874                        $compareOperator .
875                        $match .
876                        ") ";
877                    $subject = preg_replace( $pattern, $replacement, $subject, $limit = 1 );
878                }
879            }
880            $found = true;
881        }
882    }
883
884    private function handleVirtualFields() {
885        // The array-field alias can be found in a number of different
886        // clauses. Handling depends on which clause it is:
887        // "where" - make sure that "HOLDS" or "HOLDS LIKE" is
888        // specified. If it is, "translate" it into required subquery.
889        // "join on" - make sure that "HOLDS" is specified, If it is,
890        // "translate" it, and add the values table to "tables".
891        // "group by" - always "translate" it into the single value.
892        // "having" - same as "group by".
893        // "fields" - "translate" it, where the translation (i.e.
894        // the true field) depends on whether or not the values
895        // table is included.
896        // "order by" - same as "fields".
897
898        // First, create an array of the virtual fields in the current
899        // set of tables.
900        $virtualFields = [];
901        foreach ( $this->mTableSchemas as $tableName => $tableSchema ) {
902            foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
903                if ( !$fieldDescription->mIsList ) {
904                    continue;
905                }
906                foreach ( $this->mAliasedTableNames as $tableAlias => $tableName2 ) {
907                    if ( $tableName == $tableName2 ) {
908                        $virtualFields[] = [
909                            'fieldName' => $fieldName,
910                            'tableAlias' => $tableAlias,
911                            'tableName' => $tableName,
912                            'fieldType' => $fieldDescription->mType,
913                            'isHierarchy' => $fieldDescription->mIsHierarchy
914                        ];
915                    }
916                }
917            }
918        }
919
920        // "where"
921        $matches = [];
922        $numHoldsExpressions = 0;
923        foreach ( $virtualFields as $virtualField ) {
924            $fieldName = $virtualField['fieldName'];
925            $tableAlias = $virtualField['tableAlias'];
926            $tableName = $virtualField['tableName'];
927            $fieldType = $virtualField['fieldType'];
928            $isHierarchy = $virtualField['isHierarchy'];
929
930            $fieldTableName = $tableName . '__' . $fieldName;
931            $fieldReplaced = false;
932
933            $patternSimple = [
934                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName ),
935                CargoUtils::getSQLFieldPattern( $fieldName )
936                ];
937            $patternRoot = [
938                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, false ) . '\s+',
939                CargoUtils::getSQLFieldPattern( $fieldName, false ) . '\s+'
940                ];
941
942            for ( $i = 0; $i < 2; $i++ ) {
943                if ( preg_match( $patternSimple[$i], $this->mWhereStr ) ) {
944
945                    $this->substVirtualFieldName(
946                        $this->mWhereStr,
947                        $patternRoot[$i] . 'HOLDS\s+NOT\s+LIKE',
948                        $tableAlias,
949                        $notOperation = true,
950                        $fieldTableName,
951                        $compareOperation = "LIKE ",
952                        $fieldReplaced
953                    );
954
955                    $this->substVirtualFieldName(
956                        $this->mWhereStr,
957                        $patternRoot[$i] . 'HOLDS\s+LIKE',
958                        $tableAlias,
959                        $notOperation = false,
960                        $fieldTableName,
961                        $compareOperation = "LIKE ",
962                        $fieldReplaced
963                    );
964
965                    $this->substVirtualFieldName(
966                        $this->mWhereStr,
967                        $patternRoot[$i] . 'HOLDS\s+NOT',
968                        $tableAlias,
969                        $notOperation = true,
970                        $fieldTableName,
971                        $compareOperation = "= ",
972                        $fieldReplaced
973                    );
974
975                    $this->substVirtualFieldName(
976                        $this->mWhereStr,
977                        $patternRoot[$i] . 'HOLDS',
978                        $tableAlias,
979                        $notOperation = false,
980                        $fieldTableName,
981                        $compareOperation = "= ",
982                        $fieldReplaced
983                    );
984
985                    if ( preg_match( $patternSimple[$i], $this->mWhereStr ) ) {
986                        if ( $isHierarchy ) {
987                            throw new MWException( "Error: operator for the hierarchy field '" .
988                                "$tableName.$fieldName' must be 'HOLDS', 'HOLDS NOT', '" .
989                                "HOLDS WITHIN', 'HOLDS LIKE' or 'HOLDS NOT LIKE'." );
990                        } else {
991                            throw new MWException( "Error: operator for the virtual field '" .
992                                "$tableName.$fieldName' must be 'HOLDS', 'HOLDS NOT', '" .
993                                "HOLDS LIKE' or 'HOLDS NOT LIKE'." );
994                        }
995                    }
996                }
997            }
998            // Always use the "field table" if it's a date field,
999            // and it's being queried.
1000            $isFieldInQuery = in_array( $fieldName, $this->mAliasedFieldNames ) ||
1001                in_array( "$tableAlias.$fieldName", $this->mAliasedFieldNames );
1002            if ( $isFieldInQuery && ( $fieldType == 'Date' || $fieldType == 'Datetime' ) ) {
1003                $fieldReplaced = true;
1004            }
1005        }
1006        // "join on"
1007        $newCargoJoinConds = [];
1008        foreach ( $this->mCargoJoinConds as $i => $joinCond ) {
1009            // We only handle 'HOLDS' here - no joining on
1010            // 'HOLDS LIKE'.
1011            if ( !array_key_exists( 'joinOperator', $joinCond ) || $joinCond['joinOperator'] != ' HOLDS ' ) {
1012                continue;
1013            }
1014
1015            foreach ( $virtualFields as $virtualField ) {
1016                $fieldName = $virtualField['fieldName'];
1017                $tableAlias = $virtualField['tableAlias'];
1018                $tableName = $virtualField['tableName'];
1019                if ( $fieldName != $joinCond['field1'] || $tableAlias != $joinCond['table1'] ) {
1020                    continue;
1021                }
1022                $fieldTableName = $tableName . '__' . $fieldName;
1023                $fieldTableAlias = $tableAlias . '__' . $fieldName;
1024                $this->addFieldTableToTableNames( $fieldTableName, $fieldTableAlias, $tableAlias );
1025                $newJoinCond = [
1026                    'joinType' => 'LEFT OUTER JOIN',
1027                    'table1' => $tableAlias,
1028                    'field1' => '_ID',
1029                    'table2' => $fieldTableAlias,
1030                    'field2' => '_rowID'
1031                ];
1032                $newCargoJoinConds[] = $newJoinCond;
1033                $newJoinCond2 = [
1034                    'joinType' => 'RIGHT OUTER JOIN',
1035                    'table1' => $fieldTableAlias,
1036                    'field1' => '_value',
1037                    'table2' => $this->mCargoJoinConds[$i]['table2'],
1038                    'field2' => $this->mCargoJoinConds[$i]['field2']
1039                ];
1040                $newCargoJoinConds[] = $newJoinCond2;
1041                // Is it safe to unset an array value while
1042                // cycling through the array? Hopefully.
1043                unset( $this->mCargoJoinConds[$i] );
1044            }
1045        }
1046        $this->addToCargoJoinConds( $newCargoJoinConds );
1047
1048        // If there's more than one table, there must be one or more
1049        // joins - match the order in $this->mAliasedTableNames to the
1050        // order of the tables within the joins.
1051        if ( count( $this->mAliasedTableNames ) > 1 ) {
1052            $orderedTableAliases = [];
1053            foreach ( $this->mCargoJoinConds as $joinCond ) {
1054                $table1 = $joinCond['table1'];
1055                $table2 = $joinCond['table2'];
1056                if ( !in_array( $table1, $orderedTableAliases ) ) {
1057                    $orderedTableAliases[] = $table1;
1058                }
1059                if ( !in_array( $table2, $orderedTableAliases ) ) {
1060                    $orderedTableAliases[] = $table2;
1061                }
1062            }
1063
1064            uksort( $this->mAliasedTableNames, static function ( $key1, $key2 ) use ( $orderedTableAliases ) {
1065                return ( array_search( $key1, $orderedTableAliases ) - array_search( $key2, $orderedTableAliases ) );
1066            } );
1067        }
1068
1069        // "group by" and "having"
1070        // We handle these before "fields" and "order by" because,
1071        // unlike those two, a virtual field here can affect the
1072        // set of tables and fields being included - which will
1073        // affect the other two.
1074        $matches = [];
1075        foreach ( $virtualFields as $virtualField ) {
1076            $fieldName = $virtualField['fieldName'];
1077            $tableAlias = $virtualField['tableAlias'];
1078            $tableName = $virtualField['tableName'];
1079            $pattern1 = CargoUtils::getSQLTableAndFieldPattern( $tableName, $fieldName );
1080            $foundMatch1 = preg_match( $pattern1, $this->mGroupByStr, $matches );
1081            $pattern2 = CargoUtils::getSQLFieldPattern( $fieldName );
1082            $foundMatch2 = false;
1083
1084            if ( !$foundMatch1 ) {
1085                $foundMatch2 = preg_match( $pattern2, $this->mGroupByStr, $matches );
1086            }
1087            if ( $foundMatch1 || $foundMatch2 ) {
1088                $fieldTableName = $tableName . '__' . $fieldName;
1089                $fieldTableAlias = $tableAlias . '__' . $fieldName;
1090                if ( !$this->fieldTableIsIncluded( $fieldTableAlias ) ) {
1091                    $this->addFieldTableToTableNames( $fieldTableName, $fieldTableAlias, $tableAlias );
1092                    $this->mCargoJoinConds[] = [
1093                        'joinType' => 'LEFT OUTER JOIN',
1094                        'table1' => $tableAlias,
1095                        'field1' => '_ID',
1096                        'table2' => $fieldTableAlias,
1097                        'field2' => '_rowID'
1098                    ];
1099                }
1100                $replacement = "$fieldTableAlias._value";
1101
1102                if ( $foundMatch1 ) {
1103                    $this->mGroupByStr = preg_replace( $pattern1, $replacement, $this->mGroupByStr ?? '' );
1104                    $this->mHavingStr = preg_replace( $pattern1, $replacement, $this->mHavingStr ?? '' );
1105                } elseif ( $foundMatch2 ) {
1106                    $this->mGroupByStr = preg_replace( $pattern2, $replacement, $this->mGroupByStr ?? '' );
1107                    $this->mHavingStr = preg_replace( $pattern2, $replacement, $this->mHavingStr ?? '' );
1108                }
1109            }
1110        }
1111
1112        // "fields"
1113        foreach ( $this->mAliasedFieldNames as $alias => $fieldName ) {
1114            $fieldDescription = $this->mFieldDescriptions[$alias];
1115
1116            if ( strpos( $fieldName, '.' ) !== false ) {
1117                // This could probably be done better with
1118                // regexps.
1119                [ $tableAlias, $fieldName ] = explode( '.', $fieldName, 2 );
1120            } else {
1121                $tableAlias = $this->mFieldTables[$alias];
1122            }
1123
1124            // We're only interested in virtual list fields.
1125            $isVirtualField = false;
1126            foreach ( $virtualFields as $virtualField ) {
1127                if ( $fieldName == $virtualField['fieldName'] && $tableAlias == $virtualField['tableAlias'] ) {
1128                    $isVirtualField = true;
1129                    break;
1130                }
1131            }
1132            if ( !$isVirtualField ) {
1133                continue;
1134            }
1135
1136            // Since the field name is an alias, it should get
1137            // translated, to either the "full" equivalent or to
1138            // the "value" field in the field table - depending on
1139            // whether or not that field has been "joined" on.
1140            $fieldTableAlias = $tableAlias . '__' . $fieldName;
1141            if ( $this->fieldTableIsIncluded( $fieldTableAlias ) ) {
1142                $fieldName = $fieldTableAlias . '._value';
1143            } else {
1144                $fieldName .= '__full';
1145            }
1146            $this->mAliasedFieldNames[$alias] = $fieldName;
1147        }
1148
1149        // "order by"
1150        $matches = [];
1151        foreach ( $virtualFields as $virtualField ) {
1152            $fieldName = $virtualField['fieldName'];
1153            $tableAlias = $virtualField['tableAlias'];
1154            $tableName = $virtualField['tableName'];
1155            $pattern1 = CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName );
1156            $pattern2 = CargoUtils::getSQLFieldPattern( $fieldName );
1157            $foundMatch1 = $foundMatch2 = false;
1158            foreach ( $this->mOrderBy as &$orderByElem ) {
1159                $foundMatch1 = preg_match( $pattern1, $orderByElem, $matches );
1160
1161                if ( !$foundMatch1 ) {
1162                    $foundMatch2 = preg_match( $pattern2, $orderByElem, $matches );
1163                }
1164                if ( !$foundMatch1 && !$foundMatch2 ) {
1165                    continue;
1166                }
1167                $fieldTableAlias = $tableAlias . '__' . $fieldName;
1168                if ( $this->fieldTableIsIncluded( $fieldTableAlias ) ) {
1169                    $replacement = "$fieldTableAlias._value";
1170                } else {
1171                    $replacement = $tableAlias . '.' . $fieldName . '__full ';
1172                }
1173                if ( isset( $matches[2] ) && ( $matches[2] == ',' ) ) {
1174                    $replacement .= ',';
1175                }
1176                if ( $foundMatch1 ) {
1177                    $orderByElem = preg_replace( $pattern1, $replacement, $orderByElem );
1178                } else { // $foundMatch2
1179                    $orderByElem = preg_replace( $pattern2, $replacement, $orderByElem );
1180                }
1181            }
1182        }
1183    }
1184
1185    /**
1186     * Similar to handleVirtualFields(), but handles coordinates fields
1187     * instead of fields that hold lists. This handling is much simpler.
1188     */
1189    private function handleVirtualCoordinateFields() {
1190        // Coordinate fields can be found in the "fields" and "where"
1191        // clauses. The following handling is done:
1192        // "fields" - "translate" it, where the translation (i.e.
1193        // the true field) depends on whether or not the values
1194        // table is included.
1195        // "where" - make sure that "NEAR" is specified. If it is,
1196        // translate the clause accordingly.
1197
1198        // First, create an array of the coordinate fields in the
1199        // current set of tables.
1200        $coordinateFields = [];
1201        foreach ( $this->mTableSchemas as $tableName => $tableSchema ) {
1202            foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
1203                if ( $fieldDescription->mType == 'Coordinates' ) {
1204                    foreach ( $this->mAliasedTableNames as $tableAlias => $tableName2 ) {
1205                        if ( $tableName == $tableName2 ) {
1206                            $coordinateFields[] = [
1207                                'fieldName' => $fieldName,
1208                                'tableName' => $tableName,
1209                                'tableAlias' => $tableAlias,
1210                            ];
1211                            break;
1212                        }
1213                    }
1214                }
1215            }
1216        }
1217
1218        // "fields"
1219        foreach ( $this->mAliasedFieldNames as $alias => $fieldName ) {
1220            $fieldDescription = $this->mFieldDescriptions[$alias];
1221
1222            if ( strpos( $fieldName, '.' ) !== false ) {
1223                // This could probably be done better with
1224                // regexps.
1225                [ $tableAlias, $fieldName ] = explode( '.', $fieldName, 2 );
1226            } else {
1227                $tableAlias = $this->mFieldTables[$alias];
1228            }
1229
1230            // We have to do this roundabout checking, instead
1231            // of just looking at the type of each field alias,
1232            // because we want to find only the *virtual*
1233            // coordinate fields.
1234            $isCoordinateField = false;
1235            foreach ( $coordinateFields as $coordinateField ) {
1236                if ( $fieldName == $coordinateField['fieldName'] &&
1237                    $tableAlias == $coordinateField['tableAlias'] ) {
1238                    $isCoordinateField = true;
1239                    break;
1240                }
1241            }
1242            if ( !$isCoordinateField ) {
1243                continue;
1244            }
1245
1246            // Since the field name is an alias, it should get
1247            // translated to its "full" equivalent.
1248            $fullFieldName = $fieldName . '__full';
1249            $this->mAliasedFieldNames[$alias] = $fullFieldName;
1250
1251            // Add in the 'lat' and 'lon' fields as well - we'll
1252            // need them, if a map is being displayed.
1253            $this->mAliasedFieldNames[$fieldName . '  lat'] = $fieldName . '__lat';
1254            $this->mAliasedFieldNames[$fieldName . '  lon'] = $fieldName . '__lon';
1255        }
1256
1257        // "where"
1258        // @TODO - add handling for "HOLDS POINT NEAR"
1259        $matches = [];
1260        foreach ( $coordinateFields as $coordinateField ) {
1261            $fieldName = $coordinateField['fieldName'];
1262            $tableAlias = $coordinateField['tableAlias'];
1263            $patternSuffix = '(\s+NEAR\s*)\(([^)]*)\)/i';
1264
1265            $pattern1 = CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, false ) . $patternSuffix;
1266            $foundMatch1 = preg_match( $pattern1, $this->mWhereStr, $matches );
1267            if ( !$foundMatch1 ) {
1268                $pattern2 = CargoUtils::getSQLFieldPattern( $fieldName, false ) . $patternSuffix;
1269                $foundMatch2 = preg_match( $pattern2, $this->mWhereStr, $matches );
1270            }
1271            if ( $foundMatch1 || $foundMatch2 ) {
1272                // If no "NEAR", throw an error.
1273                if ( count( $matches ) != 4 ) {
1274                    throw new MWException( "Error: operator for the virtual coordinates field "
1275                    . "'$tableAlias.$fieldName' must be 'NEAR'." );
1276                }
1277                $coordinatesAndDistance = explode( ',', $matches[3] );
1278                if ( count( $coordinatesAndDistance ) != 3 ) {
1279                    throw new MWException( "Error: value for the 'NEAR' operator must be of the form "
1280                    . "\"(latitude, longitude, distance)\"." );
1281                }
1282                [ $latitude, $longitude, $distance ] = $coordinatesAndDistance;
1283                $distanceComponents = explode( ' ', trim( $distance ) );
1284                if ( count( $distanceComponents ) != 2 ) {
1285                    throw new MWException( "Error: the third argument for the 'NEAR' operator, "
1286                    . "representing the distance, must be of the form \"number unit\"." );
1287                }
1288                [ $distanceNumber, $distanceUnit ] = $distanceComponents;
1289                $distanceNumber = trim( $distanceNumber );
1290                $distanceUnit = trim( $distanceUnit );
1291                [ $latDistance, $longDistance ] = self::distanceToDegrees( $distanceNumber, $distanceUnit,
1292                        $latitude );
1293                // There are much better ways to do this, but
1294                // for now, just make a "bounding box" instead
1295                // of a bounding circle.
1296                $newWhere = " $tableAlias.{$fieldName}__lat >= " . max( $latitude - $latDistance, -90 ) .
1297                    " AND $tableAlias.{$fieldName}__lat <= " . min( $latitude + $latDistance, 90 ) .
1298                    " AND $tableAlias.{$fieldName}__lon >= " . max( $longitude - $longDistance, -180 ) .
1299                    " AND $tableAlias.{$fieldName}__lon <= " . min( $longitude + $longDistance, 180 ) . ' ';
1300
1301                if ( $foundMatch1 ) {
1302                    $this->mWhereStr = preg_replace( $pattern1, $newWhere, $this->mWhereStr );
1303                } elseif ( $foundMatch2 ) {
1304                    $this->mWhereStr = preg_replace( $pattern2, $newWhere, $this->mWhereStr );
1305                }
1306            }
1307        }
1308
1309        // "order by"
1310        // This one is simpler than the others - just add a "__full"
1311        // to each coordinates field in the "order by" clause.
1312        $matches = [];
1313        foreach ( $coordinateFields as $coordinateField ) {
1314            $fieldName = $coordinateField['fieldName'];
1315            $tableAlias = $coordinateField['tableAlias'];
1316
1317            $pattern1 = CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, true );
1318            $pattern2 = CargoUtils::getSQLFieldPattern( $fieldName, true );
1319            foreach ( $this->mOrderBy as &$orderByElem ) {
1320                $orderByElem = preg_replace( $pattern1, '$1' . "$tableAlias.$fieldName" . '__full$2', $orderByElem );
1321                $orderByElem = preg_replace( $pattern2, '$1' . $fieldName . '__full$2', $orderByElem );
1322            }
1323        }
1324    }
1325
1326    /**
1327     * Handles Hierarchy fields' "WHERE" operations
1328     */
1329    private function handleHierarchyFields() {
1330        // "where" - make sure that if
1331        // "WITHIN" (if not list) or "HOLDS WITHIN" (if list)
1332        // is specified, then translate the clause accordingly.
1333        // other translations in case of List fields,
1334        // are handled by handleVirtualFields().
1335
1336        // First, create an array of the hierarchy fields in the
1337        // current set of tables.
1338        $hierarchyFields = [];
1339        foreach ( $this->mTableSchemas as $tableName => $tableSchema ) {
1340            foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
1341                if ( !$fieldDescription->mIsHierarchy ) {
1342                    continue;
1343                }
1344                foreach ( $this->mAliasedTableNames as $tableAlias => $tableName2 ) {
1345                    if ( $tableName == $tableName2 ) {
1346                        $hierarchyFields[] = [
1347                            'fieldName' => $fieldName,
1348                            'tableAlias' => $tableAlias,
1349                            'tableName' => $tableName,
1350                            'isList' => $fieldDescription->mIsList
1351                        ];
1352                    }
1353                }
1354            }
1355        }
1356
1357        // "where"
1358        foreach ( $hierarchyFields as $hierarchyField ) {
1359            $fieldName = $hierarchyField['fieldName'];
1360            $tableName = $hierarchyField['tableName'];
1361            $tableAlias = $hierarchyField['tableAlias'];
1362            $fieldIsList = $hierarchyField['isList'];
1363
1364            $patternSimple = [
1365                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName ),
1366                CargoUtils::getSQLFieldPattern( $fieldName )
1367                ];
1368            $patternRootArray = [
1369                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, false ),
1370                CargoUtils::getSQLFieldPattern( $fieldName, false )
1371                ];
1372
1373            $simpleMatch = false;
1374            $completeMatch = false;
1375            $patternRoot = "";
1376
1377            if ( preg_match( $patternSimple[0], $this->mWhereStr ) ) {
1378                $simpleMatch = true;
1379                $patternRoot = $patternRootArray[0];
1380            } elseif ( preg_match( $patternSimple[1], $this->mWhereStr ) ) {
1381                $simpleMatch = true;
1382                $patternRoot = $patternRootArray[1];
1383            }
1384            // else we don't have current field in WHERE clause
1385
1386            if ( !$simpleMatch ) {
1387                continue;
1388            }
1389            $patternSuffix = '([\'"]?[^\'"]*[\'"]?)/i';  // To capture string in quotes or a number
1390            $hierarchyTable = $this->mCargoDB->tableName( $tableName . '__' . $fieldName . '__hierarchy' );
1391            $fieldTableName = $this->mCargoDB->tableName( $tableName . '__' . $fieldName );
1392            $completeSearchPattern = "";
1393            $matches = [];
1394            $newWhere = "";
1395            $leftFieldName = $this->mCargoDB->addIdentifierQuotes( "_left" );
1396            $rightFieldName = $this->mCargoDB->addIdentifierQuotes( "_right" );
1397            $valueFieldName = $this->mCargoDB->addIdentifierQuotes( "_value" );
1398
1399            if ( preg_match( $patternRoot . '(\s+HOLDS WITHIN\s+)' . $patternSuffix, $this->mWhereStr, $matches ) ) {
1400                if ( !$fieldIsList ) {
1401                    throw new MWException( "Error: \"HOLDS WITHIN\" cannot be used for single hierarchy field, use \"WITHIN\" instead." );
1402                }
1403                $completeMatch = true;
1404                $completeSearchPattern = $patternRoot . '(\s+HOLDS WITHIN\s+)' . $patternSuffix;
1405                if ( count( $matches ) != 4 || $matches[3] === "" ) {
1406                    throw new MWException( "Error: Please specify a value for \"HOLDS WITHIN\"" );
1407                }
1408                $withinValue = $matches[3];
1409                $subquery = "( SELECT $valueFieldName FROM $hierarchyTable WHERE " .
1410                    "$leftFieldName >= ( SELECT $leftFieldName FROM $hierarchyTable WHERE $valueFieldName = $withinValue ) AND " .
1411                    "$rightFieldName <= ( SELECT $rightFieldName FROM $hierarchyTable WHERE $valueFieldName = $withinValue ) " .
1412                    ")";
1413                $subquery = "( SELECT DISTINCT( " . $this->mCargoDB->addIdentifierQuotes( "_rowID" ) . " ) FROM $fieldTableName WHERE $valueFieldName IN " . $subquery . " )";
1414                $newWhere = " " . $tableName . "._ID" . " IN " . $subquery;
1415            }
1416
1417            if ( preg_match( $patternRoot . '(\s+WITHIN\s+)' . $patternSuffix, $this->mWhereStr, $matches ) ) {
1418                if ( $fieldIsList ) {
1419                    throw new MWException( "Error: \"WITHIN\" cannot be used for list hierarchy field, use \"HOLDS WITHIN\" instead." );
1420                }
1421                $completeMatch = true;
1422                $completeSearchPattern = $patternRoot . '(\s+WITHIN\s+)' . $patternSuffix;
1423                if ( count( $matches ) != 4 || $matches[3] === "" ) {
1424                    throw new MWException( "Error: Please specify a value for \"WITHIN\"" );
1425                }
1426                $withinValue = $matches[3];
1427                $subquery = "( SELECT $valueFieldName FROM $hierarchyTable WHERE " .
1428                    "$leftFieldName >= ( SELECT $leftFieldName FROM $hierarchyTable WHERE $valueFieldName = $withinValue ) AND " .
1429                    "$rightFieldName <= ( SELECT $rightFieldName FROM $hierarchyTable WHERE $valueFieldName = $withinValue ) " .
1430                    ")";
1431                $newWhere = " " . $fieldName . " IN " . $subquery;
1432            }
1433
1434            if ( $completeMatch ) {
1435                $this->mWhereStr = preg_replace( $completeSearchPattern, $newWhere, $this->mWhereStr );
1436            }
1437
1438            // In case fieldIsList === true, there is a possibility of more Query commands.
1439            // like "HOLDS" and "HOLDS LIKE", that is handled by handleVirtualFields()
1440        }
1441    }
1442
1443    /**
1444     * Returns the number of degrees of both latitude and longitude that
1445     * correspond to the passed-in distance (in either kilometers or
1446     * miles), based on the passed-in latitude. (Longitude doesn't matter
1447     * when doing this conversion, but latitude does.)
1448     */
1449    private static function distanceToDegrees( $distanceNumber, $distanceUnit, $latString ) {
1450        if ( in_array( $distanceUnit, [ 'kilometers', 'kilometres', 'km' ] ) ) {
1451            $distanceInKM = $distanceNumber;
1452        } elseif ( in_array( $distanceUnit, [ 'miles', 'mi' ] ) ) {
1453            $distanceInKM = $distanceNumber * 1.60934;
1454        } else {
1455            throw new MWException( "Error: distance for 'NEAR' operator must be in either miles or "
1456            . "kilometers (\"$distanceUnit\" specified)." );
1457        }
1458        // The calculation of distance to degrees latitude is
1459        // essentially the same wherever you are on the globe, although
1460        // the longitude calculation is more complicated.
1461        $latDistance = $distanceInKM / 111;
1462
1463        // Convert the latitude string to a latitude number - code is
1464        // copied from CargoUtils::parseCoordinatesString().
1465        $latIsNegative = false;
1466        if ( strpos( $latString, 'S' ) > 0 ) {
1467            $latIsNegative = true;
1468        }
1469        $latString = str_replace( [ 'N', 'S' ], '', $latString );
1470        if ( is_numeric( $latString ) ) {
1471            $latNum = floatval( $latString );
1472        } else {
1473            $latNum = CargoUtils::coordinatePartToNumber( $latString );
1474        }
1475        if ( $latIsNegative ) {
1476            $latNum *= -1;
1477        }
1478
1479        $lengthOfOneDegreeLongitude = cos( deg2rad( $latNum ) ) * 111.321;
1480        $longDistance = $distanceInKM / $lengthOfOneDegreeLongitude;
1481
1482        return [ $latDistance, $longDistance ];
1483    }
1484
1485    /**
1486     * For each date field, also add its corresponding "precisicon"
1487     * field (which indicates whether the date is year-only, etc.) to
1488     * the query.
1489     */
1490    public function handleDateFields() {
1491        $dateFields = [];
1492        foreach ( $this->mOrigAliasedFieldNames as $alias => $fieldName ) {
1493            if ( !array_key_exists( $alias, $this->mFieldDescriptions ) ) {
1494                continue;
1495            }
1496            $fieldDescription = $this->mFieldDescriptions[$alias];
1497            if ( ( $fieldDescription->mType == 'Date' || $fieldDescription->mType == 'Datetime' ||
1498                   $fieldDescription->mType == 'Start date' || $fieldDescription->mType == 'Start datetime' ||
1499                   $fieldDescription->mType == 'End date' || $fieldDescription->mType == 'End datetime' ) &&
1500                // Make sure this is an actual field and not a call
1501                // to a function, like DATE_FORMAT(), by checking for
1502                // the presence of '(' and ')' - there's probably a
1503                // more elegant way to do this.
1504                ( strpos( $fieldName, '(' ) == false ) && ( strpos( $fieldName, ')' ) == false ) ) {
1505                $dateFields[$alias] = $fieldName;
1506            }
1507        }
1508        foreach ( $dateFields as $alias => $dateField ) {
1509            // Handle fields that are a list of dates.
1510            if ( substr( $dateField, -6 ) == '__full' ) {
1511                $dateField = substr( $dateField, 0, -6 );
1512            }
1513            $precisionFieldName = $dateField . '__precision';
1514            $precisionFieldAlias = $alias . '__precision';
1515            $this->mAliasedFieldNames[$precisionFieldAlias] = $precisionFieldName;
1516        }
1517    }
1518
1519    private function handleSearchTextFields() {
1520        $searchTextFields = [];
1521        foreach ( $this->mTableSchemas as $tableName => $tableSchema ) {
1522            foreach ( $tableSchema->mFieldDescriptions as $fieldName => $fieldDescription ) {
1523                if ( $fieldDescription->mType != 'Searchtext' ) {
1524                    continue;
1525                }
1526                $fieldAlias = array_search( $fieldName, $this->mAliasedFieldNames );
1527                if ( $fieldAlias === false ) {
1528                    $tableAlias = array_search( $tableName, $this->mAliasedTableNames );
1529                    $fieldAlias = array_search( "$tableAlias.$fieldName", $this->mAliasedFieldNames );
1530                }
1531                if ( $fieldAlias === false ) {
1532                    $fieldAlias = $fieldName;
1533                }
1534                $searchTextFields[] = [
1535                    'fieldName' => $fieldName,
1536                    'fieldAlias' => $fieldAlias,
1537                    'tableName' => $tableName
1538                ];
1539            }
1540        }
1541
1542        $matches = [];
1543        foreach ( $searchTextFields as $searchTextField ) {
1544            $fieldName = $searchTextField['fieldName'];
1545            $fieldAlias = $searchTextField['fieldAlias'];
1546            $tableName = $searchTextField['tableName'];
1547            $tableAlias = array_search( $tableName, $this->mAliasedTableNames );
1548            $patternSuffix = '(\s+MATCHES\s*)([\'"][^\'"]*[\'"])/i';
1549            $patternSuffix1 = '(\s+MATCHES\s*)(\'[^\']*\')/i';
1550            $patternSuffix2 = '(\s+MATCHES\s*)("[^"]*")/i';
1551
1552            $patterns = [
1553                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, false ) . $patternSuffix1,
1554                CargoUtils::getSQLFieldPattern( $fieldName, false ) . $patternSuffix1,
1555                CargoUtils::getSQLTableAndFieldPattern( $tableAlias, $fieldName, false ) . $patternSuffix2,
1556                CargoUtils::getSQLFieldPattern( $fieldName, false ) . $patternSuffix2
1557            ];
1558            $matchingPattern = null;
1559            foreach ( $patterns as $i => $pattern ) {
1560                $foundMatch = preg_match( $pattern, $this->mWhereStr, $matches );
1561                if ( $foundMatch ) {
1562                    $matchingPattern = $i;
1563                    break;
1564                }
1565            }
1566
1567            if ( $foundMatch ) {
1568                $searchString = $matches[3];
1569                $newWhere = " MATCH($tableAlias.$fieldName) AGAINST ($searchString IN BOOLEAN MODE) ";
1570
1571                $pattern = $patterns[$matchingPattern];
1572                $this->mWhereStr = preg_replace( $pattern, $newWhere, $this->mWhereStr );
1573                $searchEngine = new CargoSearchMySQL();
1574                $searchTerms = $searchEngine->getSearchTerms( $searchString );
1575                // @TODO - does $tableName need to be in there?
1576                $this->mSearchTerms[$fieldAlias] = $searchTerms;
1577            }
1578        }
1579    }
1580
1581    /**
1582     * Adds the "cargo" table prefix for every element in the SQL query
1583     * except for 'tables' and 'join on' - for 'tables', the prefix is
1584     * prepended automatically by the MediaWiki query, while for
1585     * 'join on' the prefixes are added when the object is created.
1586     */
1587    private function addTablePrefixesToAll() {
1588        foreach ( $this->mAliasedFieldNames as $alias => $fieldName ) {
1589            $this->mAliasedFieldNames[$alias] = $this->addTablePrefixes( $fieldName );
1590        }
1591        $this->mWhereStr = $this->addTablePrefixes( $this->mWhereStr );
1592        $this->mGroupByStr = $this->addTablePrefixes( $this->mGroupByStr );
1593        $this->mHavingStr = $this->addTablePrefixes( $this->mHavingStr );
1594        foreach ( $this->mOrderBy as &$orderByElem ) {
1595            $orderByElem = $this->addTablePrefixes( $orderByElem );
1596        }
1597    }
1598
1599    /**
1600     * Calls a database SELECT query given the parts of the query; first
1601     * appending the Cargo prefix onto table names where necessary.
1602     */
1603    public function run() {
1604        foreach ( $this->mAliasedTableNames as $tableName ) {
1605            if ( !$this->mCargoDB->tableExists( $tableName, __METHOD__ ) ) {
1606                throw new MWException( wfMessage( "cargo-query-unknowndbtable", $tableName )->parse() );
1607            }
1608        }
1609
1610        $selectOptions = [];
1611
1612        if ( $this->mGroupByStr != '' ) {
1613            $selectOptions['GROUP BY'] = $this->mGroupByStr;
1614        }
1615        if ( $this->mHavingStr != '' ) {
1616            $selectOptions['HAVING'] = $this->mHavingStr;
1617        }
1618
1619        $selectOptions['ORDER BY'] = $this->mOrderBy;
1620        $selectOptions['LIMIT'] = $this->mQueryLimit;
1621        $selectOptions['OFFSET'] = $this->mOffset;
1622
1623        // Aliases need to be surrounded by quotes when we actually
1624        // call the DB query.
1625        $realAliasedFieldNames = [];
1626        foreach ( $this->mAliasedFieldNames as $alias => $fieldName ) {
1627            // If it's either a field, or a table + field,
1628            // add quotes around the name(s).
1629            if ( strpos( $fieldName, '(' ) === false ) {
1630                if ( strpos( $fieldName, '.' ) === false ) {
1631                    if ( !$this->mCargoDB->isQuotedIdentifier( $fieldName ) && !CargoUtils::isSQLStringLiteral( $fieldName ) ) {
1632                        $fieldName = $this->mCargoDB->addIdentifierQuotes( $fieldName );
1633                    }
1634                } else {
1635                    [ $realTableName, $realFieldName ] = explode( '.', $fieldName, 2 );
1636                    if ( !$this->mCargoDB->isQuotedIdentifier( $realTableName ) && !CargoUtils::isSQLStringLiteral( $realTableName ) ) {
1637                        $realTableName = $this->mCargoDB->addIdentifierQuotes( $realTableName );
1638                    }
1639                    if ( !$this->mCargoDB->isQuotedIdentifier( $realFieldName ) && !CargoUtils::isSQLStringLiteral( $realFieldName ) ) {
1640                        $realFieldName = $this->mCargoDB->addIdentifierQuotes( $realFieldName );
1641                    }
1642                    $fieldName = "$realTableName.$realFieldName";
1643                }
1644            }
1645            $realAliasedFieldNames[$alias] = $fieldName;
1646        }
1647
1648        $res = $this->mCargoDB->select( $this->mAliasedTableNames, $realAliasedFieldNames, $this->mWhereStr, __METHOD__,
1649            $selectOptions, $this->mJoinConds );
1650
1651        // Is there a more straightforward way of turning query
1652        // results into an array?
1653        $resultArray = [];
1654        foreach ( $res as $row ) {
1655            $resultsRow = [];
1656            foreach ( $this->mAliasedFieldNames as $alias => $fieldName ) {
1657                if ( !isset( $row->$alias ) ) {
1658                    $resultsRow[$alias] = null;
1659                    continue;
1660                }
1661
1662                $curValue = $row->$alias;
1663                if ( $curValue instanceof DateTime ) {
1664                    // @TODO - This code may no longer be necessary.
1665                    $resultsRow[$alias] = $curValue->format( DateTime::W3C );
1666                } else {
1667                    // It's a string.
1668                    // Escape any HTML, to avoid JavaScript
1669                    // injections and the like.
1670                    $resultsRow[$alias] = htmlspecialchars( $curValue );
1671                }
1672            }
1673            $resultArray[] = $resultsRow;
1674        }
1675
1676        return $resultArray;
1677    }
1678
1679    private function addTablePrefixes( $string ) {
1680        if ( $string === null ) {
1681            return null;
1682        }
1683
1684        // Create arrays for doing replacements of table names within
1685        // the SQL by their "real" equivalents.
1686        $tableNamePatterns = [];
1687        foreach ( $this->mAliasedTableNames as $alias => $tableName ) {
1688            $tableNamePatterns[] = CargoUtils::getSQLTablePattern( $tableName );
1689            $tableNamePatterns[] = CargoUtils::getSQLTablePattern( $alias );
1690        }
1691
1692        return preg_replace_callback( $tableNamePatterns,
1693            [ $this, 'addQuotes' ], $string );
1694    }
1695
1696    private function addQuotes( $matches ) {
1697        $beforeText = $matches[1];
1698        $tableName = $matches[2];
1699        $fieldName = $matches[3];
1700        $oldAliasing = self::mwUsesOldDBAliasing();
1701        if ( $oldAliasing ) {
1702            $isTableAlias = false;
1703            if ( array_key_exists( $tableName, $this->mAliasedTableNames ) ) {
1704                if ( !in_array( $tableName, $this->mAliasedTableNames ) ) {
1705                    $isTableAlias = true;
1706                }
1707            }
1708        } else {
1709            $isTableAlias = true;
1710        }
1711        if ( $isTableAlias ) {
1712            return $beforeText . $this->mCargoDB->addIdentifierQuotes( $tableName ) . "." .
1713                   $this->mCargoDB->addIdentifierQuotes( $fieldName );
1714        } else {
1715            return $beforeText . $this->mCargoDB->tableName( $tableName ) . "." .
1716                   $this->mCargoDB->addIdentifierQuotes( $fieldName );
1717        }
1718    }
1719
1720    /**
1721     * Figure out which fields, if any, in this query are supposed to
1722     * represent start and end dates, based on a combination of field types,
1723     * order (start is expected to be listed before end) and alias.
1724     * @todo - this logic currently allows for any number of start/end
1725     * date pairs, but that may be overly complicated - it may be safe to
1726     * assume that any query contains no more than one start date and end
1727     * date, and any other dates can just be ignored, i.e. treated as
1728     * display fields.
1729     */
1730    public function determineDateFields() {
1731        foreach ( $this->mFieldDescriptions as $alias => $description ) {
1732            $realFieldName = $this->mAliasedFieldNames[$alias] ?? $alias;
1733            $curNameAndAlias = [ $realFieldName, $alias ];
1734            if ( $alias == 'start' || $description->mType == 'Start date' || $description->mType == 'Start datetime' ) {
1735                $foundMatch = false;
1736                foreach ( $this->mDateFieldPairs as &$datePair ) {
1737                    if ( array_key_exists( 'end', $datePair ) && !array_key_exists( 'start', $datePair ) ) {
1738                        $datePair['start'] = $curNameAndAlias;
1739                        $foundMatch = true;
1740                        break;
1741                    }
1742                }
1743                if ( !$foundMatch ) {
1744                    $this->mDateFieldPairs[] = [ 'start' => $curNameAndAlias ];
1745                }
1746            } elseif ( $alias == 'end' || $description->mType == 'End date' || $description->mType == 'End datetime' ) {
1747                $foundMatch = false;
1748                foreach ( $this->mDateFieldPairs as &$datePair ) {
1749                    if ( array_key_exists( 'start', $datePair ) && !array_key_exists( 'end', $datePair ) ) {
1750                        $datePair['end'] = $curNameAndAlias;
1751                        $foundMatch = true;
1752                        break;
1753                    }
1754                }
1755                if ( !$foundMatch ) {
1756                    $this->mDateFieldPairs[] = [ 'end' => $curNameAndAlias ];
1757                }
1758            } elseif ( $description->mType == 'Date' || $description->mType == 'Datetime' ) {
1759                $foundMatch = false;
1760                foreach ( $this->mDateFieldPairs as &$datePair ) {
1761                    if ( array_key_exists( 'end', $datePair ) && !array_key_exists( 'start', $datePair ) ) {
1762                        $datePair['start'] = $curNameAndAlias;
1763                        $foundMatch = true;
1764                        break;
1765                    } elseif ( array_key_exists( 'start', $datePair ) && !array_key_exists( 'end', $datePair ) ) {
1766                        $datePair['end'] = $curNameAndAlias;
1767                        $foundMatch = true;
1768                        break;
1769                    }
1770                }
1771                if ( !$foundMatch ) {
1772                    $this->mDateFieldPairs[] = [ 'start' => $curNameAndAlias ];
1773                }
1774            }
1775        }
1776
1777        // Error-checking.
1778        if ( count( $this->mDateFieldPairs ) == 0 ) {
1779            throw new MWException( "Error: No date fields were found in this query." );
1780        }
1781        foreach ( $this->mDateFieldPairs as $datePair ) {
1782            if ( !array_key_exists( 'start', $datePair ) ) {
1783                throw new MWException( "Error: No corresponding start date field was found for the end date field {$datePair['end'][0]}." );
1784            }
1785        }
1786    }
1787
1788    public function getMainStartAndEndDateFields() {
1789        if ( count( $this->mDateFieldPairs ) == 0 ) {
1790            $this->determineDateFields();
1791        }
1792        $firstFieldPair = $this->mDateFieldPairs[0];
1793        $startDateField = $firstFieldPair['start'][1];
1794        $endDateField = ( array_key_exists( 'end', $firstFieldPair ) ) ? $firstFieldPair['end'][1] : null;
1795        return [ $startDateField, $endDateField ];
1796    }
1797
1798    /**
1799     * See if a query does aggregation - used to determine whether a
1800     * "backlinks" element should be added to the query. (It doesn't make
1801     * sense for aggregating queries.)
1802     * This is somewhat redundant code, because all of these fields and
1803     * their functions have been parsed already, but it seemed easier to
1804     * just do this specific re-parse.
1805     */
1806    public function isAggregating() {
1807        if ( $this->mGroupByStr != '' ) {
1808            return true;
1809        }
1810        foreach ( $this->mOrigAliasedFieldNames as $alias => $field ) {
1811            $sqlFunctionMatch = [];
1812            $sqlFunctionRegex = '/(\b|\W)(\w*?)\s*\(/';
1813            preg_match( $sqlFunctionRegex, $field, $sqlFunctionMatch );
1814            if ( count( $sqlFunctionMatch ) > 1 ) {
1815                $firstFunction = strtoupper( trim( $sqlFunctionMatch[2] ) );
1816                if ( in_array( $firstFunction, [ 'COUNT', 'AVG', 'MAX', 'MIN' ] ) ) {
1817                    return true;
1818                }
1819            }
1820        }
1821        return false;
1822    }
1823
1824}