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