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
19 import sqlalchemy as sa
21 from nominatim.typing import SaSelect, SaRow
22 from nominatim.api.types import Point, LookupDetails
23 from nominatim.api.connection import SearchConnection
24 from nominatim.api.logging import log
26 # This file defines complex result data classes.
27 # pylint: disable=too-many-instance-attributes
29 class SourceTable(enum.Enum):
30 """ Enumeration of kinds of results.
39 @dataclasses.dataclass
41 """ Detailed information about a related place.
43 place_id: Optional[int]
44 osm_object: Optional[Tuple[str, int]]
45 category: Tuple[str, str]
47 extratags: Optional[Dict[str, str]]
49 admin_level: Optional[int]
56 AddressLines = Sequence[AddressLine]
59 @dataclasses.dataclass
61 """ Detailed information about a search term.
65 word: Optional[str] = None
68 WordInfos = Sequence[WordInfo]
71 @dataclasses.dataclass
73 """ Data class collecting all available information about a search result.
75 source_table: SourceTable
76 category: Tuple[str, str]
79 place_id : Optional[int] = None
80 parent_place_id: Optional[int] = None
81 linked_place_id: Optional[int] = None
82 osm_object: Optional[Tuple[str, int]] = None
85 names: Optional[Dict[str, str]] = None
86 address: Optional[Dict[str, str]] = None
87 extratags: Optional[Dict[str, str]] = None
89 housenumber: Optional[str] = None
90 postcode: Optional[str] = None
91 wikipedia: Optional[str] = None
93 rank_address: int = 30
95 importance: Optional[float] = None
97 country_code: Optional[str] = None
99 indexed_date: Optional[dt.datetime] = None
101 address_rows: Optional[AddressLines] = None
102 linked_rows: Optional[AddressLines] = None
103 parented_rows: Optional[AddressLines] = None
104 name_keywords: Optional[WordInfos] = None
105 address_keywords: Optional[WordInfos] = None
107 geometry: Dict[str, str] = dataclasses.field(default_factory=dict)
109 def __post_init__(self) -> None:
110 if self.indexed_date is not None and self.indexed_date.tzinfo is None:
111 self.indexed_date = self.indexed_date.replace(tzinfo=dt.timezone.utc)
114 def lat(self) -> float:
115 """ Get the latitude (or y) of the center point of the place.
117 return self.centroid[1]
121 def lon(self) -> float:
122 """ Get the longitude (or x) of the center point of the place.
124 return self.centroid[0]
127 def calculated_importance(self) -> float:
128 """ Get a valid importance value. This is either the stored importance
129 of the value or an artificial value computed from the place's
132 return self.importance or (0.7500001 - (self.rank_search/40.0))
135 def _filter_geometries(row: SaRow) -> Dict[str, str]:
136 return {k[9:]: v for k, v in row._mapping.items() # pylint: disable=W0212
137 if k.startswith('geometry_')}
140 def create_from_placex_row(row: SaRow) -> SearchResult:
141 """ Construct a new SearchResult and add the data from the result row
142 from the placex table.
144 return SearchResult(source_table=SourceTable.PLACEX,
145 place_id=row.place_id,
146 parent_place_id=row.parent_place_id,
147 linked_place_id=row.linked_place_id,
148 osm_object=(row.osm_type, row.osm_id),
149 category=(row.class_, row.type),
150 admin_level=row.admin_level,
153 extratags=row.extratags,
154 housenumber=row.housenumber,
155 postcode=row.postcode,
156 wikipedia=row.wikipedia,
157 rank_address=row.rank_address,
158 rank_search=row.rank_search,
159 importance=row.importance,
160 country_code=row.country_code,
161 indexed_date=getattr(row, 'indexed_date'),
162 centroid=Point.from_wkb(row.centroid.data),
163 geometry=_filter_geometries(row))
166 def create_from_osmline_row(row: SaRow) -> SearchResult:
167 """ Construct a new SearchResult and add the data from the result row
168 from the osmline table.
170 return SearchResult(source_table=SourceTable.OSMLINE,
171 place_id=row.place_id,
172 parent_place_id=row.parent_place_id,
173 osm_object=('W', row.osm_id),
174 category=('place', 'houses'),
176 postcode=row.postcode,
177 extratags={'startnumber': str(row.startnumber),
178 'endnumber': str(row.endnumber),
179 'step': str(row.step)},
180 country_code=row.country_code,
181 indexed_date=getattr(row, 'indexed_date'),
182 centroid=Point.from_wkb(row.centroid.data),
183 geometry=_filter_geometries(row))
186 def create_from_tiger_row(row: SaRow) -> SearchResult:
187 """ Construct a new SearchResult and add the data from the result row
188 from the Tiger table.
190 return SearchResult(source_table=SourceTable.TIGER,
191 place_id=row.place_id,
192 parent_place_id=row.parent_place_id,
193 category=('place', 'houses'),
194 postcode=row.postcode,
195 extratags={'startnumber': str(row.startnumber),
196 'endnumber': str(row.endnumber),
197 'step': str(row.step)},
199 centroid=Point.from_wkb(row.centroid.data),
200 geometry=_filter_geometries(row))
203 def create_from_postcode_row(row: SaRow) -> SearchResult:
204 """ Construct a new SearchResult and add the data from the result row
205 from the postcode centroid table.
207 return SearchResult(source_table=SourceTable.POSTCODE,
208 place_id=row.place_id,
209 parent_place_id=row.parent_place_id,
210 category=('place', 'postcode'),
211 names={'ref': row.postcode},
212 rank_search=row.rank_search,
213 rank_address=row.rank_address,
214 country_code=row.country_code,
215 centroid=Point.from_wkb(row.centroid.data),
216 indexed_date=row.indexed_date,
217 geometry=_filter_geometries(row))
220 async def add_result_details(conn: SearchConnection, result: SearchResult,
221 details: LookupDetails) -> None:
222 """ Retrieve more details from the database according to the
223 parameters specified in 'details'.
225 log().section('Query details for result')
226 if details.address_details:
227 log().comment('Query address details')
228 await complete_address_details(conn, result)
229 if details.linked_places:
230 log().comment('Query linked places')
231 await complete_linked_places(conn, result)
232 if details.parented_places:
233 log().comment('Query parent places')
234 await complete_parented_places(conn, result)
236 log().comment('Query keywords')
237 await complete_keywords(conn, result)
240 def _result_row_to_address_row(row: SaRow) -> AddressLine:
241 """ Create a new AddressLine from the results of a datbase query.
243 extratags: Dict[str, str] = getattr(row, 'extratags', {})
244 if 'place_type' in row:
245 extratags['place_type'] = row.place_type
248 if getattr(row, 'housenumber', None) is not None:
251 names['housenumber'] = row.housenumber
253 return AddressLine(place_id=row.place_id,
254 osm_object=None if row.osm_type is None else (row.osm_type, row.osm_id),
255 category=(getattr(row, 'class'), row.type),
258 admin_level=row.admin_level,
259 fromarea=row.fromarea,
260 isaddress=getattr(row, 'isaddress', True),
261 rank_address=row.rank_address,
262 distance=row.distance)
265 async def complete_address_details(conn: SearchConnection, result: SearchResult) -> None:
266 """ Retrieve information about places that make up the address of the result.
269 if result.source_table in (SourceTable.TIGER, SourceTable.OSMLINE):
270 if result.housenumber is not None:
271 housenumber = int(result.housenumber)
272 elif result.extratags is not None and 'startnumber' in result.extratags:
273 # details requests do not come with a specific house number
274 housenumber = int(result.extratags['startnumber'])
276 sfn = sa.func.get_addressdata(result.place_id, housenumber)\
277 .table_valued( # type: ignore[no-untyped-call]
278 sa.column('place_id', type_=sa.Integer),
280 sa.column('osm_id', type_=sa.BigInteger),
281 sa.column('name', type_=conn.t.types.Composite),
282 'class', 'type', 'place_type',
283 sa.column('admin_level', type_=sa.Integer),
284 sa.column('fromarea', type_=sa.Boolean),
285 sa.column('isaddress', type_=sa.Boolean),
286 sa.column('rank_address', type_=sa.SmallInteger),
287 sa.column('distance', type_=sa.Float))
288 sql = sa.select(sfn).order_by(sa.column('rank_address').desc(),
289 sa.column('isaddress').desc())
291 result.address_rows = []
292 for row in await conn.execute(sql):
293 result.address_rows.append(_result_row_to_address_row(row))
295 # pylint: disable=consider-using-f-string
296 def _placex_select_address_row(conn: SearchConnection,
297 centroid: Point) -> SaSelect:
299 return sa.select(t.c.place_id, t.c.osm_type, t.c.osm_id, t.c.name,
300 t.c.class_.label('class'), t.c.type,
301 t.c.admin_level, t.c.housenumber,
302 sa.literal_column("""ST_GeometryType(geometry) in
303 ('ST_Polygon','ST_MultiPolygon')""").label('fromarea'),
306 """ST_DistanceSpheroid(geometry, 'SRID=4326;POINT(%f %f)'::geometry,
307 'SPHEROID["WGS 84",6378137,298.257223563, AUTHORITY["EPSG","7030"]]')
308 """ % centroid).label('distance'))
311 async def complete_linked_places(conn: SearchConnection, result: SearchResult) -> None:
312 """ Retrieve information about places that link to the result.
314 result.linked_rows = []
315 if result.source_table != SourceTable.PLACEX:
318 sql = _placex_select_address_row(conn, result.centroid)\
319 .where(conn.t.placex.c.linked_place_id == result.place_id)
321 for row in await conn.execute(sql):
322 result.linked_rows.append(_result_row_to_address_row(row))
325 async def complete_keywords(conn: SearchConnection, result: SearchResult) -> None:
326 """ Retrieve information about the search terms used for this place.
328 t = conn.t.search_name
329 sql = sa.select(t.c.name_vector, t.c.nameaddress_vector)\
330 .where(t.c.place_id == result.place_id)
332 result.name_keywords = []
333 result.address_keywords = []
334 for name_tokens, address_tokens in await conn.execute(sql):
336 sel = sa.select(t.c.word_id, t.c.word_token, t.c.word)
338 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(name_tokens))):
339 result.name_keywords.append(WordInfo(*row))
341 for row in await conn.execute(sel.where(t.c.word_id == sa.any_(address_tokens))):
342 result.address_keywords.append(WordInfo(*row))
345 async def complete_parented_places(conn: SearchConnection, result: SearchResult) -> None:
346 """ Retrieve information about places that the result provides the
349 result.parented_rows = []
350 if result.source_table != SourceTable.PLACEX:
353 sql = _placex_select_address_row(conn, result.centroid)\
354 .where(conn.t.placex.c.parent_place_id == result.place_id)\
355 .where(conn.t.placex.c.rank_search == 30)
357 for row in await conn.execute(sql):
358 result.parented_rows.append(_result_row_to_address_row(row))