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