1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Implementation of the actual database accesses for forward search.
10 from typing import List, Tuple, AsyncIterator, Dict, Any, Callable, cast
13 import sqlalchemy as sa
15 from ..typing import SaFromClause, SaScalarSelect, SaColumn, \
16 SaExpression, SaSelect, SaLambdaSelect, SaRow, SaBind
17 from ..sql.sqlalchemy_types import Geometry, IntArray
18 from ..connection import SearchConnection
19 from ..types import SearchDetails, DataLayer, GeometryFormat, Bbox
20 from .. import results as nres
21 from .db_search_fields import SearchData, WeightedCategories
23 #pylint: disable=singleton-comparison,not-callable
24 #pylint: disable=too-many-branches,too-many-arguments,too-many-locals,too-many-statements
26 def no_index(expr: SaColumn) -> SaColumn:
27 """ Wrap the given expression, so that the query planner will
28 refrain from using the expression for index lookup.
30 return sa.func.coalesce(sa.null(), expr) # pylint: disable=not-callable
33 def _details_to_bind_params(details: SearchDetails) -> Dict[str, Any]:
34 """ Create a dictionary from search parameters that can be used
35 as bind parameter for SQL execute.
37 return {'limit': details.max_results,
38 'min_rank': details.min_rank,
39 'max_rank': details.max_rank,
40 'viewbox': details.viewbox,
41 'viewbox2': details.viewbox_x2,
43 'near_radius': details.near_radius,
44 'excluded': details.excluded,
45 'countries': details.countries}
48 LIMIT_PARAM: SaBind = sa.bindparam('limit')
49 MIN_RANK_PARAM: SaBind = sa.bindparam('min_rank')
50 MAX_RANK_PARAM: SaBind = sa.bindparam('max_rank')
51 VIEWBOX_PARAM: SaBind = sa.bindparam('viewbox', type_=Geometry)
52 VIEWBOX2_PARAM: SaBind = sa.bindparam('viewbox2', type_=Geometry)
53 NEAR_PARAM: SaBind = sa.bindparam('near', type_=Geometry)
54 NEAR_RADIUS_PARAM: SaBind = sa.bindparam('near_radius')
55 COUNTRIES_PARAM: SaBind = sa.bindparam('countries')
58 def filter_by_area(sql: SaSelect, t: SaFromClause,
59 details: SearchDetails, avoid_index: bool = False) -> SaSelect:
60 """ Apply SQL statements for filtering by viewbox and near point,
63 if details.near is not None and details.near_radius is not None:
64 if details.near_radius < 0.1 and not avoid_index:
65 sql = sql.where(t.c.geometry.within_distance(NEAR_PARAM, NEAR_RADIUS_PARAM))
67 sql = sql.where(t.c.geometry.ST_Distance(NEAR_PARAM) <= NEAR_RADIUS_PARAM)
68 if details.viewbox is not None and details.bounded_viewbox:
69 sql = sql.where(t.c.geometry.intersects(VIEWBOX_PARAM,
70 use_index=not avoid_index and
71 details.viewbox.area < 0.2))
76 def _exclude_places(t: SaFromClause) -> Callable[[], SaExpression]:
77 return lambda: t.c.place_id.not_in(sa.bindparam('excluded'))
80 def _select_placex(t: SaFromClause) -> SaSelect:
81 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
83 t.c.address, t.c.extratags,
84 t.c.housenumber, t.c.postcode, t.c.country_code,
86 t.c.parent_place_id, t.c.rank_address, t.c.rank_search,
87 t.c.linked_place_id, t.c.admin_level,
89 t.c.geometry.ST_Expand(0).label('bbox'))
92 def _add_geometry_columns(sql: SaLambdaSelect, col: SaColumn, details: SearchDetails) -> SaSelect:
95 if details.geometry_simplification > 0.0:
96 col = sa.func.ST_SimplifyPreserveTopology(col, details.geometry_simplification)
98 if details.geometry_output & GeometryFormat.GEOJSON:
99 out.append(sa.func.ST_AsGeoJSON(col, 7).label('geometry_geojson'))
100 if details.geometry_output & GeometryFormat.TEXT:
101 out.append(sa.func.ST_AsText(col).label('geometry_text'))
102 if details.geometry_output & GeometryFormat.KML:
103 out.append(sa.func.ST_AsKML(col, 7).label('geometry_kml'))
104 if details.geometry_output & GeometryFormat.SVG:
105 out.append(sa.func.ST_AsSVG(col, 0, 7).label('geometry_svg'))
107 return sql.add_columns(*out)
110 def _make_interpolation_subquery(table: SaFromClause, inner: SaFromClause,
111 numerals: List[int], details: SearchDetails) -> SaScalarSelect:
112 all_ids = sa.func.ArrayAgg(table.c.place_id)
113 sql = sa.select(all_ids).where(table.c.parent_place_id == inner.c.place_id)
115 if len(numerals) == 1:
116 sql = sql.where(sa.between(numerals[0], table.c.startnumber, table.c.endnumber))\
117 .where((numerals[0] - table.c.startnumber) % table.c.step == 0)
119 sql = sql.where(sa.or_(
120 *(sa.and_(sa.between(n, table.c.startnumber, table.c.endnumber),
121 (n - table.c.startnumber) % table.c.step == 0)
125 sql = sql.where(_exclude_places(table))
127 return sql.scalar_subquery()
130 def _filter_by_layer(table: SaFromClause, layers: DataLayer) -> SaColumn:
131 orexpr: List[SaExpression] = []
132 if layers & DataLayer.ADDRESS and layers & DataLayer.POI:
133 orexpr.append(no_index(table.c.rank_address).between(1, 30))
134 elif layers & DataLayer.ADDRESS:
135 orexpr.append(no_index(table.c.rank_address).between(1, 29))
136 orexpr.append(sa.func.IsAddressPoint(table))
137 elif layers & DataLayer.POI:
138 orexpr.append(sa.and_(no_index(table.c.rank_address) == 30,
139 table.c.class_.not_in(('place', 'building'))))
141 if layers & DataLayer.MANMADE:
143 if not layers & DataLayer.RAILWAY:
144 exclude.append('railway')
145 if not layers & DataLayer.NATURAL:
146 exclude.extend(('natural', 'water', 'waterway'))
147 orexpr.append(sa.and_(table.c.class_.not_in(tuple(exclude)),
148 no_index(table.c.rank_address) == 0))
151 if layers & DataLayer.RAILWAY:
152 include.append('railway')
153 if layers & DataLayer.NATURAL:
154 include.extend(('natural', 'water', 'waterway'))
155 orexpr.append(sa.and_(table.c.class_.in_(tuple(include)),
156 no_index(table.c.rank_address) == 0))
161 return sa.or_(*orexpr)
164 def _interpolated_position(table: SaFromClause, nr: SaColumn) -> SaColumn:
165 pos = sa.cast(nr - table.c.startnumber, sa.Float) / (table.c.endnumber - table.c.startnumber)
167 (table.c.endnumber == table.c.startnumber, table.c.linegeo.ST_Centroid()),
168 else_=table.c.linegeo.ST_LineInterpolatePoint(pos)).label('centroid')
171 async def _get_placex_housenumbers(conn: SearchConnection,
172 place_ids: List[int],
173 details: SearchDetails) -> AsyncIterator[nres.SearchResult]:
175 sql = _select_placex(t).add_columns(t.c.importance)\
176 .where(t.c.place_id.in_(place_ids))
178 if details.geometry_output:
179 sql = _add_geometry_columns(sql, t.c.geometry, details)
181 for row in await conn.execute(sql):
182 result = nres.create_from_placex_row(row, nres.SearchResult)
184 result.bbox = Bbox.from_wkb(row.bbox)
188 def _int_list_to_subquery(inp: List[int]) -> 'sa.Subquery':
189 """ Create a subselect that returns the given list of integers
190 as rows in the column 'nr'.
192 vtab = sa.func.JsonArrayEach(sa.type_coerce(inp, sa.JSON))\
193 .table_valued(sa.column('value', type_=sa.JSON))
194 return sa.select(sa.cast(sa.cast(vtab.c.value, sa.Text), sa.Integer).label('nr')).subquery()
197 async def _get_osmline(conn: SearchConnection, place_ids: List[int],
199 details: SearchDetails) -> AsyncIterator[nres.SearchResult]:
202 values = _int_list_to_subquery(numerals)
203 sql = sa.select(t.c.place_id, t.c.osm_id,
204 t.c.parent_place_id, t.c.address,
205 values.c.nr.label('housenumber'),
206 _interpolated_position(t, values.c.nr),
207 t.c.postcode, t.c.country_code)\
208 .where(t.c.place_id.in_(place_ids))\
209 .join(values, values.c.nr.between(t.c.startnumber, t.c.endnumber))
211 if details.geometry_output:
213 sql = _add_geometry_columns(sa.select(sub), sub.c.centroid, details)
215 for row in await conn.execute(sql):
216 result = nres.create_from_osmline_row(row, nres.SearchResult)
221 async def _get_tiger(conn: SearchConnection, place_ids: List[int],
222 numerals: List[int], osm_id: int,
223 details: SearchDetails) -> AsyncIterator[nres.SearchResult]:
225 values = _int_list_to_subquery(numerals)
226 sql = sa.select(t.c.place_id, t.c.parent_place_id,
227 sa.literal('W').label('osm_type'),
228 sa.literal(osm_id).label('osm_id'),
229 values.c.nr.label('housenumber'),
230 _interpolated_position(t, values.c.nr),
232 .where(t.c.place_id.in_(place_ids))\
233 .join(values, values.c.nr.between(t.c.startnumber, t.c.endnumber))
235 if details.geometry_output:
237 sql = _add_geometry_columns(sa.select(sub), sub.c.centroid, details)
239 for row in await conn.execute(sql):
240 result = nres.create_from_tiger_row(row, nres.SearchResult)
245 class AbstractSearch(abc.ABC):
246 """ Encapuslation of a single lookup in the database.
250 def __init__(self, penalty: float) -> None:
251 self.penalty = penalty
254 async def lookup(self, conn: SearchConnection,
255 details: SearchDetails) -> nres.SearchResults:
256 """ Find results for the search in the database.
260 class NearSearch(AbstractSearch):
261 """ Category search of a place type near the result of another search.
263 def __init__(self, penalty: float, categories: WeightedCategories,
264 search: AbstractSearch) -> None:
265 super().__init__(penalty)
267 self.categories = categories
270 async def lookup(self, conn: SearchConnection,
271 details: SearchDetails) -> nres.SearchResults:
272 """ Find results for the search in the database.
274 results = nres.SearchResults()
275 base = await self.search.lookup(conn, details)
280 base.sort(key=lambda r: (r.accuracy, r.rank_search))
281 max_accuracy = base[0].accuracy + 0.5
282 if base[0].rank_address == 0:
285 elif base[0].rank_address < 26:
287 max_rank = min(25, base[0].rank_address + 4)
291 base = nres.SearchResults(r for r in base if r.source_table == nres.SourceTable.PLACEX
292 and r.accuracy <= max_accuracy
293 and r.bbox and r.bbox.area < 20
294 and r.rank_address >= min_rank
295 and r.rank_address <= max_rank)
298 baseids = [b.place_id for b in base[:5] if b.place_id]
300 for category, penalty in self.categories:
301 await self.lookup_category(results, conn, baseids, category, penalty, details)
302 if len(results) >= details.max_results:
308 async def lookup_category(self, results: nres.SearchResults,
309 conn: SearchConnection, ids: List[int],
310 category: Tuple[str, str], penalty: float,
311 details: SearchDetails) -> None:
312 """ Find places of the given category near the list of
313 place ids and add the results to 'results'.
315 table = await conn.get_class_table(*category)
317 tgeom = conn.t.placex.alias('pgeom')
320 # No classtype table available, do a simplified lookup in placex.
321 table = conn.t.placex
322 sql = sa.select(table.c.place_id,
323 sa.func.min(tgeom.c.centroid.ST_Distance(table.c.centroid))
325 .join(tgeom, table.c.geometry.intersects(tgeom.c.centroid.ST_Expand(0.01)))\
326 .where(table.c.class_ == category[0])\
327 .where(table.c.type == category[1])
329 # Use classtype table. We can afford to use a larger
330 # radius for the lookup.
331 sql = sa.select(table.c.place_id,
332 sa.func.min(tgeom.c.centroid.ST_Distance(table.c.centroid))
335 table.c.centroid.ST_CoveredBy(
336 sa.case((sa.and_(tgeom.c.rank_address > 9,
337 tgeom.c.geometry.is_area()),
339 else_ = tgeom.c.centroid.ST_Expand(0.05))))
341 inner = sql.where(tgeom.c.place_id.in_(ids))\
342 .group_by(table.c.place_id).subquery()
345 sql = _select_placex(t).add_columns((-inner.c.dist).label('importance'))\
346 .join(inner, inner.c.place_id == t.c.place_id)\
347 .order_by(inner.c.dist)
349 sql = sql.where(no_index(t.c.rank_address).between(MIN_RANK_PARAM, MAX_RANK_PARAM))
350 if details.countries:
351 sql = sql.where(t.c.country_code.in_(COUNTRIES_PARAM))
353 sql = sql.where(_exclude_places(t))
354 if details.layers is not None:
355 sql = sql.where(_filter_by_layer(t, details.layers))
357 sql = sql.limit(LIMIT_PARAM)
358 for row in await conn.execute(sql, _details_to_bind_params(details)):
359 result = nres.create_from_placex_row(row, nres.SearchResult)
361 result.accuracy = self.penalty + penalty
362 result.bbox = Bbox.from_wkb(row.bbox)
363 results.append(result)
367 class PoiSearch(AbstractSearch):
368 """ Category search in a geographic area.
370 def __init__(self, sdata: SearchData) -> None:
371 super().__init__(sdata.penalty)
372 self.qualifiers = sdata.qualifiers
373 self.countries = sdata.countries
376 async def lookup(self, conn: SearchConnection,
377 details: SearchDetails) -> nres.SearchResults:
378 """ Find results for the search in the database.
380 bind_params = _details_to_bind_params(details)
383 rows: List[SaRow] = []
385 if details.near and details.near_radius is not None and details.near_radius < 0.2:
386 # simply search in placex table
387 def _base_query() -> SaSelect:
388 return _select_placex(t) \
389 .add_columns((-t.c.centroid.ST_Distance(NEAR_PARAM))
390 .label('importance'))\
391 .where(t.c.linked_place_id == None) \
392 .where(t.c.geometry.within_distance(NEAR_PARAM, NEAR_RADIUS_PARAM)) \
393 .order_by(t.c.centroid.ST_Distance(NEAR_PARAM)) \
396 classtype = self.qualifiers.values
397 if len(classtype) == 1:
398 cclass, ctype = classtype[0]
399 sql: SaLambdaSelect = sa.lambda_stmt(lambda: _base_query()
400 .where(t.c.class_ == cclass)
401 .where(t.c.type == ctype))
403 sql = _base_query().where(sa.or_(*(sa.and_(t.c.class_ == cls, t.c.type == typ)
404 for cls, typ in classtype)))
407 sql = sql.where(t.c.country_code.in_(self.countries.values))
409 if details.viewbox is not None and details.bounded_viewbox:
410 sql = sql.where(t.c.geometry.intersects(VIEWBOX_PARAM))
412 rows.extend(await conn.execute(sql, bind_params))
414 # use the class type tables
415 for category in self.qualifiers.values:
416 table = await conn.get_class_table(*category)
417 if table is not None:
418 sql = _select_placex(t)\
419 .add_columns(t.c.importance)\
420 .join(table, t.c.place_id == table.c.place_id)\
421 .where(t.c.class_ == category[0])\
422 .where(t.c.type == category[1])
424 if details.viewbox is not None and details.bounded_viewbox:
425 sql = sql.where(table.c.centroid.intersects(VIEWBOX_PARAM))
427 if details.near and details.near_radius is not None:
428 sql = sql.order_by(table.c.centroid.ST_Distance(NEAR_PARAM))\
429 .where(table.c.centroid.within_distance(NEAR_PARAM,
433 sql = sql.where(t.c.country_code.in_(self.countries.values))
435 sql = sql.limit(LIMIT_PARAM)
436 rows.extend(await conn.execute(sql, bind_params))
438 results = nres.SearchResults()
440 result = nres.create_from_placex_row(row, nres.SearchResult)
442 result.accuracy = self.penalty + self.qualifiers.get_penalty((row.class_, row.type))
443 result.bbox = Bbox.from_wkb(row.bbox)
444 results.append(result)
449 class CountrySearch(AbstractSearch):
450 """ Search for a country name or country code.
454 def __init__(self, sdata: SearchData) -> None:
455 super().__init__(sdata.penalty)
456 self.countries = sdata.countries
459 async def lookup(self, conn: SearchConnection,
460 details: SearchDetails) -> nres.SearchResults:
461 """ Find results for the search in the database.
465 ccodes = self.countries.values
466 sql = _select_placex(t)\
467 .add_columns(t.c.importance)\
468 .where(t.c.country_code.in_(ccodes))\
469 .where(t.c.rank_address == 4)
471 if details.geometry_output:
472 sql = _add_geometry_columns(sql, t.c.geometry, details)
475 sql = sql.where(_exclude_places(t))
477 sql = filter_by_area(sql, t, details)
479 results = nres.SearchResults()
480 for row in await conn.execute(sql, _details_to_bind_params(details)):
481 result = nres.create_from_placex_row(row, nres.SearchResult)
483 result.accuracy = self.penalty + self.countries.get_penalty(row.country_code, 5.0)
484 result.bbox = Bbox.from_wkb(row.bbox)
485 results.append(result)
488 results = await self.lookup_in_country_table(conn, details)
491 details.min_rank = min(5, details.max_rank)
492 details.max_rank = min(25, details.max_rank)
497 async def lookup_in_country_table(self, conn: SearchConnection,
498 details: SearchDetails) -> nres.SearchResults:
499 """ Look up the country in the fallback country tables.
501 # Avoid the fallback search when this is a more search. Country results
502 # usually are in the first batch of results and it is not possible
503 # to exclude these fallbacks.
505 return nres.SearchResults()
507 t = conn.t.country_name
508 tgrid = conn.t.country_grid
510 sql = sa.select(tgrid.c.country_code,
511 tgrid.c.geometry.ST_Centroid().ST_Collect().ST_Centroid()
513 tgrid.c.geometry.ST_Collect().ST_Expand(0).label('bbox'))\
514 .where(tgrid.c.country_code.in_(self.countries.values))\
515 .group_by(tgrid.c.country_code)
517 sql = filter_by_area(sql, tgrid, details, avoid_index=True)
519 sub = sql.subquery('grid')
521 sql = sa.select(t.c.country_code,
522 t.c.name.merge(t.c.derived_name).label('name'),
523 sub.c.centroid, sub.c.bbox)\
524 .join(sub, t.c.country_code == sub.c.country_code)
526 if details.geometry_output:
527 sql = _add_geometry_columns(sql, sub.c.centroid, details)
529 results = nres.SearchResults()
530 for row in await conn.execute(sql, _details_to_bind_params(details)):
531 result = nres.create_from_country_row(row, nres.SearchResult)
533 result.bbox = Bbox.from_wkb(row.bbox)
534 result.accuracy = self.penalty + self.countries.get_penalty(row.country_code, 5.0)
535 results.append(result)
541 class PostcodeSearch(AbstractSearch):
542 """ Search for a postcode.
544 def __init__(self, extra_penalty: float, sdata: SearchData) -> None:
545 super().__init__(sdata.penalty + extra_penalty)
546 self.countries = sdata.countries
547 self.postcodes = sdata.postcodes
548 self.lookups = sdata.lookups
549 self.rankings = sdata.rankings
552 async def lookup(self, conn: SearchConnection,
553 details: SearchDetails) -> nres.SearchResults:
554 """ Find results for the search in the database.
557 pcs = self.postcodes.values
559 sql = sa.select(t.c.place_id, t.c.parent_place_id,
560 t.c.rank_search, t.c.rank_address,
561 t.c.postcode, t.c.country_code,
562 t.c.geometry.label('centroid'))\
563 .where(t.c.postcode.in_(pcs))
565 if details.geometry_output:
566 sql = _add_geometry_columns(sql, t.c.geometry, details)
568 penalty: SaExpression = sa.literal(self.penalty)
570 if details.viewbox is not None and not details.bounded_viewbox:
571 penalty += sa.case((t.c.geometry.intersects(VIEWBOX_PARAM), 0.0),
572 (t.c.geometry.intersects(VIEWBOX2_PARAM), 0.5),
575 if details.near is not None:
576 sql = sql.order_by(t.c.geometry.ST_Distance(NEAR_PARAM))
578 sql = filter_by_area(sql, t, details)
581 sql = sql.where(t.c.country_code.in_(self.countries.values))
584 sql = sql.where(_exclude_places(t))
587 assert len(self.lookups) == 1
588 tsearch = conn.t.search_name
589 sql = sql.where(tsearch.c.place_id == t.c.parent_place_id)\
590 .where((tsearch.c.name_vector + tsearch.c.nameaddress_vector)
591 .contains(sa.type_coerce(self.lookups[0].tokens,
594 for ranking in self.rankings:
595 penalty += ranking.sql_penalty(conn.t.search_name)
596 penalty += sa.case(*((t.c.postcode == v, p) for v, p in self.postcodes),
600 sql = sql.add_columns(penalty.label('accuracy'))
601 sql = sql.order_by('accuracy').limit(LIMIT_PARAM)
603 results = nres.SearchResults()
604 for row in await conn.execute(sql, _details_to_bind_params(details)):
606 placex_sql = _select_placex(p).add_columns(p.c.importance)\
607 .where(sa.text("""class = 'boundary'
608 AND type = 'postal_code'
609 AND osm_type = 'R'"""))\
610 .where(p.c.country_code == row.country_code)\
611 .where(p.c.postcode == row.postcode)\
614 if details.geometry_output:
615 placex_sql = _add_geometry_columns(placex_sql, p.c.geometry, details)
617 for prow in await conn.execute(placex_sql, _details_to_bind_params(details)):
618 result = nres.create_from_placex_row(prow, nres.SearchResult)
619 if result is not None:
620 result.bbox = Bbox.from_wkb(prow.bbox)
623 result = nres.create_from_postcode_row(row, nres.SearchResult)
626 if result.place_id not in details.excluded:
627 result.accuracy = row.accuracy
628 results.append(result)
634 class PlaceSearch(AbstractSearch):
635 """ Generic search for an address or named place.
639 def __init__(self, extra_penalty: float, sdata: SearchData, expected_count: int) -> None:
640 super().__init__(sdata.penalty + extra_penalty)
641 self.countries = sdata.countries
642 self.postcodes = sdata.postcodes
643 self.housenumbers = sdata.housenumbers
644 self.qualifiers = sdata.qualifiers
645 self.lookups = sdata.lookups
646 self.rankings = sdata.rankings
647 self.expected_count = expected_count
650 def _inner_search_name_cte(self, conn: SearchConnection,
651 details: SearchDetails) -> 'sa.CTE':
652 """ Create a subquery that preselects the rows in the search_name
655 t = conn.t.search_name
657 penalty: SaExpression = sa.literal(self.penalty)
658 for ranking in self.rankings:
659 penalty += ranking.sql_penalty(t)
661 sql = sa.select(t.c.place_id, t.c.search_rank, t.c.address_rank,
662 t.c.country_code, t.c.centroid,
663 t.c.name_vector, t.c.nameaddress_vector,
664 sa.case((t.c.importance > 0, t.c.importance),
665 else_=0.40001-(sa.cast(t.c.search_rank, sa.Float())/75))
666 .label('importance'),
667 penalty.label('penalty'))
669 for lookup in self.lookups:
670 sql = sql.where(lookup.sql_condition(t))
673 sql = sql.where(t.c.country_code.in_(self.countries.values))
676 # if a postcode is given, don't search for state or country level objects
677 sql = sql.where(t.c.address_rank > 9)
678 if self.expected_count > 10000:
679 # Many results expected. Restrict by postcode.
680 tpc = conn.t.postcode
681 sql = sql.where(sa.select(tpc.c.postcode)
682 .where(tpc.c.postcode.in_(self.postcodes.values))
683 .where(t.c.centroid.within_distance(tpc.c.geometry, 0.4))
686 if details.viewbox is not None:
687 if details.bounded_viewbox:
688 sql = sql.where(t.c.centroid
689 .intersects(VIEWBOX_PARAM,
690 use_index=details.viewbox.area < 0.2))
691 elif not self.postcodes and not self.housenumbers and self.expected_count >= 10000:
692 sql = sql.where(t.c.centroid
693 .intersects(VIEWBOX2_PARAM,
694 use_index=details.viewbox.area < 0.5))
696 if details.near is not None and details.near_radius is not None:
697 if details.near_radius < 0.1:
698 sql = sql.where(t.c.centroid.within_distance(NEAR_PARAM,
701 sql = sql.where(t.c.centroid
702 .ST_Distance(NEAR_PARAM) < NEAR_RADIUS_PARAM)
704 if self.housenumbers:
705 sql = sql.where(t.c.address_rank.between(16, 30))
708 sql = sql.where(_exclude_places(t))
709 if details.min_rank > 0:
710 sql = sql.where(sa.or_(t.c.address_rank >= MIN_RANK_PARAM,
711 t.c.search_rank >= MIN_RANK_PARAM))
712 if details.max_rank < 30:
713 sql = sql.where(sa.or_(t.c.address_rank <= MAX_RANK_PARAM,
714 t.c.search_rank <= MAX_RANK_PARAM))
716 inner = sql.limit(10000).order_by(sa.desc(sa.text('importance'))).subquery()
718 sql = sa.select(inner.c.place_id, inner.c.search_rank, inner.c.address_rank,
719 inner.c.country_code, inner.c.centroid, inner.c.importance,
722 # If the query is not an address search or has a geographic preference,
723 # preselect most important items to restrict the number of places
724 # that need to be looked up in placex.
725 if not self.housenumbers\
726 and (details.viewbox is None or details.bounded_viewbox)\
727 and (details.near is None or details.near_radius is not None)\
728 and not self.qualifiers:
729 sql = sql.add_columns(sa.func.first_value(inner.c.penalty - inner.c.importance)
730 .over(order_by=inner.c.penalty - inner.c.importance)
731 .label('min_penalty'))
733 inner = sql.subquery()
735 sql = sa.select(inner.c.place_id, inner.c.search_rank, inner.c.address_rank,
736 inner.c.country_code, inner.c.centroid, inner.c.importance,
738 .where(inner.c.penalty - inner.c.importance < inner.c.min_penalty + 0.5)
740 return sql.cte('searches')
743 async def lookup(self, conn: SearchConnection,
744 details: SearchDetails) -> nres.SearchResults:
745 """ Find results for the search in the database.
748 tsearch = self._inner_search_name_cte(conn, details)
750 sql = _select_placex(t).join(tsearch, t.c.place_id == tsearch.c.place_id)
752 if details.geometry_output:
753 sql = _add_geometry_columns(sql, t.c.geometry, details)
755 penalty: SaExpression = tsearch.c.penalty
758 tpc = conn.t.postcode
759 pcs = self.postcodes.values
761 pc_near = sa.select(sa.func.min(tpc.c.geometry.ST_Distance(t.c.centroid)))\
762 .where(tpc.c.postcode.in_(pcs))\
764 penalty += sa.case((t.c.postcode.in_(pcs), 0.0),
765 else_=sa.func.coalesce(pc_near, cast(SaColumn, 2.0)))
767 if details.viewbox is not None and not details.bounded_viewbox:
768 penalty += sa.case((t.c.geometry.intersects(VIEWBOX_PARAM, use_index=False), 0.0),
769 (t.c.geometry.intersects(VIEWBOX2_PARAM, use_index=False), 0.5),
772 if details.near is not None:
773 sql = sql.add_columns((-tsearch.c.centroid.ST_Distance(NEAR_PARAM))
774 .label('importance'))
775 sql = sql.order_by(sa.desc(sa.text('importance')))
777 sql = sql.order_by(penalty - tsearch.c.importance)
778 sql = sql.add_columns(tsearch.c.importance)
781 sql = sql.add_columns(penalty.label('accuracy'))\
782 .order_by(sa.text('accuracy'))
784 if self.housenumbers:
785 hnr_list = '|'.join(self.housenumbers.values)
786 inner = sql.where(sa.or_(tsearch.c.address_rank < 30,
787 sa.func.RegexpWord(hnr_list, t.c.housenumber)))\
790 # Housenumbers from placex
791 thnr = conn.t.placex.alias('hnr')
792 pid_list = sa.func.ArrayAgg(thnr.c.place_id)
793 place_sql = sa.select(pid_list)\
794 .where(thnr.c.parent_place_id == inner.c.place_id)\
795 .where(sa.func.RegexpWord(hnr_list, thnr.c.housenumber))\
796 .where(thnr.c.linked_place_id == None)\
797 .where(thnr.c.indexed_status == 0)
800 place_sql = place_sql.where(thnr.c.place_id.not_in(sa.bindparam('excluded')))
802 place_sql = place_sql.where(self.qualifiers.sql_restrict(thnr))
804 numerals = [int(n) for n in self.housenumbers.values
805 if n.isdigit() and len(n) < 8]
806 interpol_sql: SaColumn
809 (not self.qualifiers or ('place', 'house') in self.qualifiers.values):
810 # Housenumbers from interpolations
811 interpol_sql = _make_interpolation_subquery(conn.t.osmline, inner,
813 # Housenumbers from Tiger
814 tiger_sql = sa.case((inner.c.country_code == 'us',
815 _make_interpolation_subquery(conn.t.tiger, inner,
819 interpol_sql = sa.null()
820 tiger_sql = sa.null()
822 unsort = sa.select(inner, place_sql.scalar_subquery().label('placex_hnr'),
823 interpol_sql.label('interpol_hnr'),
824 tiger_sql.label('tiger_hnr')).subquery('unsort')
825 sql = sa.select(unsort)\
826 .order_by(sa.case((unsort.c.placex_hnr != None, 1),
827 (unsort.c.interpol_hnr != None, 2),
828 (unsort.c.tiger_hnr != None, 3),
832 sql = sql.where(t.c.linked_place_id == None)\
833 .where(t.c.indexed_status == 0)
835 sql = sql.where(self.qualifiers.sql_restrict(t))
836 if details.layers is not None:
837 sql = sql.where(_filter_by_layer(t, details.layers))
839 sql = sql.limit(LIMIT_PARAM)
841 results = nres.SearchResults()
842 for row in await conn.execute(sql, _details_to_bind_params(details)):
843 result = nres.create_from_placex_row(row, nres.SearchResult)
845 result.bbox = Bbox.from_wkb(row.bbox)
846 result.accuracy = row.accuracy
847 if self.housenumbers and row.rank_address < 30:
849 subs = _get_placex_housenumbers(conn, row.placex_hnr, details)
850 elif row.interpol_hnr:
851 subs = _get_osmline(conn, row.interpol_hnr, numerals, details)
853 subs = _get_tiger(conn, row.tiger_hnr, numerals, row.osm_id, details)
858 async for sub in subs:
859 assert sub.housenumber
860 sub.accuracy = result.accuracy
861 if not any(nr in self.housenumbers.values
862 for nr in sub.housenumber.split(';')):
866 # Only add the street as a result, if it meets all other
868 if (not details.excluded or result.place_id not in details.excluded)\
869 and (not self.qualifiers or result.category in self.qualifiers.values)\
870 and result.rank_address >= details.min_rank:
871 result.accuracy += 1.0 # penalty for missing housenumber
872 results.append(result)
874 results.append(result)