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 @dataclasses.dataclass
177 class SearchResult(BaseResult):
178 """ A search result for forward geocoding.
180 bbox: Optional[Bbox] = None
183 class SearchResults(List[SearchResult]):
184 """ Sequence of forward lookup results ordered by relevance.
185 May be empty when no result was found.
189 def _filter_geometries(row: SaRow) -> Dict[str, str]:
190 return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
191 if k.startswith('geometry_')}
194 def create_from_placex_row(row: Optional[SaRow],
195 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
196 """ Construct a new result and add the data from the result row
197 from the placex table. 'class_type' defines the type of result
198 to return. Returns None if the row is None.
203 return class_type(source_table=SourceTable.PLACEX,
204 place_id=row.place_id,
205 osm_object=(row.osm_type, row.osm_id),
206 category=(row.class_, row.type),
209 extratags=row.extratags,
210 housenumber=row.housenumber,
211 postcode=row.postcode,
212 wikipedia=row.wikipedia,
213 rank_address=row.rank_address,
214 rank_search=row.rank_search,
215 importance=row.importance,
216 country_code=row.country_code,
217 centroid=Point.from_wkb(row.centroid.data),
218 geometry=_filter_geometries(row))
221 def create_from_osmline_row(row: Optional[SaRow],
222 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
223 """ Construct a new result and add the data from the result row
224 from the address interpolation table osmline. 'class_type' defines
225 the type of result to return. Returns None if the row is None.
227 If the row contains a housenumber, then the housenumber is filled out.
228 Otherwise the result contains the interpolation information in extratags.
233 hnr = getattr(row, 'housenumber', None)
235 res = class_type(source_table=SourceTable.OSMLINE,
236 place_id=row.place_id,
237 osm_object=('W', row.osm_id),
238 category=('place', 'houses' if hnr is None else 'house'),
240 postcode=row.postcode,
241 country_code=row.country_code,
242 centroid=Point.from_wkb(row.centroid.data),
243 geometry=_filter_geometries(row))
246 res.extratags = {'startnumber': str(row.startnumber),
247 'endnumber': str(row.endnumber),
248 'step': str(row.step)}
250 res.housenumber = str(hnr)
255 def create_from_tiger_row(row: Optional[SaRow],
256 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
257 """ Construct a new result and add the data from the result row
258 from the Tiger data interpolation table. 'class_type' defines
259 the type of result to return. Returns None if the row is None.
261 If the row contains a housenumber, then the housenumber is filled out.
262 Otherwise the result contains the interpolation information in extratags.
267 hnr = getattr(row, 'housenumber', None)
269 res = class_type(source_table=SourceTable.TIGER,
270 place_id=row.place_id,
271 osm_object=(row.osm_type, row.osm_id),
272 category=('place', 'houses' if hnr is None else 'house'),
273 postcode=row.postcode,
275 centroid=Point.from_wkb(row.centroid.data),
276 geometry=_filter_geometries(row))
279 res.extratags = {'startnumber': str(row.startnumber),
280 'endnumber': str(row.endnumber),
281 'step': str(row.step)}
283 res.housenumber = str(hnr)
288 def create_from_postcode_row(row: Optional[SaRow],
289 class_type: Type[BaseResultT]) -> Optional[BaseResultT]:
290 """ Construct a new result and add the data from the result row
291 from the postcode table. 'class_type' defines
292 the type of result to return. Returns None if the row is None.
297 return class_type(source_table=SourceTable.POSTCODE,
298 place_id=row.place_id,
299 category=('place', 'postcode'),
300 names={'ref': row.postcode},
301 rank_search=row.rank_search,
302 rank_address=row.rank_address,
303 country_code=row.country_code,
304 centroid=Point.from_wkb(row.centroid.data),
305 geometry=_filter_geometries(row))
308 async def add_result_details(conn: SearchConnection, result: BaseResult,
309 details: LookupDetails) -> None:
310 """ Retrieve more details from the database according to the
311 parameters specified in 'details'.
313 log().section('Query details for result')
314 if details.address_details:
315 log().comment('Query address details')
316 await complete_address_details(conn, result)
317 if details.linked_places:
318 log().comment('Query linked places')
319 await complete_linked_places(conn, result)
320 if details.parented_places:
321 log().comment('Query parent places')
322 await complete_parented_places(conn, result)
324 log().comment('Query keywords')
325 await complete_keywords(conn, result)
328 def _result_row_to_address_row(row: SaRow) -> AddressLine:
329 """ Create a new AddressLine from the results of a datbase query.
331 extratags: Dict[str, str] = getattr(row, 'extratags', {})
332 if hasattr(row, 'place_type') and row.place_type:
333 extratags['place'] = row.place_type
336 if getattr(row, 'housenumber', None) is not None:
339 names['housenumber'] = row.housenumber
341 return AddressLine(place_id=row.place_id,
342 osm_object=None if row.osm_type is None else (row.osm_type, row.osm_id),
343 category=(getattr(row, 'class'), row.type),
346 admin_level=row.admin_level,
347 fromarea=row.fromarea,
348 isaddress=getattr(row, 'isaddress', True),
349 rank_address=row.rank_address,
350 distance=row.distance)
353 async def complete_address_details(conn: SearchConnection, result: BaseResult) -> None:
354 """ Retrieve information about places that make up the address of the result.
357 if result.source_table in (SourceTable.TIGER, SourceTable.OSMLINE):
358 if result.housenumber is not None:
359 housenumber = int(result.housenumber)
360 elif result.extratags is not None and 'startnumber' in result.extratags:
361 # details requests do not come with a specific house number
362 housenumber = int(result.extratags['startnumber'])
364 sfn = sa.func.get_addressdata(result.place_id, housenumber)\
365 .table_valued( # type: ignore[no-untyped-call]
366 sa.column('place_id', type_=sa.Integer),
368 sa.column('osm_id', type_=sa.BigInteger),
369 sa.column('name', type_=conn.t.types.Composite),
370 'class', 'type', 'place_type',
371 sa.column('admin_level', type_=sa.Integer),
372 sa.column('fromarea', type_=sa.Boolean),
373 sa.column('isaddress', type_=sa.Boolean),
374 sa.column('rank_address', type_=sa.SmallInteger),
375 sa.column('distance', type_=sa.Float))
376 sql = sa.select(sfn).order_by(sa.column('rank_address').desc(),
377 sa.column('isaddress').desc())
379 result.address_rows = AddressLines()
380 for row in await conn.execute(sql):
381 result.address_rows.append(_result_row_to_address_row(row))
384 # pylint: disable=consider-using-f-string
385 def _placex_select_address_row(conn: SearchConnection,
386 centroid: Point) -> SaSelect:
388 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
389 t.c.class_.label('class'), t.c.type,
390 t.c.admin_level, t.c.housenumber,
391 sa.literal_column("""ST_GeometryType(geometry) in
392 ('ST_Polygon','ST_MultiPolygon')""").label('fromarea'),
395 """ST_DistanceSpheroid(geometry, 'SRID=4326;POINT(%f %f)'::geometry,
396 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]')
397 """ % centroid).label('distance'))
400 async def complete_linked_places(conn: SearchConnection, result: BaseResult) -> None:
401 """ Retrieve information about places that link to the result.
403 result.linked_rows = AddressLines()
404 if result.source_table != SourceTable.PLACEX:
407 sql = _placex_select_address_row(conn, result.centroid)\
408 .where(conn.t.placex.c.linked_place_id == result.place_id)
410 for row in await conn.execute(sql):
411 result.linked_rows.append(_result_row_to_address_row(row))
414 async def complete_keywords(conn: SearchConnection, result: BaseResult) -> None:
415 """ Retrieve information about the search terms used for this place.
417 t = conn.t.search_name
418 sql = sa.select(t.c.name_vector, t.c.nameaddress_vector)\
419 .where(t.c.place_id == result.place_id)
421 result.name_keywords = []
422 result.address_keywords = []
423 for name_tokens, address_tokens in await conn.execute(sql):
425 sel = sa.select(t.c.word_id, t.c.word_token, t.c.word)
427 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(name_tokens))):
428 result.name_keywords.append(WordInfo(*row))
430 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(address_tokens))):
431 result.address_keywords.append(WordInfo(*row))
434 async def complete_parented_places(conn: SearchConnection, result: BaseResult) -> None:
435 """ Retrieve information about places that the result provides the
438 result.parented_rows = AddressLines()
439 if result.source_table != SourceTable.PLACEX:
442 sql = _placex_select_address_row(conn, result.centroid)\
443 .where(conn.t.placex.c.parent_place_id == result.place_id)\
444 .where(conn.t.placex.c.rank_search == 30)
446 for row in await conn.execute(sql):
447 result.parented_rows.append(_result_row_to_address_row(row))