Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 268 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
PFAutocompleteAPI | |
0.00% |
0 / 268 |
|
0.00% |
0 / 11 |
4160 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 70 |
|
0.00% |
0 / 1 |
650 | |||
getAllowedParams | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
2 | |||
getParamDescription | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getExamples | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getAllValuesForProperty | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
20 | |||
computeAllValuesForProperty | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
90 | |||
getAllValuesForCargoField | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
computeAllValuesForCargoField | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
272 | |||
sortValuesByLength | |
0.00% |
0 / 6 |
|
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 | */ |
15 | class 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( '"', '"', $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 | } |