Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PFAutocompleteAPI
0.00% covered (danger)
0.00%
0 / 275
0.00% covered (danger)
0.00%
0 / 11
4422
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
 execute
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
702
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 getParamDescription
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExamples
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getAllValuesForProperty
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 computeAllValuesForProperty
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
110
 getAllValuesForCargoField
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 computeAllValuesForCargoField
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
272
 sortValuesByLength
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * @file
4 * @ingroup PF
5 */
6
7use MediaWiki\Language\RawMessage;
8
9/**
10 * Adds and handles the 'pfautocomplete' action to the MediaWiki API.
11 *
12 * @ingroup PF
13 *
14 * @author Sergey Chernyshev
15 * @author Yaron Koren
16 */
17class PFAutocompleteAPI extends ApiBase {
18
19    public function __construct( $query, $moduleName ) {
20        parent::__construct( $query, $moduleName );
21    }
22
23    public function execute() {
24        $params = $this->extractRequestParams();
25        $substr = $params['substr'];
26        $namespace = $params['namespace'];
27        $property = $params['property'];
28        $category = $params['category'];
29        $wikidata = $params['wikidata'];
30        $concept = $params['concept'];
31        $query = $params['semantic_query'];
32        $cargo_table = $params['cargo_table'];
33        $cargo_field = $params['cargo_field'];
34        $cargo_where = $params['cargo_where'];
35        $external_url = $params['external_url'];
36        $baseprop = $params['baseprop'];
37        $base_cargo_table = $params['base_cargo_table'];
38        $base_cargo_field = $params['base_cargo_field'];
39        $basevalue = $params['basevalue'];
40        // $limit = $params['limit'];
41
42        if ( $baseprop === null && $base_cargo_table === null && strlen( $substr ) == 0 ) {
43            $this->dieWithError( [ 'apierror-missingparam', 'substr' ], 'param_substr' );
44        }
45
46        global $wgPageFormsUseDisplayTitle;
47        $map = false;
48        if ( $baseprop !== null ) {
49            if ( $property !== null ) {
50                $data = $this->getAllValuesForProperty( $property, null, $baseprop, $basevalue );
51            }
52        } elseif ( $property !== null ) {
53            $data = $this->getAllValuesForProperty( $property, $substr );
54        } elseif ( $wikidata !== null ) {
55            $data = PFValuesUtils::getAllValuesFromWikidata( urlencode( $wikidata ), $substr );
56        } elseif ( $category !== null ) {
57            $data = PFValuesUtils::getAllPagesForCategory( $category, 3, $substr );
58            $map = $wgPageFormsUseDisplayTitle;
59            if ( $map ) {
60                $data = PFMappingUtils::createDisplayTitleLabels( $data );
61            }
62        } elseif ( $concept !== null ) {
63            $data = PFValuesUtils::getAllPagesForConcept( $concept, $substr );
64            $map = $wgPageFormsUseDisplayTitle;
65            if ( $map ) {
66                $data = PFMappingUtils::createDisplayTitleLabels( $data );
67            }
68        } elseif ( $query !== null ) {
69            $query = PFValuesUtils::processSemanticQuery( $query, $substr );
70            $data = PFValuesUtils::getAllPagesForQuery( $query );
71            $map = $wgPageFormsUseDisplayTitle;
72            if ( $map ) {
73                $data = PFMappingUtils::createDisplayTitleLabels( $data );
74            }
75        } elseif ( $cargo_table !== null && $cargo_field !== null ) {
76            $data = self::getAllValuesForCargoField( $cargo_table, $cargo_field, $cargo_where, $substr, $base_cargo_table, $base_cargo_field, $basevalue );
77        } elseif ( $namespace !== null ) {
78            $data = PFValuesUtils::getAllPagesForNamespace( $namespace, $substr );
79            $map = $wgPageFormsUseDisplayTitle;
80            if ( $map ) {
81                $data = PFMappingUtils::createDisplayTitleLabels( $data );
82            }
83        } elseif ( $external_url !== null ) {
84            $data = PFValuesUtils::getValuesFromExternalURL( $external_url, $substr );
85            $map = true;
86        } else {
87            $data = [];
88        }
89
90        // If we got back an error message, exit with that message.
91        if ( !is_array( $data ) ) {
92            if ( is_callable( [ $this, 'dieWithError' ] ) ) {
93                if ( !$data instanceof Message ) {
94                    $data = ApiMessage::create( new RawMessage( '$1', [ $data ] ), 'unknownerror' );
95                }
96                $this->dieWithError( $data );
97            } else {
98                $code = 'unknownerror';
99                if ( $data instanceof Message ) {
100                    $code = $data instanceof IApiMessage ? $data->getApiCode() : $data->getKey();
101                    $data = $data->inLanguage( 'en' )->useDatabase( false )->text();
102                }
103                $this->dieWithError( $data, $code );
104            }
105        }
106        // Sort the values by their lengths for better UX
107        $data = self::sortValuesByLength( $data );
108
109        // to prevent JS parsing problems, display should be the same
110        // even if there are no results
111        /*
112        if ( count( $data ) <= 0 ) {
113            return;
114        }
115        */
116
117        // Format data as the API requires it - in the case of "values
118        // from url", it's a little odd because we are re-adding the
119        // "title" key after having removed it, but the removal was
120        // needed for the sorting.
121        $formattedData = [];
122        foreach ( $data as $key => $value ) {
123            if ( $map ) {
124                $formattedData[] = [ 'title' => $key, 'displaytitle' => $value ];
125            } else {
126                $formattedData[] = [ 'title' => $value ];
127            }
128        }
129
130        // Set top-level elements.
131        $result = $this->getResult();
132        $result->setIndexedTagName( $formattedData, 'p' );
133        $result->addValue( null, $this->getModuleName(), $formattedData );
134    }
135
136    protected function getAllowedParams() {
137        return [
138            'limit' => [
139                ApiBase::PARAM_TYPE => 'limit',
140                ApiBase::PARAM_DFLT => 10,
141                ApiBase::PARAM_MIN => 1,
142                ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
143                ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
144            ],
145            'substr' => null,
146            'property' => null,
147            'category' => null,
148            'concept' => null,
149            'wikidata' => null,
150            'semantic_query' => null,
151            'cargo_table' => null,
152            'cargo_field' => null,
153            'cargo_where' => null,
154            'namespace' => null,
155            'external_url' => null,
156            'baseprop' => null,
157            'base_cargo_table' => null,
158            'base_cargo_field' => null,
159            'basevalue' => null,
160        ];
161    }
162
163    protected function getParamDescription() {
164        return [
165            'substr' => 'Search substring',
166            'property' => 'Semantic property for which to search values',
167            'category' => 'Category for which to search values',
168            'concept' => 'Concept for which to search values',
169            'wikidata' => 'Search string for getting values from wikidata',
170            'semantic_query' => 'Query for which to search values',
171            'namespace' => 'Namespace for which to search values',
172            'external_url' => 'Alias for external URL from which to get values',
173            'baseprop' => 'A previous property in the form to check against',
174            'basevalue' => 'The value to check for the previous property',
175            // 'limit' => 'Limit how many entries to return',
176        ];
177    }
178
179    protected function getDescription() {
180        return 'Autocompletion call used by the Page Forms extension (https://www.mediawiki.org/Extension:Page_Forms)';
181    }
182
183    protected function getExamples() {
184        return [
185            'api.php?action=pfautocomplete&substr=te',
186            'api.php?action=pfautocomplete&substr=te&property=Has_author',
187            'api.php?action=pfautocomplete&substr=te&category=Authors',
188            'api.php?action=pfautocomplete&semantic_query=((Category:Test)) ((MyProperty::Something))',
189        ];
190    }
191
192    private function getAllValuesForProperty(
193        $property_name,
194        $substring,
195        $basePropertyName = null,
196        $baseValue = null
197    ) {
198        global $wgPageFormsCacheAutocompleteValues, $wgPageFormsAutocompleteCacheTimeout;
199        global $smwgDefaultStore;
200
201        if ( $smwgDefaultStore == null ) {
202            $this->dieWithError( 'Semantic MediaWiki must be installed to query on "property"', 'param_property' );
203        }
204
205        $property_name = str_replace( ' ', '_', $property_name );
206
207        // Use cache if allowed
208        if ( !$wgPageFormsCacheAutocompleteValues ) {
209            return $this->computeAllValuesForProperty( $property_name, $substring, $basePropertyName, $baseValue );
210        }
211
212        $cache = PFFormUtils::getFormCache();
213        // Remove trailing whitespace to avoid unnecessary database selects
214        $cacheKeyString = $property_name . '::' . rtrim( $substring );
215        if ( $basePropertyName !== null ) {
216            $cacheKeyString .= ',' . $basePropertyName . ',' . $baseValue;
217        }
218        $cacheKey = $cache->makeKey( 'pf-autocomplete', md5( $cacheKeyString ) );
219        return $cache->getWithSetCallback(
220            $cacheKey,
221            $wgPageFormsAutocompleteCacheTimeout,
222            function () use ( $property_name, $substring, $basePropertyName, $baseValue ) {
223                return $this->computeAllValuesForProperty( $property_name, $substring, $basePropertyName, $baseValue );
224            }
225        );
226    }
227
228    /**
229     * @param string $property_name
230     * @param string $substring
231     * @param string|null $basePropertyName
232     * @param mixed $baseValue
233     * @return array
234     */
235    private function computeAllValuesForProperty(
236        $property_name,
237        $substring,
238        $basePropertyName = null,
239        $baseValue = null
240    ) {
241        $db = PFUtils::getReadDB();
242        $tables = [];
243        $joinConds = [];
244        $sqlOptions = [
245            'LIMIT' => PFValuesUtils::getMaxValuesToRetrieve( $substring )
246        ];
247
248        $property = SMW\DataValueFactory::getInstance()->newPropertyValueByLabel( $property_name );
249        $propertyHasTypePage = ( $property->getPropertyTypeID() == '_wpg' );
250        $store = smwfGetStore();
251        if ( $store instanceof SMW\SQLStore\SQLStore ) {
252            $inceptiveProperty = $property->getInceptiveProperty();
253            $propertyTableId = $store->findPropertyTableID( $inceptiveProperty );
254            $isFixedProperty = preg_match( '/smw_fpt_/', $propertyTableId );
255        } else {
256            $isFixedProperty = false;
257        }
258
259        $idsTable = 'smw_object_ids';
260        $tables['p_ids'] = $idsTable;
261
262        if ( $isFixedProperty ) {
263            $propsTable = $propertyTableId;
264        } else {
265            $conditions = [ 'p_ids.smw_title' => $property_name ];
266            if ( $propertyHasTypePage ) {
267                $propsTable = 'smw_di_wikipage';
268            } else {
269                $propsTable = 'smw_di_blob';
270            }
271
272        }
273        $tables['p'] = $propsTable;
274        $joinConds['p_ids'] = [ 'JOIN', 'p.p_id = p_ids.smw_id' ];
275
276        if ( $propertyHasTypePage ) {
277            $tables['o_ids'] = $idsTable;
278            $joinConds['o_ids'] = [ 'JOIN', 'p.o_id = o_ids.smw_id' ];
279            $valueField = 'o_ids.smw_title';
280        } else {
281            $valueField = 'p.o_hash';
282        }
283
284        if ( $basePropertyName !== null ) {
285            $baseProperty = SMW\DataValueFactory::getInstance()->newPropertyValueByLabel( $basePropertyName );
286            $basePropertyHasTypePage = ( $baseProperty->getPropertyTypeID() == '_wpg' );
287
288            $basePropertyName = str_replace( ' ', '_', $basePropertyName );
289            $conditions['base_p_ids.smw_title'] = $basePropertyName;
290            if ( $basePropertyHasTypePage ) {
291                $propsTable = 'smw_di_wikipage';
292                $baseValue = str_replace( ' ', '_', $baseValue );
293            } else {
294                $propsTable = 'smw_di_blob';
295                $conditions['p_base.o_hash'] = $baseValue;
296            }
297            $tables['p_base'] = $propsTable;
298            $joinConds['p_base'] = [ 'JOIN', 'p.s_id = p_base.s_id' ];
299            $tables['base_p_ids'] = $idsTable;
300            $joinConds['base_p_ids'] = [ 'JOIN', 'p_base.p_id = base_p_ids.smw_id' ];
301            if ( $basePropertyHasTypePage ) {
302                $tables['base_o_ids'] = $idsTable;
303                $joinConds['base_o_ids'] = [ 'JOIN', 'p_base.o_id = base_o_ids.smw_id' ];
304                $conditions['base_o_ids.smw_title'] = $baseValue;
305            }
306        }
307
308        if ( $substring !== null ) {
309            // "Page" type property values are stored differently
310            // in the DB, i.e. underlines instead of spaces.
311            $conditions[] = PFValuesUtils::getSQLConditionForAutocompleteInColumn( $valueField, $substring, $propertyHasTypePage );
312        }
313
314        $sqlOptions['ORDER BY'] = $valueField;
315        $res = $db->select( $tables, "DISTINCT $valueField",
316            $conditions, __METHOD__, $sqlOptions, $joinConds );
317
318        $values = [];
319        while ( $row = $res->fetchRow() ) {
320            $values[] = str_replace( '_', ' ', $row[0] );
321        }
322        $res->free();
323        return $values;
324    }
325
326    private static function getAllValuesForCargoField( $cargoTable, $cargoField, $cargoWhere, $substring, $baseCargoTable = null, $baseCargoField = null, $baseValue = null ) {
327        global $wgPageFormsCacheAutocompleteValues, $wgPageFormsAutocompleteCacheTimeout;
328
329        if ( !$wgPageFormsCacheAutocompleteValues ) {
330            return self::computeAllValuesForCargoField(
331                $cargoTable, $cargoField, $cargoWhere, $substring, $baseCargoTable, $baseCargoField, $baseValue );
332        }
333
334        $cache = PFFormUtils::getFormCache();
335        // Remove trailing whitespace to avoid unnecessary database selects
336        $cacheKeyString = $cargoTable . '|' . $cargoField . '|' . rtrim( $substring );
337        if ( $baseCargoTable !== null ) {
338            $cacheKeyString .= '|' . $baseCargoTable . '|' . $baseCargoField . '|' . $baseValue;
339        }
340        $cacheKey = $cache->makeKey( 'pf-autocomplete', md5( $cacheKeyString ) );
341        return $cache->getWithSetCallback(
342            $cacheKey,
343            $wgPageFormsAutocompleteCacheTimeout,
344            function () use ( $cargoTable, $cargoField, $cargoWhere, $substring, $baseCargoTable, $baseCargoField, $baseValue ) {
345                return self::computeAllValuesForCargoField(
346                    $cargoTable, $cargoField, $cargoWhere, $substring, $baseCargoTable, $baseCargoField, $baseValue );
347            }
348        );
349    }
350
351    private static function computeAllValuesForCargoField(
352        $cargoTable,
353        $cargoField,
354        $cargoWhere,
355        $substring,
356        $baseCargoTable,
357        $baseCargoField,
358        $baseValue
359    ) {
360        global $wgPageFormsAutocompleteOnAllChars;
361
362        $tablesStr = $cargoTable;
363        $fieldsStr = $cargoField;
364        $joinOnStr = '';
365        $whereStr = '';
366
367        if ( $cargoWhere !== null ) {
368            $whereStr = '(' . stripslashes( $cargoWhere ) . ')';
369        }
370
371        if ( $baseCargoTable !== null && $baseCargoField !== null ) {
372            if ( $whereStr != '' ) {
373                $whereStr .= " AND ";
374            }
375            if ( $baseCargoTable != $cargoTable ) {
376                $tablesStr .= "$baseCargoTable";
377                $joinOnStr = "$cargoTable._pageName = $baseCargoTable._pageName";
378            }
379            $whereStr .= "$baseCargoTable.$baseCargoField = \"$baseValue\"";
380        }
381
382        if ( $substring !== null ) {
383            if ( $whereStr != '' ) {
384                $whereStr .= " AND ";
385            }
386            // @TODO - this is duplicate work; the schema is retrieved
387            // again when the CargoSQLQuery object is created. There should
388            // be some way of avoiding that duplicate retrieval.
389            $fieldDesc = PFUtils::getCargoFieldDescription( $cargoTable, $cargoField );
390
391            if ( $fieldDesc !== null && $fieldDesc->mIsList ) {
392                // If it's a list field, we query directly on
393                // the "helper table" for that field. We could
394                // instead use "HOLDS LIKE", but this would
395                // return false positives - other values that
396                // have been listed alongside the values we're
397                // looking for - at least for Cargo >= 2.6.
398                $fieldTableName = $cargoTable . '__' . $cargoField;
399                // Because of the way Cargo querying works, the
400                // field table has to be listed first for only
401                // the right values to show up.
402                $tablesStr = $fieldTableName . ', ' . $tablesStr;
403                if ( $joinOnStr != '' ) {
404                    $joinOnStr = ', ' . $joinOnStr;
405                }
406                $joinOnStr = $fieldTableName . '._rowID=' .
407                    $cargoTable . '._ID' . $joinOnStr;
408
409                $fieldsStr = $cargoField = '_value';
410            }
411
412            $cdb = CargoUtils::getDB();
413            $quotedCargoField = $cdb->addIdentifierQuotes( $cargoField );
414
415            // LIKE is almost always case-insensitive for MySQL,
416            // usually (?) case-sensitive for PostgreSQL, and
417            // case-insensitive (though only for ASCII characters)
418            // in SQLite. In order to make this check consistenly
419            // case-sensitive everywhere, we call LOWER() on all
420            // the fields. There are other ways to accomplish this,
421            // but this one works consistently across the different
422            // DB systems.
423            if ( $wgPageFormsAutocompleteOnAllChars ) {
424                $whereStr .= "(LOWER($quotedCargoField) LIKE LOWER('%$substring%'))";
425            } else {
426                $whereStr .= "(LOWER($quotedCargoField) LIKE LOWER('$substring%')";
427                // Also look for the substring after any word
428                // separator (most commonly, a space). In theory,
429                // any punctuation can be a word separator,
430                // but we will just look for the most common
431                // ones.
432                // This would be much easier to do with the
433                // REGEXP operator, but its presence is
434                // inconsistent between MySQL, PostgreSQL and
435                // SQLite.
436                $wordSeparators = [ ' ', '/', '(', ')', '-', '|', "\'", '"' ];
437                foreach ( $wordSeparators as $wordSeparator ) {
438                    $whereStr .= " OR LOWER($quotedCargoField) LIKE LOWER('%$wordSeparator$substring%')";
439                }
440                $whereStr .= ')';
441            }
442        }
443
444        $sqlQuery = CargoSQLQuery::newFromValues(
445            $tablesStr,
446            $fieldsStr,
447            $whereStr,
448            $joinOnStr,
449            $cargoField,
450            $havingStr = null,
451            $cargoField,
452            PFValuesUtils::getMaxValuesToRetrieve( $substring ),
453            $offsetStr = 0,
454            true
455        );
456        $queryResults = $sqlQuery->run();
457
458        if ( $cargoField[0] != '_' ) {
459            $cargoFieldAlias = str_replace( '_', ' ', $cargoField );
460        } else {
461            $cargoFieldAlias = $cargoField;
462        }
463
464        $values = [];
465        foreach ( $queryResults as $row ) {
466            $value = $row[$cargoFieldAlias];
467            // @TODO - this check should not be necessary.
468            if ( $value == '' ) {
469                continue;
470            }
471            // Cargo HTML-encodes everything - let's decode double
472            // quotes, at least.
473            $values[] = str_replace( '&quot;', '"', $value );
474        }
475        return $values;
476    }
477
478    /**
479     * Sort the values of an array by their lengths (shortest to longest)
480     *
481     * @param array $values
482     * @return array $values
483     */
484    static function sortValuesByLength( $values ) {
485        if ( empty( $values ) ) {
486            return $values;
487        }
488        uasort( $values, static function ( $a, $b ) {
489            return strlen( $a ) - strlen( $b );
490        } );
491        return $values;
492    }
493}