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 osm_object=(row.osm_type, row.osm_id),
259 category=('place', 'houses' if hnr is None else 'house'),
260 postcode=row.postcode,
262 centroid=Point.from_wkb(row.centroid.data),
263 geometry=_filter_geometries(row))
266 res.extratags = {'startnumber': str(row.startnumber),
267 'endnumber': str(row.endnumber),
268 'step': str(row.step)}
270 res.housenumber = str(hnr)
275 def create_from_postcode_row(row: Optional[SaRow],
276 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
277 """ Construct a new result and add the data from the result row
278 from the postcode table. 'class_type' defines
279 the type of result to return. Returns None if the row is None.
284 return class_type(source_table=SourceTable.POSTCODE,
285 place_id=row.place_id,
286 category=('place', 'postcode'),
287 names={'ref': row.postcode},
288 rank_search=row.rank_search,
289 rank_address=row.rank_address,
290 country_code=row.country_code,
291 centroid=Point.from_wkb(row.centroid.data),
292 geometry=_filter_geometries(row))
295 async def add_result_details(conn: SearchConnection, result: BaseResult,
296 details: LookupDetails) -> None:
297 """ Retrieve more details from the database according to the
298 parameters specified in 'details'.
300 log().section('Query details for result')
301 if details.address_details:
302 log().comment('Query address details')
303 await complete_address_details(conn, result)
304 if details.linked_places:
305 log().comment('Query linked places')
306 await complete_linked_places(conn, result)
307 if details.parented_places:
308 log().comment('Query parent places')
309 await complete_parented_places(conn, result)
311 log().comment('Query keywords')
312 await complete_keywords(conn, result)
315 def _result_row_to_address_row(row: SaRow) -> AddressLine:
316 """ Create a new AddressLine from the results of a datbase query.
318 extratags: Dict[str, str] = getattr(row, 'extratags', {})
319 if hasattr(row, 'place_type') and row.place_type:
320 extratags['place'] = row.place_type
323 if getattr(row, 'housenumber', None) is not None:
326 names['housenumber'] = row.housenumber
328 return AddressLine(place_id=row.place_id,
329 osm_object=None if row.osm_type is None else (row.osm_type, row.osm_id),
330 category=(getattr(row, 'class'), row.type),
333 admin_level=row.admin_level,
334 fromarea=row.fromarea,
335 isaddress=getattr(row, 'isaddress', True),
336 rank_address=row.rank_address,
337 distance=row.distance)
340 async def complete_address_details(conn: SearchConnection, result: BaseResult) -> None:
341 """ Retrieve information about places that make up the address of the result.
344 if result.source_table in (SourceTable.TIGER, SourceTable.OSMLINE):
345 if result.housenumber is not None:
346 housenumber = int(result.housenumber)
347 elif result.extratags is not None and 'startnumber' in result.extratags:
348 # details requests do not come with a specific house number
349 housenumber = int(result.extratags['startnumber'])
351 sfn = sa.func.get_addressdata(result.place_id, housenumber)\
352 .table_valued( # type: ignore[no-untyped-call]
353 sa.column('place_id', type_=sa.Integer),
355 sa.column('osm_id', type_=sa.BigInteger),
356 sa.column('name', type_=conn.t.types.Composite),
357 'class', 'type', 'place_type',
358 sa.column('admin_level', type_=sa.Integer),
359 sa.column('fromarea', type_=sa.Boolean),
360 sa.column('isaddress', type_=sa.Boolean),
361 sa.column('rank_address', type_=sa.SmallInteger),
362 sa.column('distance', type_=sa.Float))
363 sql = sa.select(sfn).order_by(sa.column('rank_address').desc(),
364 sa.column('isaddress').desc())
366 result.address_rows = AddressLines()
367 for row in await conn.execute(sql):
368 result.address_rows.append(_result_row_to_address_row(row))
371 # pylint: disable=consider-using-f-string
372 def _placex_select_address_row(conn: SearchConnection,
373 centroid: Point) -> SaSelect:
375 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
376 t.c.class_.label('class'), t.c.type,
377 t.c.admin_level, t.c.housenumber,
378 sa.literal_column("""ST_GeometryType(geometry) in
379 ('ST_Polygon','ST_MultiPolygon')""").label('fromarea'),
382 """ST_DistanceSpheroid(geometry, 'SRID=4326;POINT(%f %f)'::geometry,
383 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]')
384 """ % centroid).label('distance'))
387 async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None:
388 """ Retrieve information about places that link to the result.
390 result.linked_rows = AddressLines()
391 if result.source_table != SourceTable.PLACEX:
394 sql = _placex_select_address_row(conn, result.centroid)\
395 .where(conn.t.placex.c.linked_place_id == result.place_id)
397 for row in await conn.execute(sql):
398 result.linked_rows.append(_result_row_to_address_row(row))
401 async def complete_keywords(conn: SearchConnection, result: BaseResult) -> None:
402 """ Retrieve information about the search terms used for this place.
404 t = conn.t.search_name
405 sql = sa.select(t.c.name_vector, t.c.nameaddress_vector)\
406 .where(t.c.place_id == result.place_id)
408 result.name_keywords = []
409 result.address_keywords = []
410 for name_tokens, address_tokens in await conn.execute(sql):
412 sel = sa.select(t.c.word_id, t.c.word_token, t.c.word)
414 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(name_tokens))):
415 result.name_keywords.append(WordInfo(*row))
417 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(address_tokens))):
418 result.address_keywords.append(WordInfo(*row))
421 async def complete_parented_places(conn: SearchConnection, result: BaseResult) -> None:
422 """ Retrieve information about places that the result provides the
425 result.parented_rows = AddressLines()
426 if result.source_table != SourceTable.PLACEX:
429 sql = _placex_select_address_row(conn, result.centroid)\
430 .where(conn.t.placex.c.parent_place_id == result.place_id)\
431 .where(conn.t.placex.c.rank_search == 30)
433 for row in await conn.execute(sql):
434 result.parented_rows.append(_result_row_to_address_row(row))