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