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