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))