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