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