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