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
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 class SourceTable(enum.Enum):
31 """ Enumeration of kinds of results.
40 @dataclasses.dataclass
42 """ Detailed information about a related place.
44 place_id: Optional[int]
45 osm_object: Optional[Tuple[str, int]]
46 category: Tuple[str, str]
48 extratags: Optional[Dict[str, str]]
50 admin_level: Optional[int]
56 local_name: Optional[str] = None
59 class AddressLines(List[AddressLine]):
60 """ Sequence of address lines order in descending order by their rank.
63 def localize(self, locales: Locales) -> List[str]:
64 """ Set the local name of address parts according to the chosen
65 locale. Return the list of local names without duplications.
67 Only address parts that are marked as isaddress are localized
70 label_parts: List[str] = []
73 if line.isaddress and line.names:
74 line.local_name = locales.display_name(line.names)
75 if not label_parts or label_parts[-1] != line.local_name:
76 label_parts.append(line.local_name)
82 @dataclasses.dataclass
84 """ Detailed information about a search term.
88 word: Optional[str] = None
91 WordInfos = Sequence[WordInfo]
94 @dataclasses.dataclass
96 """ Data class collecting information common to all
97 types of search results.
99 source_table: SourceTable
100 category: Tuple[str, str]
103 place_id : Optional[int] = None
104 osm_object: Optional[Tuple[str, int]] = None
106 names: Optional[Dict[str, str]] = None
107 address: Optional[Dict[str, str]] = None
108 extratags: Optional[Dict[str, str]] = None
110 housenumber: Optional[str] = None
111 postcode: Optional[str] = None
112 wikipedia: Optional[str] = None
114 rank_address: int = 30
115 rank_search: int = 30
116 importance: Optional[float] = None
118 country_code: Optional[str] = None
120 address_rows: Optional[AddressLines] = None
121 linked_rows: Optional[AddressLines] = None
122 parented_rows: Optional[AddressLines] = None
123 name_keywords: Optional[WordInfos] = None
124 address_keywords: Optional[WordInfos] = None
126 geometry: Dict[str, str] = dataclasses.field(default_factory=dict)
129 def lat(self) -> float:
130 """ Get the latitude (or y) of the center point of the place.
132 return self.centroid[1]
136 def lon(self) -> float:
137 """ Get the longitude (or x) of the center point of the place.
139 return self.centroid[0]
142 def calculated_importance(self) -> float:
143 """ Get a valid importance value. This is either the stored importance
144 of the value or an artificial value computed from the place's
147 return self.importance or (0.7500001 - (self.rank_search/40.0))
149 BaseResultT = TypeVar('BaseResultT', bound=BaseResult)
151 @dataclasses.dataclass
152 class DetailedResult(BaseResult):
153 """ A search result with more internal information from the database
156 parent_place_id: Optional[int] = None
157 linked_place_id: Optional[int] = None
158 admin_level: int = 15
159 indexed_date: Optional[dt.datetime] = None
162 @dataclasses.dataclass
163 class ReverseResult(BaseResult):
164 """ A search result for reverse geocoding.
166 distance: Optional[float] = None
167 bbox: Optional[Bbox] = None
170 class ReverseResults(List[ReverseResult]):
171 """ Sequence of reverse lookup results ordered by distance.
172 May be empty when no result was found.
176 def _filter_geometries(row: SaRow) -> Dict[str, str]:
177 return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
178 if k.startswith('geometry_')}
181 def create_from_placex_row(row: Optional[SaRow],
182 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
183 """ Construct a new result and add the data from the result row
184 from the placex table. 'class_type' defines the type of result
185 to return. Returns None if the row is None.
190 return class_type(source_table=SourceTable.PLACEX,
191 place_id=row.place_id,
192 osm_object=(row.osm_type, row.osm_id),
193 category=(row.class_, row.type),
196 extratags=row.extratags,
197 housenumber=row.housenumber,
198 postcode=row.postcode,
199 wikipedia=row.wikipedia,
200 rank_address=row.rank_address,
201 rank_search=row.rank_search,
202 importance=row.importance,
203 country_code=row.country_code,
204 centroid=Point.from_wkb(row.centroid.data),
205 geometry=_filter_geometries(row))
208 def create_from_osmline_row(row: Optional[SaRow],
209 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
210 """ Construct a new result and add the data from the result row
211 from the address interpolation table osmline. 'class_type' defines
212 the type of result to return. Returns None if the row is None.
214 If the row contains a housenumber, then the housenumber is filled out.
215 Otherwise the result contains the interpolation information in extratags.
220 hnr = getattr(row, 'housenumber', None)
222 res = class_type(source_table=SourceTable.OSMLINE,
223 place_id=row.place_id,
224 osm_object=('W', row.osm_id),
225 category=('place', 'houses' if hnr is None else 'house'),
227 postcode=row.postcode,
228 country_code=row.country_code,
229 centroid=Point.from_wkb(row.centroid.data),
230 geometry=_filter_geometries(row))
233 res.extratags = {'startnumber': str(row.startnumber),
234 'endnumber': str(row.endnumber),
235 'step': str(row.step)}
237 res.housenumber = str(hnr)
242 def create_from_tiger_row(row: Optional[SaRow],
243 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
244 """ Construct a new result and add the data from the result row
245 from the Tiger data interpolation table. 'class_type' defines
246 the type of result to return. Returns None if the row is None.
248 If the row contains a housenumber, then the housenumber is filled out.
249 Otherwise the result contains the interpolation information in extratags.
254 hnr = getattr(row, 'housenumber', None)
256 res = class_type(source_table=SourceTable.TIGER,
257 place_id=row.place_id,
258 category=('place', 'houses' if hnr is None else 'house'),
259 postcode=row.postcode,
261 centroid=Point.from_wkb(row.centroid.data),
262 geometry=_filter_geometries(row))
265 res.extratags = {'startnumber': str(row.startnumber),
266 'endnumber': str(row.endnumber),
267 'step': str(row.step)}
269 res.housenumber = str(hnr)
274 def create_from_postcode_row(row: Optional[SaRow],
275 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
276 """ Construct a new result and add the data from the result row
277 from the postcode table. 'class_type' defines
278 the type of result to return. Returns None if the row is None.
283 return class_type(source_table=SourceTable.POSTCODE,
284 place_id=row.place_id,
285 category=('place', 'postcode'),
286 names={'ref': row.postcode},
287 rank_search=row.rank_search,
288 rank_address=row.rank_address,
289 country_code=row.country_code,
290 centroid=Point.from_wkb(row.centroid.data),
291 geometry=_filter_geometries(row))
294 async def add_result_details(conn: SearchConnection, result: BaseResult,
295 details: LookupDetails) -> None:
296 """ Retrieve more details from the database according to the
297 parameters specified in 'details'.
299 log().section('Query details for result')
300 if details.address_details:
301 log().comment('Query address details')
302 await complete_address_details(conn, result)
303 if details.linked_places:
304 log().comment('Query linked places')
305 await complete_linked_places(conn, result)
306 if details.parented_places:
307 log().comment('Query parent places')
308 await complete_parented_places(conn, result)
310 log().comment('Query keywords')
311 await complete_keywords(conn, result)
314 def _result_row_to_address_row(row: SaRow) -> AddressLine:
315 """ Create a new AddressLine from the results of a datbase query.
317 extratags: Dict[str, str] = getattr(row, 'extratags', {})
318 if 'place_type' in row:
319 extratags['place_type'] = row.place_type
322 if getattr(row, 'housenumber', None) is not None:
325 names['housenumber'] = row.housenumber
327 return AddressLine(place_id=row.place_id,
328 osm_object=None if row.osm_type is None else (row.osm_type, row.osm_id),
329 category=(getattr(row, 'class'), row.type),
332 admin_level=row.admin_level,
333 fromarea=row.fromarea,
334 isaddress=getattr(row, 'isaddress', True),
335 rank_address=row.rank_address,
336 distance=row.distance)
339 async def complete_address_details(conn: SearchConnection, result: BaseResult) -> None:
340 """ Retrieve information about places that make up the address of the result.
343 if result.source_table in (SourceTable.TIGER, SourceTable.OSMLINE):
344 if result.housenumber is not None:
345 housenumber = int(result.housenumber)
346 elif result.extratags is not None and 'startnumber' in result.extratags:
347 # details requests do not come with a specific house number
348 housenumber = int(result.extratags['startnumber'])
350 sfn = sa.func.get_addressdata(result.place_id, housenumber)\
351 .table_valued( # type: ignore[no-untyped-call]
352 sa.column('place_id', type_=sa.Integer),
354 sa.column('osm_id', type_=sa.BigInteger),
355 sa.column('name', type_=conn.t.types.Composite),
356 'class', 'type', 'place_type',
357 sa.column('admin_level', type_=sa.Integer),
358 sa.column('fromarea', type_=sa.Boolean),
359 sa.column('isaddress', type_=sa.Boolean),
360 sa.column('rank_address', type_=sa.SmallInteger),
361 sa.column('distance', type_=sa.Float))
362 sql = sa.select(sfn).order_by(sa.column('rank_address').desc(),
363 sa.column('isaddress').desc())
365 result.address_rows = AddressLines()
366 for row in await conn.execute(sql):
367 result.address_rows.append(_result_row_to_address_row(row))
370 # pylint: disable=consider-using-f-string
371 def _placex_select_address_row(conn: SearchConnection,
372 centroid: Point) -> SaSelect:
374 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
375 t.c.class_.label('class'), t.c.type,
376 t.c.admin_level, t.c.housenumber,
377 sa.literal_column("""ST_GeometryType(geometry) in
378 ('ST_Polygon','ST_MultiPolygon')""").label('fromarea'),
381 """ST_DistanceSpheroid(geometry, 'SRID=4326;POINT(%f %f)'::geometry,
382 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]')
383 """ % centroid).label('distance'))
386 async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None:
387 """ Retrieve information about places that link to the result.
389 result.linked_rows = AddressLines()
390 if result.source_table != SourceTable.PLACEX:
393 sql = _placex_select_address_row(conn, result.centroid)\
394 .where(conn.t.placex.c.linked_place_id == result.place_id)
396 for row in await conn.execute(sql):
397 result.linked_rows.append(_result_row_to_address_row(row))
400 async def complete_keywords(conn: SearchConnection, result: BaseResult) -> None:
401 """ Retrieve information about the search terms used for this place.
403 t = conn.t.search_name
404 sql = sa.select(t.c.name_vector, t.c.nameaddress_vector)\
405 .where(t.c.place_id == result.place_id)
407 result.name_keywords = []
408 result.address_keywords = []
409 for name_tokens, address_tokens in await conn.execute(sql):
411 sel = sa.select(t.c.word_id, t.c.word_token, t.c.word)
413 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(name_tokens))):
414 result.name_keywords.append(WordInfo(*row))
416 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(address_tokens))):
417 result.address_keywords.append(WordInfo(*row))
420 async def complete_parented_places(conn: SearchConnection, result: BaseResult) -> None:
421 """ Retrieve information about places that the result provides the
424 result.parented_rows = AddressLines()
425 if result.source_table != SourceTable.PLACEX:
428 sql = _placex_select_address_row(conn, result.centroid)\
429 .where(conn.t.placex.c.parent_place_id == result.place_id)\
430 .where(conn.t.placex.c.rank_search == 30)
432 for row in await conn.execute(sql):
433 result.parented_rows.append(_result_row_to_address_row(row))