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 nominatim_core.typing import SaFromClause, SaScalarSelect, SaColumn, \
16 SaExpression, SaSelect, SaLambdaSelect, SaRow, SaBind
17 from nominatim_core.db.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)
621 result = nres.create_from_postcode_row(row, nres.SearchResult)
624 if result.place_id not in details.excluded:
625 result.accuracy = row.accuracy
626 results.append(result)
632 class PlaceSearch(AbstractSearch):
633 """ Generic search for an address or named place.
637 def __init__(self, extra_penalty: float, sdata: SearchData, expected_count: int) -> None:
638 super().__init__(sdata.penalty + extra_penalty)
639 self.countries = sdata.countries
640 self.postcodes = sdata.postcodes
641 self.housenumbers = sdata.housenumbers
642 self.qualifiers = sdata.qualifiers
643 self.lookups = sdata.lookups
644 self.rankings = sdata.rankings
645 self.expected_count = expected_count
648 def _inner_search_name_cte(self, conn: SearchConnection,
649 details: SearchDetails) -> 'sa.CTE':
650 """ Create a subquery that preselects the rows in the search_name
653 t = conn.t.search_name
655 penalty: SaExpression = sa.literal(self.penalty)
656 for ranking in self.rankings:
657 penalty += ranking.sql_penalty(t)
659 sql = sa.select(t.c.place_id, t.c.search_rank, t.c.address_rank,
660 t.c.country_code, t.c.centroid,
661 t.c.name_vector, t.c.nameaddress_vector,
662 sa.case((t.c.importance > 0, t.c.importance),
663 else_=0.40001-(sa.cast(t.c.search_rank, sa.Float())/75))
664 .label('importance'),
665 penalty.label('penalty'))
667 for lookup in self.lookups:
668 sql = sql.where(lookup.sql_condition(t))
671 sql = sql.where(t.c.country_code.in_(self.countries.values))
674 # if a postcode is given, don't search for state or country level objects
675 sql = sql.where(t.c.address_rank > 9)
676 if self.expected_count > 10000:
677 # Many results expected. Restrict by postcode.
678 tpc = conn.t.postcode
679 sql = sql.where(sa.select(tpc.c.postcode)
680 .where(tpc.c.postcode.in_(self.postcodes.values))
681 .where(t.c.centroid.within_distance(tpc.c.geometry, 0.4))
684 if details.viewbox is not None:
685 if details.bounded_viewbox:
686 sql = sql.where(t.c.centroid
687 .intersects(VIEWBOX_PARAM,
688 use_index=details.viewbox.area < 0.2))
689 elif not self.postcodes and not self.housenumbers and self.expected_count >= 10000:
690 sql = sql.where(t.c.centroid
691 .intersects(VIEWBOX2_PARAM,
692 use_index=details.viewbox.area < 0.5))
694 if details.near is not None and details.near_radius is not None:
695 if details.near_radius < 0.1:
696 sql = sql.where(t.c.centroid.within_distance(NEAR_PARAM,
699 sql = sql.where(t.c.centroid
700 .ST_Distance(NEAR_PARAM) < NEAR_RADIUS_PARAM)
702 if self.housenumbers:
703 sql = sql.where(t.c.address_rank.between(16, 30))
706 sql = sql.where(_exclude_places(t))
707 if details.min_rank > 0:
708 sql = sql.where(sa.or_(t.c.address_rank >= MIN_RANK_PARAM,
709 t.c.search_rank >= MIN_RANK_PARAM))
710 if details.max_rank < 30:
711 sql = sql.where(sa.or_(t.c.address_rank <= MAX_RANK_PARAM,
712 t.c.search_rank <= MAX_RANK_PARAM))
714 inner = sql.limit(10000).order_by(sa.desc(sa.text('importance'))).subquery()
716 sql = sa.select(inner.c.place_id, inner.c.search_rank, inner.c.address_rank,
717 inner.c.country_code, inner.c.centroid, inner.c.importance,
720 # If the query is not an address search or has a geographic preference,
721 # preselect most important items to restrict the number of places
722 # that need to be looked up in placex.
723 if not self.housenumbers\
724 and (details.viewbox is None or details.bounded_viewbox)\
725 and (details.near is None or details.near_radius is not None)\
726 and not self.qualifiers:
727 sql = sql.add_columns(sa.func.first_value(inner.c.penalty - inner.c.importance)
728 .over(order_by=inner.c.penalty - inner.c.importance)
729 .label('min_penalty'))
731 inner = sql.subquery()
733 sql = sa.select(inner.c.place_id, inner.c.search_rank, inner.c.address_rank,
734 inner.c.country_code, inner.c.centroid, inner.c.importance,
736 .where(inner.c.penalty - inner.c.importance < inner.c.min_penalty + 0.5)
738 return sql.cte('searches')
741 async def lookup(self, conn: SearchConnection,
742 details: SearchDetails) -> nres.SearchResults:
743 """ Find results for the search in the database.
746 tsearch = self._inner_search_name_cte(conn, details)
748 sql = _select_placex(t).join(tsearch, t.c.place_id == tsearch.c.place_id)
750 if details.geometry_output:
751 sql = _add_geometry_columns(sql, t.c.geometry, details)
753 penalty: SaExpression = tsearch.c.penalty
756 tpc = conn.t.postcode
757 pcs = self.postcodes.values
759 pc_near = sa.select(sa.func.min(tpc.c.geometry.ST_Distance(t.c.centroid)))\
760 .where(tpc.c.postcode.in_(pcs))\
762 penalty += sa.case((t.c.postcode.in_(pcs), 0.0),
763 else_=sa.func.coalesce(pc_near, cast(SaColumn, 2.0)))
765 if details.viewbox is not None and not details.bounded_viewbox:
766 penalty += sa.case((t.c.geometry.intersects(VIEWBOX_PARAM, use_index=False), 0.0),
767 (t.c.geometry.intersects(VIEWBOX2_PARAM, use_index=False), 0.5),
770 if details.near is not None:
771 sql = sql.add_columns((-tsearch.c.centroid.ST_Distance(NEAR_PARAM))
772 .label('importance'))
773 sql = sql.order_by(sa.desc(sa.text('importance')))
775 sql = sql.order_by(penalty - tsearch.c.importance)
776 sql = sql.add_columns(tsearch.c.importance)
779 sql = sql.add_columns(penalty.label('accuracy'))\
780 .order_by(sa.text('accuracy'))
782 if self.housenumbers:
783 hnr_list = '|'.join(self.housenumbers.values)
784 inner = sql.where(sa.or_(tsearch.c.address_rank < 30,
785 sa.func.RegexpWord(hnr_list, t.c.housenumber)))\
788 # Housenumbers from placex
789 thnr = conn.t.placex.alias('hnr')
790 pid_list = sa.func.ArrayAgg(thnr.c.place_id)
791 place_sql = sa.select(pid_list)\
792 .where(thnr.c.parent_place_id == inner.c.place_id)\
793 .where(sa.func.RegexpWord(hnr_list, thnr.c.housenumber))\
794 .where(thnr.c.linked_place_id == None)\
795 .where(thnr.c.indexed_status == 0)
798 place_sql = place_sql.where(thnr.c.place_id.not_in(sa.bindparam('excluded')))
800 place_sql = place_sql.where(self.qualifiers.sql_restrict(thnr))
802 numerals = [int(n) for n in self.housenumbers.values
803 if n.isdigit() and len(n) < 8]
804 interpol_sql: SaColumn
807 (not self.qualifiers or ('place', 'house') in self.qualifiers.values):
808 # Housenumbers from interpolations
809 interpol_sql = _make_interpolation_subquery(conn.t.osmline, inner,
811 # Housenumbers from Tiger
812 tiger_sql = sa.case((inner.c.country_code == 'us',
813 _make_interpolation_subquery(conn.t.tiger, inner,
817 interpol_sql = sa.null()
818 tiger_sql = sa.null()
820 unsort = sa.select(inner, place_sql.scalar_subquery().label('placex_hnr'),
821 interpol_sql.label('interpol_hnr'),
822 tiger_sql.label('tiger_hnr')).subquery('unsort')
823 sql = sa.select(unsort)\
824 .order_by(sa.case((unsort.c.placex_hnr != None, 1),
825 (unsort.c.interpol_hnr != None, 2),
826 (unsort.c.tiger_hnr != None, 3),
830 sql = sql.where(t.c.linked_place_id == None)\
831 .where(t.c.indexed_status == 0)
833 sql = sql.where(self.qualifiers.sql_restrict(t))
834 if details.layers is not None:
835 sql = sql.where(_filter_by_layer(t, details.layers))
837 sql = sql.limit(LIMIT_PARAM)
839 results = nres.SearchResults()
840 for row in await conn.execute(sql, _details_to_bind_params(details)):
841 result = nres.create_from_placex_row(row, nres.SearchResult)
843 result.bbox = Bbox.from_wkb(row.bbox)
844 result.accuracy = row.accuracy
845 if self.housenumbers and row.rank_address < 30:
847 subs = _get_placex_housenumbers(conn, row.placex_hnr, details)
848 elif row.interpol_hnr:
849 subs = _get_osmline(conn, row.interpol_hnr, numerals, details)
851 subs = _get_tiger(conn, row.tiger_hnr, numerals, row.osm_id, details)
856 async for sub in subs:
857 assert sub.housenumber
858 sub.accuracy = result.accuracy
859 if not any(nr in self.housenumbers.values
860 for nr in sub.housenumber.split(';')):
864 # Only add the street as a result, if it meets all other
866 if (not details.excluded or result.place_id not in details.excluded)\
867 and (not self.qualifiers or result.category in self.qualifiers.values)\
868 and result.rank_address >= details.min_rank:
869 result.accuracy += 1.0 # penalty for missing housenumber
870 results.append(result)
872 results.append(result)