]> git.openstreetmap.org Git - nominatim.git/blob - lib-php/SearchDescription.php
move SearchDescription building into tokens
[nominatim.git] / lib-php / SearchDescription.php
1 <?php
2
3 namespace Nominatim;
4
5 require_once(CONST_LibDir.'/SpecialSearchOperator.php');
6 require_once(CONST_LibDir.'/SearchContext.php');
7 require_once(CONST_LibDir.'/Result.php');
8
9 /**
10  * Description of a single interpretation of a search query.
11  */
12 class SearchDescription
13 {
14     /// Ranking how well the description fits the query.
15     private $iSearchRank = 0;
16     /// Country code of country the result must belong to.
17     private $sCountryCode = '';
18     /// List of word ids making up the name of the object.
19     private $aName = array();
20     /// True if the name is rare enough to force index use on name.
21     private $bRareName = false;
22     /// List of word ids making up the address of the object.
23     private $aAddress = array();
24     /// List of word ids that appear in the name but should be ignored.
25     private $aNameNonSearch = array();
26     /// List of word ids that appear in the address but should be ignored.
27     private $aAddressNonSearch = array();
28     /// Kind of search for special searches, see Nominatim::Operator.
29     private $iOperator = Operator::NONE;
30     /// Class of special feature to search for.
31     private $sClass = '';
32     /// Type of special feature to search for.
33     private $sType = '';
34     /// Housenumber of the object.
35     private $sHouseNumber = '';
36     /// Postcode for the object.
37     private $sPostcode = '';
38     /// Global search constraints.
39     private $oContext;
40
41     // Temporary values used while creating the search description.
42
43     /// Index of phrase currently processed.
44     private $iNamePhrase = -1;
45
46     /**
47      * Create an empty search description.
48      *
49      * @param object $oContext Global context to use. Will be inherited by
50      *                         all derived search objects.
51      */
52     public function __construct($oContext)
53     {
54         $this->oContext = $oContext;
55     }
56
57     /**
58      * Get current search rank.
59      *
60      * The higher the search rank the lower the likelihood that the
61      * search is a correct interpretation of the search query.
62      *
63      * @return integer Search rank.
64      */
65     public function getRank()
66     {
67         return $this->iSearchRank;
68     }
69
70     /**
71      * Extract key/value pairs from a query.
72      *
73      * Key/value pairs are recognised if they are of the form [<key>=<value>].
74      * If multiple terms of this kind are found then all terms are removed
75      * but only the first is used for search.
76      *
77      * @param string $sQuery Original query string.
78      *
79      * @return string The query string with the special search patterns removed.
80      */
81     public function extractKeyValuePairs($sQuery)
82     {
83         // Search for terms of kind [<key>=<value>].
84         preg_match_all(
85             '/\\[([\\w_]*)=([\\w_]*)\\]/',
86             $sQuery,
87             $aSpecialTermsRaw,
88             PREG_SET_ORDER
89         );
90
91         foreach ($aSpecialTermsRaw as $aTerm) {
92             $sQuery = str_replace($aTerm[0], ' ', $sQuery);
93             if (!$this->hasOperator()) {
94                 $this->setPoiSearch(Operator::TYPE, $aTerm[1], $aTerm[2]);
95             }
96         }
97
98         return $sQuery;
99     }
100
101     /**
102      * Check if the combination of parameters is sensible.
103      *
104      * @return bool True, if the search looks valid.
105      */
106     public function isValidSearch()
107     {
108         if (empty($this->aName)) {
109             if ($this->sHouseNumber) {
110                 return false;
111             }
112             if (!$this->sClass && !$this->sCountryCode) {
113                 return false;
114             }
115         }
116
117         return true;
118     }
119
120     /////////// Search building functions
121     public function clone($iTermCost)
122     {
123         $oSearch = clone $this;
124         $oSearch->iSearchRank += $iTermCost;
125
126         return $oSearch;
127     }
128
129     public function hasName($bIncludeNonNames = false)
130     {
131         return !empty($this->aName)
132                || (!empty($this->aNameNonSearch) && $bIncludeNonNames);
133     }
134
135     public function hasAddress()
136     {
137         return !empty($this->aAddress) || !empty($this->aAddressNonSearch);
138     }
139
140     public function hasCountry()
141     {
142         return $this->sCountryCode !== '';
143     }
144
145     public function hasPostcode()
146     {
147         return $this->sPostcode !== '';
148     }
149
150     public function hasHousenumber()
151     {
152         return $this->sHouseNumber !== '';
153     }
154
155     public function hasOperator($iOperator = null)
156     {
157         return $iOperator === null ? $this->iOperator != Operator::NONE : $this->iOperator == $iOperator;
158     }
159
160     public function addAddressToken($iId, $bSearchable = true)
161     {
162         if ($bSearchable) {
163             $this->aAddress[$iId] = $iId;
164         } else {
165             $this->aAddressNonSearch[$iId] = $iId;
166         }
167     }
168
169     public function addNameToken($iId)
170     {
171         $this->aName[$iId] = $iId;
172     }
173
174     public function addPartialNameToken($iId, $bSearchable, $iPhraseNumber)
175     {
176         if ($bSearchable) {
177             $this->aName[$iId] = $iId;
178         } else {
179             $this->aNameNonSearch[$iId] = $iId;
180         }
181         $this->iNamePhrase = $iPhraseNumber;
182     }
183
184     public function markRareName()
185     {
186         $this->bRareName = true;
187     }
188
189     public function setCountry($sCountryCode)
190     {
191         $this->sCountryCode = $sCountryCode;
192         $this->iNamePhrase = -1;
193     }
194
195     public function setPostcode($sPostcode)
196     {
197         $this->sPostcode = $sPostcode;
198         $this->iNamePhrase = -1;
199     }
200
201     public function setPostcodeAsName($iId, $sPostcode)
202     {
203         $this->iOperator = Operator::POSTCODE;
204         $this->aAddress = array_merge($this->aAddress, $this->aName);
205         $this->aName = array($iId => $sPostcode);
206         $this->bRareName = true;
207         $this->iNamePhrase = -1;
208     }
209
210     public function setHousenumber($sNumber)
211     {
212         $this->sHouseNumber = $sNumber;
213         $this->iNamePhrase = -1;
214     }
215
216     public function setHousenumberAsName($iId)
217     {
218         $this->aAddress = array_merge($this->aAddress, $this->aName);
219         $this->bRareName = false;
220         $this->aName = array($iId => $iId);
221         $this->iNamePhrase = -1;
222     }
223
224     /**
225      * Make this search a POI search.
226      *
227      * In a POI search, objects are not (only) searched by their name
228      * but also by the primary OSM key/value pair (class and type in Nominatim).
229      *
230      * @param integer $iOperator Type of POI search
231      * @param string  $sClass    Class (or OSM tag key) of POI.
232      * @param string  $sType     Type (or OSM tag value) of POI.
233      *
234      * @return void
235      */
236     public function setPoiSearch($iOperator, $sClass, $sType)
237     {
238         $this->iOperator = $iOperator;
239         $this->sClass = $sClass;
240         $this->sType = $sType;
241         $this->iNamePhrase = -1;
242     }
243
244     public function getNamePhrase()
245     {
246         return $this->iNamePhrase;
247     }
248
249     public function getContext()
250     {
251         return $this->oContext;
252     }
253
254     /////////// Query functions
255
256
257     /**
258      * Query database for places that match this search.
259      *
260      * @param object  $oDB      Nominatim::DB instance to use.
261      * @param integer $iMinRank Minimum address rank to restrict search to.
262      * @param integer $iMaxRank Maximum address rank to restrict search to.
263      * @param integer $iLimit   Maximum number of results.
264      *
265      * @return mixed[] An array with two fields: IDs contains the list of
266      *                 matching place IDs and houseNumber the houseNumber
267      *                 if appicable or -1 if not.
268      */
269     public function query(&$oDB, $iMinRank, $iMaxRank, $iLimit)
270     {
271         $aResults = array();
272
273         if ($this->sCountryCode
274             && empty($this->aName)
275             && !$this->iOperator
276             && !$this->sClass
277             && !$this->oContext->hasNearPoint()
278         ) {
279             // Just looking for a country - look it up
280             if (4 >= $iMinRank && 4 <= $iMaxRank) {
281                 $aResults = $this->queryCountry($oDB);
282             }
283         } elseif (empty($this->aName) && empty($this->aAddress)) {
284             // Neither name nor address? Then we must be
285             // looking for a POI in a geographic area.
286             if ($this->oContext->isBoundedSearch()) {
287                 $aResults = $this->queryNearbyPoi($oDB, $iLimit);
288             }
289         } elseif ($this->iOperator == Operator::POSTCODE) {
290             // looking for postcode
291             $aResults = $this->queryPostcode($oDB, $iLimit);
292         } else {
293             // Ordinary search:
294             // First search for places according to name and address.
295             $aResults = $this->queryNamedPlace(
296                 $oDB,
297                 $iMinRank,
298                 $iMaxRank,
299                 $iLimit
300             );
301
302             // Now search for housenumber, if housenumber provided. Can be zero.
303             if (($this->sHouseNumber || $this->sHouseNumber === '0') && !empty($aResults)) {
304                 $aHnResults = $this->queryHouseNumber($oDB, $aResults);
305
306                 // Downgrade the rank of the street results, they are missing
307                 // the housenumber. Also drop POI places (rank 30) here, they
308                 // cannot be a parent place and therefore must not be shown
309                 // as a result for a search with a missing housenumber.
310                 foreach ($aResults as $oRes) {
311                     if ($oRes->iAddressRank < 28) {
312                         if ($oRes->iAddressRank >= 26) {
313                             $oRes->iResultRank++;
314                         } else {
315                             $oRes->iResultRank += 2;
316                         }
317                         $aHnResults[$oRes->iId] = $oRes;
318                     }
319                 }
320
321                 $aResults = $aHnResults;
322             }
323
324             // finally get POIs if requested
325             if ($this->sClass && !empty($aResults)) {
326                 $aResults = $this->queryPoiByOperator($oDB, $aResults, $iLimit);
327             }
328         }
329
330         Debug::printDebugTable('Place IDs', $aResults);
331
332         if (!empty($aResults) && $this->sPostcode) {
333             $sPlaceIds = Result::joinIdsByTable($aResults, Result::TABLE_PLACEX);
334             if ($sPlaceIds) {
335                 $sSQL = 'SELECT place_id FROM placex';
336                 $sSQL .= ' WHERE place_id in ('.$sPlaceIds.')';
337                 $sSQL .= " AND postcode != '".$this->sPostcode."'";
338                 Debug::printSQL($sSQL);
339                 $aFilteredPlaceIDs = $oDB->getCol($sSQL);
340                 if ($aFilteredPlaceIDs) {
341                     foreach ($aFilteredPlaceIDs as $iPlaceId) {
342                         $aResults[$iPlaceId]->iResultRank++;
343                     }
344                 }
345             }
346         }
347
348         return $aResults;
349     }
350
351
352     private function queryCountry(&$oDB)
353     {
354         $sSQL = 'SELECT place_id FROM placex ';
355         $sSQL .= "WHERE country_code='".$this->sCountryCode."'";
356         $sSQL .= ' AND rank_search = 4';
357         if ($this->oContext->bViewboxBounded) {
358             $sSQL .= ' AND ST_Intersects('.$this->oContext->sqlViewboxSmall.', geometry)';
359         }
360         $sSQL .= ' ORDER BY st_area(geometry) DESC LIMIT 1';
361
362         Debug::printSQL($sSQL);
363
364         $iPlaceId = $oDB->getOne($sSQL);
365
366         $aResults = array();
367         if ($iPlaceId) {
368             $aResults[$iPlaceId] = new Result($iPlaceId);
369         }
370
371         return $aResults;
372     }
373
374     private function queryNearbyPoi(&$oDB, $iLimit)
375     {
376         if (!$this->sClass) {
377             return array();
378         }
379
380         $aDBResults = array();
381         $sPoiTable = $this->poiTable();
382
383         if ($oDB->tableExists($sPoiTable)) {
384             $sSQL = 'SELECT place_id FROM '.$sPoiTable.' ct';
385             if ($this->oContext->sqlCountryList) {
386                 $sSQL .= ' JOIN placex USING (place_id)';
387             }
388             if ($this->oContext->hasNearPoint()) {
389                 $sSQL .= ' WHERE '.$this->oContext->withinSQL('ct.centroid');
390             } elseif ($this->oContext->bViewboxBounded) {
391                 $sSQL .= ' WHERE ST_Contains('.$this->oContext->sqlViewboxSmall.', ct.centroid)';
392             }
393             if ($this->oContext->sqlCountryList) {
394                 $sSQL .= ' AND country_code in '.$this->oContext->sqlCountryList;
395             }
396             $sSQL .= $this->oContext->excludeSQL(' AND place_id');
397             if ($this->oContext->sqlViewboxCentre) {
398                 $sSQL .= ' ORDER BY ST_Distance(';
399                 $sSQL .= $this->oContext->sqlViewboxCentre.', ct.centroid) ASC';
400             } elseif ($this->oContext->hasNearPoint()) {
401                 $sSQL .= ' ORDER BY '.$this->oContext->distanceSQL('ct.centroid').' ASC';
402             }
403             $sSQL .= " LIMIT $iLimit";
404             Debug::printSQL($sSQL);
405             $aDBResults = $oDB->getCol($sSQL);
406         }
407
408         if ($this->oContext->hasNearPoint()) {
409             $sSQL = 'SELECT place_id FROM placex WHERE ';
410             $sSQL .= 'class = :class and type = :type';
411             $sSQL .= ' AND '.$this->oContext->withinSQL('geometry');
412             $sSQL .= ' AND linked_place_id is null';
413             if ($this->oContext->sqlCountryList) {
414                 $sSQL .= ' AND country_code in '.$this->oContext->sqlCountryList;
415             }
416             $sSQL .= ' ORDER BY '.$this->oContext->distanceSQL('centroid').' ASC';
417             $sSQL .= " LIMIT $iLimit";
418             Debug::printSQL($sSQL);
419             $aDBResults = $oDB->getCol(
420                 $sSQL,
421                 array(':class' => $this->sClass, ':type' => $this->sType)
422             );
423         }
424
425         $aResults = array();
426         foreach ($aDBResults as $iPlaceId) {
427             $aResults[$iPlaceId] = new Result($iPlaceId);
428         }
429
430         return $aResults;
431     }
432
433     private function queryPostcode(&$oDB, $iLimit)
434     {
435         $sSQL = 'SELECT p.place_id FROM location_postcode p ';
436
437         if (!empty($this->aAddress)) {
438             $sSQL .= ', search_name s ';
439             $sSQL .= 'WHERE s.place_id = p.parent_place_id ';
440             $sSQL .= 'AND array_cat(s.nameaddress_vector, s.name_vector)';
441             $sSQL .= '      @> '.$oDB->getArraySQL($this->aAddress).' AND ';
442         } else {
443             $sSQL .= 'WHERE ';
444         }
445
446         $sSQL .= "p.postcode = '".reset($this->aName)."'";
447         $sSQL .= $this->countryCodeSQL(' AND p.country_code');
448         if ($this->oContext->bViewboxBounded) {
449             $sSQL .= ' AND ST_Intersects('.$this->oContext->sqlViewboxSmall.', geometry)';
450         }
451         $sSQL .= $this->oContext->excludeSQL(' AND p.place_id');
452         $sSQL .= " LIMIT $iLimit";
453
454         Debug::printSQL($sSQL);
455
456         $aResults = array();
457         foreach ($oDB->getCol($sSQL) as $iPlaceId) {
458             $aResults[$iPlaceId] = new Result($iPlaceId, Result::TABLE_POSTCODE);
459         }
460
461         return $aResults;
462     }
463
464     private function queryNamedPlace(&$oDB, $iMinAddressRank, $iMaxAddressRank, $iLimit)
465     {
466         $aTerms = array();
467         $aOrder = array();
468
469         // Sort by existence of the requested house number but only if not
470         // too many results are expected for the street, i.e. if the result
471         // will be narrowed down by an address. Remeber that with ordering
472         // every single result has to be checked.
473         if ($this->sHouseNumber && ($this->bRareName || !empty($this->aAddress) || $this->sPostcode)) {
474             $sHouseNumberRegex = '\\\\m'.$this->sHouseNumber.'\\\\M';
475             $aOrder[] = ' (';
476             $aOrder[0] .= 'EXISTS(';
477             $aOrder[0] .= '  SELECT place_id';
478             $aOrder[0] .= '  FROM placex';
479             $aOrder[0] .= '  WHERE parent_place_id = search_name.place_id';
480             $aOrder[0] .= "    AND housenumber ~* E'".$sHouseNumberRegex."'";
481             $aOrder[0] .= '  LIMIT 1';
482             $aOrder[0] .= ') ';
483             // also housenumbers from interpolation lines table are needed
484             if (preg_match('/[0-9]+/', $this->sHouseNumber)) {
485                 $iHouseNumber = intval($this->sHouseNumber);
486                 $aOrder[0] .= 'OR EXISTS(';
487                 $aOrder[0] .= '  SELECT place_id ';
488                 $aOrder[0] .= '  FROM location_property_osmline ';
489                 $aOrder[0] .= '  WHERE parent_place_id = search_name.place_id';
490                 $aOrder[0] .= '    AND startnumber is not NULL';
491                 $aOrder[0] .= '    AND '.$iHouseNumber.'>=startnumber ';
492                 $aOrder[0] .= '    AND '.$iHouseNumber.'<=endnumber ';
493                 $aOrder[0] .= '  LIMIT 1';
494                 $aOrder[0] .= ')';
495             }
496             $aOrder[0] .= ') DESC';
497         }
498
499         if (!empty($this->aName)) {
500             $aTerms[] = 'name_vector @> '.$oDB->getArraySQL($this->aName);
501         }
502         if (!empty($this->aAddress)) {
503             // For infrequent name terms disable index usage for address
504             if ($this->bRareName) {
505                 $aTerms[] = 'array_cat(nameaddress_vector,ARRAY[]::integer[]) @> '.$oDB->getArraySQL($this->aAddress);
506             } else {
507                 $aTerms[] = 'nameaddress_vector @> '.$oDB->getArraySQL($this->aAddress);
508             }
509         }
510
511         $sCountryTerm = $this->countryCodeSQL('country_code');
512         if ($sCountryTerm) {
513             $aTerms[] = $sCountryTerm;
514         }
515
516         if ($this->sHouseNumber) {
517             $aTerms[] = 'address_rank between 16 and 30';
518         } elseif (!$this->sClass || $this->iOperator == Operator::NAME) {
519             if ($iMinAddressRank > 0) {
520                 $aTerms[] = "((address_rank between $iMinAddressRank and $iMaxAddressRank) or (search_rank between $iMinAddressRank and $iMaxAddressRank))";
521             }
522         }
523
524         if ($this->oContext->hasNearPoint()) {
525             $aTerms[] = $this->oContext->withinSQL('centroid');
526             $aOrder[] = $this->oContext->distanceSQL('centroid');
527         } elseif ($this->sPostcode) {
528             if (empty($this->aAddress)) {
529                 $aTerms[] = "EXISTS(SELECT place_id FROM location_postcode p WHERE p.postcode = '".$this->sPostcode."' AND ST_DWithin(search_name.centroid, p.geometry, 0.1))";
530             } else {
531                 $aOrder[] = "(SELECT min(ST_Distance(search_name.centroid, p.geometry)) FROM location_postcode p WHERE p.postcode = '".$this->sPostcode."')";
532             }
533         }
534
535         $sExcludeSQL = $this->oContext->excludeSQL('place_id');
536         if ($sExcludeSQL) {
537             $aTerms[] = $sExcludeSQL;
538         }
539
540         if ($this->oContext->bViewboxBounded) {
541             $aTerms[] = 'centroid && '.$this->oContext->sqlViewboxSmall;
542         }
543
544         if ($this->oContext->hasNearPoint()) {
545             $aOrder[] = $this->oContext->distanceSQL('centroid');
546         }
547
548         if ($this->sHouseNumber) {
549             $sImportanceSQL = '- abs(26 - address_rank) + 3';
550         } else {
551             $sImportanceSQL = '(CASE WHEN importance = 0 OR importance IS NULL THEN 0.75001-(search_rank::float/40) ELSE importance END)';
552         }
553         $sImportanceSQL .= $this->oContext->viewboxImportanceSQL('centroid');
554         $aOrder[] = "$sImportanceSQL DESC";
555
556         $aFullNameAddress = $this->oContext->getFullNameTerms();
557         if (!empty($aFullNameAddress)) {
558             $sExactMatchSQL = ' ( ';
559             $sExactMatchSQL .= ' SELECT count(*) FROM ( ';
560             $sExactMatchSQL .= '  SELECT unnest('.$oDB->getArraySQL($aFullNameAddress).')';
561             $sExactMatchSQL .= '    INTERSECT ';
562             $sExactMatchSQL .= '  SELECT unnest(nameaddress_vector)';
563             $sExactMatchSQL .= ' ) s';
564             $sExactMatchSQL .= ') as exactmatch';
565             $aOrder[] = 'exactmatch DESC';
566         } else {
567             $sExactMatchSQL = '0::int as exactmatch';
568         }
569
570         if ($this->sHouseNumber || $this->sClass) {
571             $iLimit = 40;
572         }
573
574         $aResults = array();
575
576         if (!empty($aTerms)) {
577             $sSQL = 'SELECT place_id, address_rank,'.$sExactMatchSQL;
578             $sSQL .= ' FROM search_name';
579             $sSQL .= ' WHERE '.join(' and ', $aTerms);
580             $sSQL .= ' ORDER BY '.join(', ', $aOrder);
581             $sSQL .= ' LIMIT '.$iLimit;
582
583             Debug::printSQL($sSQL);
584
585             $aDBResults = $oDB->getAll($sSQL, null, 'Could not get places for search terms.');
586
587             foreach ($aDBResults as $aResult) {
588                 $oResult = new Result($aResult['place_id']);
589                 $oResult->iExactMatches = $aResult['exactmatch'];
590                 $oResult->iAddressRank = $aResult['address_rank'];
591                 $aResults[$aResult['place_id']] = $oResult;
592             }
593         }
594
595         return $aResults;
596     }
597
598     private function queryHouseNumber(&$oDB, $aRoadPlaceIDs)
599     {
600         $aResults = array();
601         $sRoadPlaceIDs = Result::joinIdsByTableMaxRank(
602             $aRoadPlaceIDs,
603             Result::TABLE_PLACEX,
604             27
605         );
606         $sPOIPlaceIDs = Result::joinIdsByTableMinRank(
607             $aRoadPlaceIDs,
608             Result::TABLE_PLACEX,
609             30
610         );
611
612         $aIDCondition = array();
613         if ($sRoadPlaceIDs) {
614             $aIDCondition[] = 'parent_place_id in ('.$sRoadPlaceIDs.')';
615         }
616         if ($sPOIPlaceIDs) {
617             $aIDCondition[] = 'place_id in ('.$sPOIPlaceIDs.')';
618         }
619
620         if (empty($aIDCondition)) {
621             return $aResults;
622         }
623
624         $sHouseNumberRegex = '\\\\m'.$this->sHouseNumber.'\\\\M';
625         $sSQL = 'SELECT place_id FROM placex WHERE';
626         $sSQL .= "  housenumber ~* E'".$sHouseNumberRegex."'";
627         $sSQL .= ' AND ('.join(' OR ', $aIDCondition).')';
628         $sSQL .= $this->oContext->excludeSQL(' AND place_id');
629
630         Debug::printSQL($sSQL);
631
632         // XXX should inherit the exactMatches from its parent
633         foreach ($oDB->getCol($sSQL) as $iPlaceId) {
634             $aResults[$iPlaceId] = new Result($iPlaceId);
635         }
636
637         $bIsIntHouseNumber= (bool) preg_match('/[0-9]+/', $this->sHouseNumber);
638         $iHousenumber = intval($this->sHouseNumber);
639         if ($bIsIntHouseNumber && $sRoadPlaceIDs && empty($aResults)) {
640             // if nothing found, search in the interpolation line table
641             $sSQL = 'SELECT distinct place_id FROM location_property_osmline';
642             $sSQL .= ' WHERE startnumber is not NULL';
643             $sSQL .= '  AND parent_place_id in ('.$sRoadPlaceIDs.') AND (';
644             if ($iHousenumber % 2 == 0) {
645                 // If housenumber is even, look for housenumber in streets
646                 // with interpolationtype even or all.
647                 $sSQL .= "interpolationtype='even'";
648             } else {
649                 // Else look for housenumber with interpolationtype odd or all.
650                 $sSQL .= "interpolationtype='odd'";
651             }
652             $sSQL .= " or interpolationtype='all') and ";
653             $sSQL .= $iHousenumber.'>=startnumber and ';
654             $sSQL .= $iHousenumber.'<=endnumber';
655             $sSQL .= $this->oContext->excludeSQL(' AND place_id');
656
657             Debug::printSQL($sSQL);
658
659             foreach ($oDB->getCol($sSQL) as $iPlaceId) {
660                 $oResult = new Result($iPlaceId, Result::TABLE_OSMLINE);
661                 $oResult->iHouseNumber = $iHousenumber;
662                 $aResults[$iPlaceId] = $oResult;
663             }
664         }
665
666         // If nothing found then search in Tiger data (location_property_tiger)
667         if (CONST_Use_US_Tiger_Data && $sRoadPlaceIDs && $bIsIntHouseNumber && empty($aResults)) {
668             $sSQL = 'SELECT place_id FROM location_property_tiger';
669             $sSQL .= ' WHERE parent_place_id in ('.$sRoadPlaceIDs.') and (';
670             if ($iHousenumber % 2 == 0) {
671                 $sSQL .= "interpolationtype='even'";
672             } else {
673                 $sSQL .= "interpolationtype='odd'";
674             }
675             $sSQL .= " or interpolationtype='all') and ";
676             $sSQL .= $iHousenumber.'>=startnumber and ';
677             $sSQL .= $iHousenumber.'<=endnumber';
678             $sSQL .= $this->oContext->excludeSQL(' AND place_id');
679
680             Debug::printSQL($sSQL);
681
682             foreach ($oDB->getCol($sSQL) as $iPlaceId) {
683                 $oResult = new Result($iPlaceId, Result::TABLE_TIGER);
684                 $oResult->iHouseNumber = $iHousenumber;
685                 $aResults[$iPlaceId] = $oResult;
686             }
687         }
688
689         return $aResults;
690     }
691
692
693     private function queryPoiByOperator(&$oDB, $aParentIDs, $iLimit)
694     {
695         $aResults = array();
696         $sPlaceIDs = Result::joinIdsByTable($aParentIDs, Result::TABLE_PLACEX);
697
698         if (!$sPlaceIDs) {
699             return $aResults;
700         }
701
702         if ($this->iOperator == Operator::TYPE || $this->iOperator == Operator::NAME) {
703             // If they were searching for a named class (i.e. 'Kings Head pub')
704             // then we might have an extra match
705             $sSQL = 'SELECT place_id FROM placex ';
706             $sSQL .= " WHERE place_id in ($sPlaceIDs)";
707             $sSQL .= "   AND class='".$this->sClass."' ";
708             $sSQL .= "   AND type='".$this->sType."'";
709             $sSQL .= '   AND linked_place_id is null';
710             $sSQL .= $this->oContext->excludeSQL(' AND place_id');
711             $sSQL .= ' ORDER BY rank_search ASC ';
712             $sSQL .= " LIMIT $iLimit";
713
714             Debug::printSQL($sSQL);
715
716             foreach ($oDB->getCol($sSQL) as $iPlaceId) {
717                 $aResults[$iPlaceId] = new Result($iPlaceId);
718             }
719         }
720
721         // NEAR and IN are handled the same
722         if ($this->iOperator == Operator::TYPE || $this->iOperator == Operator::NEAR) {
723             $sClassTable = $this->poiTable();
724             $bCacheTable = $oDB->tableExists($sClassTable);
725
726             $sSQL = "SELECT min(rank_search) FROM placex WHERE place_id in ($sPlaceIDs)";
727             Debug::printSQL($sSQL);
728             $iMaxRank = (int) $oDB->getOne($sSQL);
729
730             // For state / country level searches the normal radius search doesn't work very well
731             $sPlaceGeom = false;
732             if ($iMaxRank < 9 && $bCacheTable) {
733                 // Try and get a polygon to search in instead
734                 $sSQL = 'SELECT geometry FROM placex';
735                 $sSQL .= " WHERE place_id in ($sPlaceIDs)";
736                 $sSQL .= "   AND rank_search < $iMaxRank + 5";
737                 $sSQL .= "   AND ST_GeometryType(geometry) in ('ST_Polygon','ST_MultiPolygon')";
738                 $sSQL .= ' ORDER BY rank_search ASC ';
739                 $sSQL .= ' LIMIT 1';
740                 Debug::printSQL($sSQL);
741                 $sPlaceGeom = $oDB->getOne($sSQL);
742             }
743
744             if ($sPlaceGeom) {
745                 $sPlaceIDs = false;
746             } else {
747                 $iMaxRank += 5;
748                 $sSQL = 'SELECT place_id FROM placex';
749                 $sSQL .= " WHERE place_id in ($sPlaceIDs) and rank_search < $iMaxRank";
750                 Debug::printSQL($sSQL);
751                 $aPlaceIDs = $oDB->getCol($sSQL);
752                 $sPlaceIDs = join(',', $aPlaceIDs);
753             }
754
755             if ($sPlaceIDs || $sPlaceGeom) {
756                 $fRange = 0.01;
757                 if ($bCacheTable) {
758                     // More efficient - can make the range bigger
759                     $fRange = 0.05;
760
761                     $sOrderBySQL = '';
762                     if ($this->oContext->hasNearPoint()) {
763                         $sOrderBySQL = $this->oContext->distanceSQL('l.centroid');
764                     } elseif ($sPlaceIDs) {
765                         $sOrderBySQL = 'ST_Distance(l.centroid, f.geometry)';
766                     } elseif ($sPlaceGeom) {
767                         $sOrderBySQL = "ST_Distance(st_centroid('".$sPlaceGeom."'), l.centroid)";
768                     }
769
770                     $sSQL = 'SELECT distinct i.place_id';
771                     if ($sOrderBySQL) {
772                         $sSQL .= ', i.order_term';
773                     }
774                     $sSQL .= ' from (SELECT l.place_id';
775                     if ($sOrderBySQL) {
776                         $sSQL .= ','.$sOrderBySQL.' as order_term';
777                     }
778                     $sSQL .= ' from '.$sClassTable.' as l';
779
780                     if ($sPlaceIDs) {
781                         $sSQL .= ',placex as f WHERE ';
782                         $sSQL .= "f.place_id in ($sPlaceIDs) ";
783                         $sSQL .= " AND ST_DWithin(l.centroid, f.centroid, $fRange)";
784                     } elseif ($sPlaceGeom) {
785                         $sSQL .= " WHERE ST_Contains('$sPlaceGeom', l.centroid)";
786                     }
787
788                     $sSQL .= $this->oContext->excludeSQL(' AND l.place_id');
789                     $sSQL .= 'limit 300) i ';
790                     if ($sOrderBySQL) {
791                         $sSQL .= 'order by order_term asc';
792                     }
793                     $sSQL .= " limit $iLimit";
794
795                     Debug::printSQL($sSQL);
796
797                     foreach ($oDB->getCol($sSQL) as $iPlaceId) {
798                         $aResults[$iPlaceId] = new Result($iPlaceId);
799                     }
800                 } else {
801                     if ($this->oContext->hasNearPoint()) {
802                         $fRange = $this->oContext->nearRadius();
803                     }
804
805                     $sOrderBySQL = '';
806                     if ($this->oContext->hasNearPoint()) {
807                         $sOrderBySQL = $this->oContext->distanceSQL('l.geometry');
808                     } else {
809                         $sOrderBySQL = 'ST_Distance(l.geometry, f.geometry)';
810                     }
811
812                     $sSQL = 'SELECT distinct l.place_id';
813                     if ($sOrderBySQL) {
814                         $sSQL .= ','.$sOrderBySQL.' as orderterm';
815                     }
816                     $sSQL .= ' FROM placex as l, placex as f';
817                     $sSQL .= " WHERE f.place_id in ($sPlaceIDs)";
818                     $sSQL .= "  AND ST_DWithin(l.geometry, f.centroid, $fRange)";
819                     $sSQL .= "  AND l.class='".$this->sClass."'";
820                     $sSQL .= "  AND l.type='".$this->sType."'";
821                     $sSQL .= $this->oContext->excludeSQL(' AND l.place_id');
822                     if ($sOrderBySQL) {
823                         $sSQL .= 'ORDER BY orderterm ASC';
824                     }
825                     $sSQL .= " limit $iLimit";
826
827                     Debug::printSQL($sSQL);
828
829                     foreach ($oDB->getCol($sSQL) as $iPlaceId) {
830                         $aResults[$iPlaceId] = new Result($iPlaceId);
831                     }
832                 }
833             }
834         }
835
836         return $aResults;
837     }
838
839     private function poiTable()
840     {
841         return 'place_classtype_'.$this->sClass.'_'.$this->sType;
842     }
843
844     private function countryCodeSQL($sVar)
845     {
846         if ($this->sCountryCode) {
847             return $sVar.' = \''.$this->sCountryCode."'";
848         }
849         if ($this->oContext->sqlCountryList) {
850             return $sVar.' in '.$this->oContext->sqlCountryList;
851         }
852
853         return '';
854     }
855
856     /////////// Sort functions
857
858
859     public static function bySearchRank($a, $b)
860     {
861         if ($a->iSearchRank == $b->iSearchRank) {
862             return $a->iOperator + strlen($a->sHouseNumber)
863                      - $b->iOperator - strlen($b->sHouseNumber);
864         }
865
866         return $a->iSearchRank < $b->iSearchRank ? -1 : 1;
867     }
868
869     //////////// Debugging functions
870
871
872     public function debugInfo()
873     {
874         return array(
875                 'Search rank' => $this->iSearchRank,
876                 'Country code' => $this->sCountryCode,
877                 'Name terms' => $this->aName,
878                 'Name terms (stop words)' => $this->aNameNonSearch,
879                 'Address terms' => $this->aAddress,
880                 'Address terms (stop words)' => $this->aAddressNonSearch,
881                 'Address terms (full words)' => $this->aFullNameAddress ?? '',
882                 'Special search' => $this->iOperator,
883                 'Class' => $this->sClass,
884                 'Type' => $this->sType,
885                 'House number' => $this->sHouseNumber,
886                 'Postcode' => $this->sPostcode
887                );
888     }
889
890     public function dumpAsHtmlTableRow(&$aWordIDs)
891     {
892         $kf = function ($k) use (&$aWordIDs) {
893             return $aWordIDs[$k] ?? '['.$k.']';
894         };
895
896         echo '<tr>';
897         echo "<td>$this->iSearchRank</td>";
898         echo '<td>'.join(', ', array_map($kf, $this->aName)).'</td>';
899         echo '<td>'.join(', ', array_map($kf, $this->aNameNonSearch)).'</td>';
900         echo '<td>'.join(', ', array_map($kf, $this->aAddress)).'</td>';
901         echo '<td>'.join(', ', array_map($kf, $this->aAddressNonSearch)).'</td>';
902         echo '<td>'.$this->sCountryCode.'</td>';
903         echo '<td>'.Operator::toString($this->iOperator).'</td>';
904         echo '<td>'.$this->sClass.'</td>';
905         echo '<td>'.$this->sType.'</td>';
906         echo '<td>'.$this->sPostcode.'</td>';
907         echo '<td>'.$this->sHouseNumber.'</td>';
908
909         echo '</tr>';
910     }
911 }