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