1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2023 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Dataclasses for search results and helper functions to fill them.
10 Data classes are part of the public API while the functions are for
11 internal use only. That's why they are implemented as free-standing functions
12 instead of member functions.
14 from typing import Optional, Tuple, Dict, Sequence, TypeVar, Type, List, Any
19 import sqlalchemy as sa
21 from nominatim.typing import SaSelect, SaRow
22 from nominatim.api.types import Point, Bbox, LookupDetails
23 from nominatim.api.connection import SearchConnection
24 from nominatim.api.logging import log
25 from nominatim.api.localization import Locales
27 # This file defines complex result data classes.
28 # pylint: disable=too-many-instance-attributes
30 def _mingle_name_tags(names: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]:
31 """ Mix-in names from linked places, so that they show up
32 as standard names where necessary.
38 for k, v in names.items():
39 if k.startswith('_place_'):
41 out[k if outkey in names else outkey] = v
48 class SourceTable(enum.Enum):
49 """ Enumeration of kinds of results.
58 @dataclasses.dataclass
60 """ Detailed information about a related place.
62 place_id: Optional[int]
63 osm_object: Optional[Tuple[str, int]]
64 category: Tuple[str, str]
66 extratags: Optional[Dict[str, str]]
68 admin_level: Optional[int]
74 local_name: Optional[str] = None
77 class AddressLines(List[AddressLine]):
78 """ Sequence of address lines order in descending order by their rank.
81 def localize(self, locales: Locales) -> List[str]:
82 """ Set the local name of address parts according to the chosen
83 locale. Return the list of local names without duplications.
85 Only address parts that are marked as isaddress are localized
88 label_parts: List[str] = []
91 if line.isaddress and line.names:
92 line.local_name = locales.display_name(line.names)
93 if not label_parts or label_parts[-1] != line.local_name:
94 label_parts.append(line.local_name)
100 @dataclasses.dataclass
102 """ Detailed information about a search term.
106 word: Optional[str] = None
109 WordInfos = Sequence[WordInfo]
112 @dataclasses.dataclass
114 """ Data class collecting information common to all
115 types of search results.
117 source_table: SourceTable
118 category: Tuple[str, str]
121 place_id : Optional[int] = None
122 osm_object: Optional[Tuple[str, int]] = None
124 locale_name: Optional[str] = None
125 display_name: Optional[str] = None
127 names: Optional[Dict[str, str]] = None
128 address: Optional[Dict[str, str]] = None
129 extratags: Optional[Dict[str, str]] = None
131 housenumber: Optional[str] = None
132 postcode: Optional[str] = None
133 wikipedia: Optional[str] = None
135 rank_address: int = 30
136 rank_search: int = 30
137 importance: Optional[float] = None
139 country_code: Optional[str] = None
141 address_rows: Optional[AddressLines] = None
142 linked_rows: Optional[AddressLines] = None
143 parented_rows: Optional[AddressLines] = None
144 name_keywords: Optional[WordInfos] = None
145 address_keywords: Optional[WordInfos] = None
147 geometry: Dict[str, str] = dataclasses.field(default_factory=dict)
150 def lat(self) -> float:
151 """ Get the latitude (or y) of the center point of the place.
153 return self.centroid[1]
157 def lon(self) -> float:
158 """ Get the longitude (or x) of the center point of the place.
160 return self.centroid[0]
163 def calculated_importance(self) -> float:
164 """ Get a valid importance value. This is either the stored importance
165 of the value or an artificial value computed from the place's
168 return self.importance or (0.7500001 - (self.rank_search/40.0))
171 def localize(self, locales: Locales) -> None:
172 """ Fill the locale_name and the display_name field for the
173 place and, if available, its address information.
175 self.locale_name = locales.display_name(self.names)
176 if self.address_rows:
177 self.display_name = ', '.join(self.address_rows.localize(locales))
179 self.display_name = self.locale_name
183 BaseResultT = TypeVar('BaseResultT', bound=BaseResult)
185 @dataclasses.dataclass
186 class DetailedResult(BaseResult):
187 """ A search result with more internal information from the database
190 parent_place_id: Optional[int] = None
191 linked_place_id: Optional[int] = None
192 admin_level: int = 15
193 indexed_date: Optional[dt.datetime] = None
196 @dataclasses.dataclass
197 class ReverseResult(BaseResult):
198 """ A search result for reverse geocoding.
200 distance: Optional[float] = None
201 bbox: Optional[Bbox] = None
204 class ReverseResults(List[ReverseResult]):
205 """ Sequence of reverse lookup results ordered by distance.
206 May be empty when no result was found.
210 @dataclasses.dataclass
211 class SearchResult(BaseResult):
212 """ A search result for forward geocoding.
214 bbox: Optional[Bbox] = None
215 accuracy: float = 0.0
219 def ranking(self) -> float:
220 """ Return the ranking, a combined measure of accuracy and importance.
222 return (self.accuracy if self.accuracy is not None else 1) \
223 - self.calculated_importance()
226 class SearchResults(List[SearchResult]):
227 """ Sequence of forward lookup results ordered by relevance.
228 May be empty when no result was found.
232 def _filter_geometries(row: SaRow) -> Dict[str, str]:
233 return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
234 if k.startswith('geometry_')}
237 def create_from_placex_row(row: Optional[SaRow],
238 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
239 """ Construct a new result and add the data from the result row
240 from the placex table. 'class_type' defines the type of result
241 to return. Returns None if the row is None.
246 return class_type(source_table=SourceTable.PLACEX,
247 place_id=row.place_id,
248 osm_object=(row.osm_type, row.osm_id),
249 category=(row.class_, row.type),
250 names=_mingle_name_tags(row.name),
252 extratags=row.extratags,
253 housenumber=row.housenumber,
254 postcode=row.postcode,
255 wikipedia=row.wikipedia,
256 rank_address=row.rank_address,
257 rank_search=row.rank_search,
258 importance=row.importance,
259 country_code=row.country_code,
260 centroid=Point.from_wkb(row.centroid.data),
261 geometry=_filter_geometries(row))
264 def create_from_osmline_row(row: Optional[SaRow],
265 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
266 """ Construct a new result and add the data from the result row
267 from the address interpolation table osmline. 'class_type' defines
268 the type of result to return. Returns None if the row is None.
270 If the row contains a housenumber, then the housenumber is filled out.
271 Otherwise the result contains the interpolation information in extratags.
276 hnr = getattr(row, 'housenumber', None)
278 res = class_type(source_table=SourceTable.OSMLINE,
279 place_id=row.place_id,
280 osm_object=('W', row.osm_id),
281 category=('place', 'houses' if hnr is None else 'house'),
283 postcode=row.postcode,
284 country_code=row.country_code,
285 centroid=Point.from_wkb(row.centroid.data),
286 geometry=_filter_geometries(row))
289 res.extratags = {'startnumber': str(row.startnumber),
290 'endnumber': str(row.endnumber),
291 'step': str(row.step)}
293 res.housenumber = str(hnr)
298 def create_from_tiger_row(row: Optional[SaRow],
299 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
300 """ Construct a new result and add the data from the result row
301 from the Tiger data interpolation table. 'class_type' defines
302 the type of result to return. Returns None if the row is None.
304 If the row contains a housenumber, then the housenumber is filled out.
305 Otherwise the result contains the interpolation information in extratags.
310 hnr = getattr(row, 'housenumber', None)
312 res = class_type(source_table=SourceTable.TIGER,
313 place_id=row.place_id,
314 osm_object=(row.osm_type, row.osm_id),
315 category=('place', 'houses' if hnr is None else 'house'),
316 postcode=row.postcode,
318 centroid=Point.from_wkb(row.centroid.data),
319 geometry=_filter_geometries(row))
322 res.extratags = {'startnumber': str(row.startnumber),
323 'endnumber': str(row.endnumber),
324 'step': str(row.step)}
326 res.housenumber = str(hnr)
331 def create_from_postcode_row(row: Optional[SaRow],
332 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
333 """ Construct a new result and add the data from the result row
334 from the postcode table. 'class_type' defines
335 the type of result to return. Returns None if the row is None.
340 return class_type(source_table=SourceTable.POSTCODE,
341 place_id=row.place_id,
342 category=('place', 'postcode'),
343 names={'ref': row.postcode},
344 rank_search=row.rank_search,
345 rank_address=row.rank_address,
346 country_code=row.country_code,
347 centroid=Point.from_wkb(row.centroid.data),
348 geometry=_filter_geometries(row))
351 def create_from_country_row(row: Optional[SaRow],
352 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
353 """ Construct a new result and add the data from the result row
354 from the fallback country tables. 'class_type' defines
355 the type of result to return. Returns None if the row is None.
360 return class_type(source_table=SourceTable.COUNTRY,
361 category=('place', 'country'),
362 centroid=Point.from_wkb(row.centroid.data),
364 rank_address=4, rank_search=4,
365 country_code=row.country_code)
368 async def add_result_details(conn: SearchConnection, results: List[BaseResultT],
369 details: LookupDetails) -> None:
370 """ Retrieve more details from the database according to the
371 parameters specified in 'details'.
374 log().section('Query details for result')
375 if details.address_details:
376 log().comment('Query address details')
377 await complete_address_details(conn, results)
378 if details.linked_places:
379 log().comment('Query linked places')
380 for result in results:
381 await complete_linked_places(conn, result)
382 if details.parented_places:
383 log().comment('Query parent places')
384 for result in results:
385 await complete_parented_places(conn, result)
387 log().comment('Query keywords')
388 for result in results:
389 await complete_keywords(conn, result)
392 def _result_row_to_address_row(row: SaRow) -> AddressLine:
393 """ Create a new AddressLine from the results of a datbase query.
395 extratags: Dict[str, str] = getattr(row, 'extratags', {})
396 if hasattr(row, 'place_type') and row.place_type:
397 extratags['place'] = row.place_type
399 names = _mingle_name_tags(row.name) or {}
400 if getattr(row, 'housenumber', None) is not None:
401 names['housenumber'] = row.housenumber
403 return AddressLine(place_id=row.place_id,
404 osm_object=None if row.osm_type is None else (row.osm_type, row.osm_id),
405 category=(getattr(row, 'class'), row.type),
408 admin_level=row.admin_level,
409 fromarea=row.fromarea,
410 isaddress=getattr(row, 'isaddress', True),
411 rank_address=row.rank_address,
412 distance=row.distance)
415 async def complete_address_details(conn: SearchConnection, results: List[BaseResultT]) -> None:
416 """ Retrieve information about places that make up the address of the result.
418 def get_hnr(result: BaseResult) -> Tuple[int, int]:
420 if result.source_table in (SourceTable.TIGER, SourceTable.OSMLINE):
421 if result.housenumber is not None:
422 housenumber = int(result.housenumber)
423 elif result.extratags is not None and 'startnumber' in result.extratags:
424 # details requests do not come with a specific house number
425 housenumber = int(result.extratags['startnumber'])
426 assert result.place_id
427 return result.place_id, housenumber
429 data: List[Tuple[Any, ...]] = [get_hnr(r) for r in results if r.place_id]
434 values = sa.values(sa.column('place_id', type_=sa.Integer),
435 sa.column('housenumber', type_=sa.Integer),
437 literal_binds=True).data(data)
439 sfn = sa.func.get_addressdata(values.c.place_id, values.c.housenumber)\
440 .table_valued( # type: ignore[no-untyped-call]
441 sa.column('place_id', type_=sa.Integer),
443 sa.column('osm_id', type_=sa.BigInteger),
444 sa.column('name', type_=conn.t.types.Composite),
445 'class', 'type', 'place_type',
446 sa.column('admin_level', type_=sa.Integer),
447 sa.column('fromarea', type_=sa.Boolean),
448 sa.column('isaddress', type_=sa.Boolean),
449 sa.column('rank_address', type_=sa.SmallInteger),
450 sa.column('distance', type_=sa.Float),
451 joins_implicitly=True)
453 sql = sa.select(values.c.place_id.label('result_place_id'), sfn)\
454 .order_by(values.c.place_id,
455 sa.column('rank_address').desc(),
456 sa.column('isaddress').desc())
458 current_result = None
459 for row in await conn.execute(sql):
460 if current_result is None or row.result_place_id != current_result.place_id:
461 for result in results:
462 if result.place_id == row.result_place_id:
463 current_result = result
467 current_result.address_rows = AddressLines()
468 current_result.address_rows.append(_result_row_to_address_row(row))
471 # pylint: disable=consider-using-f-string
472 def _placex_select_address_row(conn: SearchConnection,
473 centroid: Point) -> SaSelect:
475 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
476 t.c.class_.label('class'), t.c.type,
477 t.c.admin_level, t.c.housenumber,
478 sa.literal_column("""ST_GeometryType(geometry) in
479 ('ST_Polygon','ST_MultiPolygon')""").label('fromarea'),
482 """ST_DistanceSpheroid(geometry, 'SRID=4326;POINT(%f %f)'::geometry,
483 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]')
484 """ % centroid).label('distance'))
487 async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None:
488 """ Retrieve information about places that link to the result.
490 result.linked_rows = AddressLines()
491 if result.source_table != SourceTable.PLACEX:
494 sql = _placex_select_address_row(conn, result.centroid)\
495 .where(conn.t.placex.c.linked_place_id == result.place_id)
497 for row in await conn.execute(sql):
498 result.linked_rows.append(_result_row_to_address_row(row))
501 async def complete_keywords(conn: SearchConnection, result: BaseResult) -> None:
502 """ Retrieve information about the search terms used for this place.
504 Requires that the query analyzer was initialised to get access to
507 t = conn.t.search_name
508 sql = sa.select(t.c.name_vector, t.c.nameaddress_vector)\
509 .where(t.c.place_id == result.place_id)
511 result.name_keywords = []
512 result.address_keywords = []
514 t = conn.t.meta.tables['word']
515 sel = sa.select(t.c.word_id, t.c.word_token, t.c.word)
517 for name_tokens, address_tokens in await conn.execute(sql):
518 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(name_tokens))):
519 result.name_keywords.append(WordInfo(*row))
521 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(address_tokens))):
522 result.address_keywords.append(WordInfo(*row))
525 async def complete_parented_places(conn: SearchConnection, result: BaseResult) -> None:
526 """ Retrieve information about places that the result provides the
529 result.parented_rows = AddressLines()
530 if result.source_table != SourceTable.PLACEX:
533 sql = _placex_select_address_row(conn, result.centroid)\
534 .where(conn.t.placex.c.parent_place_id == result.place_id)\
535 .where(conn.t.placex.c.rank_search == 30)
537 for row in await conn.execute(sql):
538 result.parented_rows.append(_result_row_to_address_row(row))