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