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