1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2024 by the Nominatim developer community.
 
   6 # For a full list of authors see the git log.
 
   8 from itertools import chain
 
  11 from psycopg import sql as pysql
 
  13 from place_inserter import PlaceColumn
 
  14 from table_compare import NominatimID, DBRow
 
  16 from nominatim_db.indexer import indexer
 
  17 from nominatim_db.tokenizer import factory as tokenizer_factory
 
  19 def check_database_integrity(context):
 
  20     """ Check some generic constraints on the tables.
 
  22     with context.db.cursor(row_factory=psycopg.rows.tuple_row) as cur:
 
  23         # place_addressline should not have duplicate (place_id, address_place_id)
 
  24         cur.execute("""SELECT count(*) FROM
 
  25                         (SELECT place_id, address_place_id, count(*) as c
 
  26                          FROM place_addressline GROUP BY place_id, address_place_id) x
 
  28         assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
 
  30         # word table must not have empty word_tokens
 
  31         cur.execute("SELECT count(*) FROM word WHERE word_token = ''")
 
  32         assert cur.fetchone()[0] == 0, "Empty word tokens found in word table"
 
  36 ################################ GIVEN ##################################
 
  38 @given("the (?P<named>named )?places")
 
  39 def add_data_to_place_table(context, named):
 
  40     """ Add entries into the place table. 'named places' makes sure that
 
  41         the entries get a random name when none is explicitly given.
 
  43     with context.db.cursor() as cur:
 
  44         cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert')
 
  45         for row in context.table:
 
  46             PlaceColumn(context).add_row(row, named is not None).db_insert(cur)
 
  47         cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert')
 
  49 @given("the relations")
 
  50 def add_data_to_planet_relations(context):
 
  51     """ Add entries into the osm2pgsql relation middle table. This is needed
 
  52         for tests on data that looks up members.
 
  54     with context.db.cursor() as cur:
 
  55         cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
 
  57         if row is None or row['value'] == '1':
 
  58             for r in context.table:
 
  64                     for m in r['members'].split(','):
 
  67                             parts.insert(last_node, int(mid.oid))
 
  71                             parts.insert(last_way, int(mid.oid))
 
  74                             parts.append(int(mid.oid))
 
  76                         members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
 
  80                 tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
 
  82                 cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags)
 
  83                                VALUES (%s, %s, %s, %s, %s, %s)""",
 
  84                             (r['id'], last_node, last_way, parts, members, list(tags)))
 
  86             for r in context.table:
 
  89                     for m in r['members'].split(','):
 
  91                         members.append({'ref': mid.oid, 'role': mid.cls or '', 'type': mid.typ})
 
  95                 tags = {h[5:]: r[h] for h in r.headings if h.startswith("tags+")}
 
  97                 cur.execute("""INSERT INTO planet_osm_rels (id, tags, members)
 
  98                                VALUES (%s, %s, %s)""",
 
  99                             (r['id'], psycopg.types.json.Json(tags),
 
 100                              psycopg.types.json.Json(members)))
 
 103 def add_data_to_planet_ways(context):
 
 104     """ Add entries into the osm2pgsql way middle table. This is necessary for
 
 105         tests on that that looks up node ids in this table.
 
 107     with context.db.cursor() as cur:
 
 108         cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
 
 110         json_tags = row is not None and row['value'] != '1'
 
 111         for r in context.table:
 
 113                 tags = psycopg.types.json.Json({h[5:]: r[h] for h in r.headings if h.startswith("tags+")})
 
 115                 tags = list(chain.from_iterable([(h[5:], r[h])
 
 116                                                  for h in r.headings if h.startswith("tags+")]))
 
 117             nodes = [ int(x.strip()) for x in r['nodes'].split(',') ]
 
 119             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
 
 120                         (r['id'], nodes, tags))
 
 122 ################################ WHEN ##################################
 
 125 def import_and_index_data_from_place_table(context):
 
 126     """ Import data previously set up in the place table.
 
 128     context.nominatim.run_nominatim('import', '--continue', 'load-data',
 
 129                                               '--index-noanalyse', '-q',
 
 132     check_database_integrity(context)
 
 134     # Remove the output of the input, when all was right. Otherwise it will be
 
 135     # output when there are errors that had nothing to do with the import
 
 137     context.log_capture.buffer.clear()
 
 139 @when("updating places")
 
 140 def update_place_table(context):
 
 141     """ Update the place table with the given data. Also runs all triggers
 
 142         related to updates and reindexes the new data.
 
 144     context.nominatim.run_nominatim('refresh', '--functions')
 
 145     with context.db.cursor() as cur:
 
 146         for row in context.table:
 
 147             col = PlaceColumn(context).add_row(row, False)
 
 150         cur.execute('SELECT flush_deleted_places()')
 
 152     context.nominatim.reindex_placex(context.db)
 
 153     check_database_integrity(context)
 
 155     # Remove the output of the input, when all was right. Otherwise it will be
 
 156     # output when there are errors that had nothing to do with the import
 
 158     context.log_capture.buffer.clear()
 
 161 @when("updating postcodes")
 
 162 def update_postcodes(context):
 
 163     """ Rerun the calculation of postcodes.
 
 165     context.nominatim.run_nominatim('refresh', '--postcodes')
 
 167 @when("marking for delete (?P<oids>.*)")
 
 168 def delete_places(context, oids):
 
 169     """ Remove entries from the place table. Multiple ids may be given
 
 170         separated by commas. Also runs all triggers
 
 171         related to updates and reindexes the new data.
 
 173     context.nominatim.run_nominatim('refresh', '--functions')
 
 174     with context.db.cursor() as cur:
 
 175         cur.execute('TRUNCATE place_to_be_deleted')
 
 176         for oid in oids.split(','):
 
 177             NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
 
 178         cur.execute('SELECT flush_deleted_places()')
 
 180     context.nominatim.reindex_placex(context.db)
 
 182     # Remove the output of the input, when all was right. Otherwise it will be
 
 183     # output when there are errors that had nothing to do with the import
 
 185     context.log_capture.buffer.clear()
 
 187 ################################ THEN ##################################
 
 189 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
 
 190 def check_place_contents(context, table, exact):
 
 191     """ Check contents of place/placex tables. Each row represents a table row
 
 192         and all data must match. Data not present in the expected table, may
 
 193         be arbitrary. The rows are identified via the 'object' column which must
 
 194         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
 
 195         rows match (for example because 'class' was left out and there are
 
 196         multiple entries for the given OSM object) then all must match. All
 
 197         expected rows are expected to be present with at least one database row.
 
 198         When 'exactly' is given, there must not be additional rows in the database.
 
 200     with context.db.cursor() as cur:
 
 201         expected_content = set()
 
 202         for row in context.table:
 
 203             nid = NominatimID(row['object'])
 
 204             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
 
 205             if table == 'placex':
 
 206                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
 
 207             query += " FROM %s WHERE {}" % (table, )
 
 208             nid.query_osm_id(cur, query)
 
 209             assert cur.rowcount > 0, "No rows found for " + row['object']
 
 213                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
 
 215                 DBRow(nid, res, context).assert_row(row, ['object'])
 
 218             cur.execute(pysql.SQL('SELECT osm_type, osm_id, class from')
 
 219                         + pysql.Identifier(table))
 
 220             actual = set([(r['osm_type'], r['osm_id'], r['class']) for r in cur])
 
 221             assert expected_content == actual, \
 
 222                    f"Missing entries: {expected_content - actual}\n" \
 
 223                    f"Not expected in table: {actual - expected_content}"
 
 226 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
 
 227 def check_place_has_entry(context, table, oid):
 
 228     """ Ensure that no database row for the given object exists. The ID
 
 229         must be of the form '<NRW><osm id>[:<class>]'.
 
 231     with context.db.cursor() as cur:
 
 232         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
 
 233         assert cur.rowcount == 0, \
 
 234                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 237 @then("search_name contains(?P<exclude> not)?")
 
 238 def check_search_name_contents(context, exclude):
 
 239     """ Check contents of place/placex tables. Each row represents a table row
 
 240         and all data must match. Data not present in the expected table, may
 
 241         be arbitrary. The rows are identified via the 'object' column which must
 
 242         have an identifier of the form '<NRW><osm id>[:<class>]'. All
 
 243         expected rows are expected to be present with at least one database row.
 
 245     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
 
 247     with tokenizer.name_analyzer() as analyzer:
 
 248         with context.db.cursor() as cur:
 
 249             for row in context.table:
 
 250                 nid = NominatimID(row['object'])
 
 251                 nid.row_by_place_id(cur, 'search_name',
 
 252                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
 
 253                 assert cur.rowcount > 0, "No rows found for " + row['object']
 
 256                     db_row = DBRow(nid, res, context)
 
 257                     for name, value in zip(row.headings, row.cells):
 
 258                         if name in ('name_vector', 'nameaddress_vector'):
 
 259                             items = [x.strip() for x in value.split(',')]
 
 260                             tokens = analyzer.get_word_token_info(items)
 
 263                                 assert len(tokens) >= len(items), \
 
 264                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
 
 265                             for word, token, wid in tokens:
 
 267                                     assert wid not in res[name], \
 
 268                                            "Found term for {}/{}: {}".format(nid, name, wid)
 
 270                                     assert wid in res[name], \
 
 271                                            "Missing term for {}/{}: {}".format(nid, name, wid)
 
 272                         elif name != 'object':
 
 273                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
 
 275 @then("search_name has no entry for (?P<oid>.*)")
 
 276 def check_search_name_has_entry(context, oid):
 
 277     """ Check that there is noentry in the search_name table for the given
 
 278         objects. IDs are in format '<NRW><osm id>[:<class>]'.
 
 280     with context.db.cursor() as cur:
 
 281         NominatimID(oid).row_by_place_id(cur, 'search_name')
 
 283         assert cur.rowcount == 0, \
 
 284                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 286 @then("location_postcode contains exactly")
 
 287 def check_location_postcode(context):
 
 288     """ Check full contents for location_postcode table. Each row represents a table row
 
 289         and all data must match. Data not present in the expected table, may
 
 290         be arbitrary. The rows are identified via 'country' and 'postcode' columns.
 
 291         All rows must be present as excepted and there must not be additional
 
 294     with context.db.cursor() as cur:
 
 295         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
 
 296         assert cur.rowcount == len(list(context.table)), \
 
 297             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
 
 301             key = (row['country_code'], row['postcode'])
 
 302             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
 
 303             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
 
 305         for row in context.table:
 
 306             db_row = results.get((row['country'],row['postcode']))
 
 307             assert db_row is not None, \
 
 308                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
 
 310             db_row.assert_row(row, ('country', 'postcode'))
 
 312 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
 
 313 def check_word_table_for_postcodes(context, exclude, postcodes):
 
 314     """ Check that the tokenizer produces postcode tokens for the given
 
 315         postcodes. The postcodes are a comma-separated list of postcodes.
 
 318     nctx = context.nominatim
 
 319     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
 
 320     with tokenizer.name_analyzer() as ana:
 
 321         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
 
 325     with context.db.cursor() as cur:
 
 326         cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
 
 329         found = [row['word'] for row in cur]
 
 330         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
 
 333         assert len(found) == 0, f"Unexpected postcodes: {found}"
 
 335         assert set(found) == set(plist), \
 
 336         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
 
 338 @then("place_addressline contains")
 
 339 def check_place_addressline(context):
 
 340     """ Check the contents of the place_addressline table. Each row represents
 
 341         a table row and all data must match. Data not present in the expected
 
 342         table, may be arbitrary. The rows are identified via the 'object' column,
 
 343         representing the addressee and the 'address' column, representing the
 
 346     with context.db.cursor() as cur:
 
 347         for row in context.table:
 
 348             nid = NominatimID(row['object'])
 
 349             pid = nid.get_place_id(cur)
 
 350             apid = NominatimID(row['address']).get_place_id(cur)
 
 351             cur.execute(""" SELECT * FROM place_addressline
 
 352                             WHERE place_id = %s AND address_place_id = %s""",
 
 354             assert cur.rowcount > 0, \
 
 355                         "No rows found for place %s and address %s" % (row['object'], row['address'])
 
 358                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
 
 360 @then("place_addressline doesn't contain")
 
 361 def check_place_addressline_exclude(context):
 
 362     """ Check that the place_addressline doesn't contain any entries for the
 
 363         given addressee/address item pairs.
 
 365     with context.db.cursor() as cur:
 
 366         for row in context.table:
 
 367             pid = NominatimID(row['object']).get_place_id(cur)
 
 368             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
 
 370                 cur.execute(""" SELECT * FROM place_addressline
 
 371                                 WHERE place_id = %s AND address_place_id = %s""",
 
 373                 assert cur.rowcount == 0, \
 
 374                     "Row found for place %s and address %s" % (row['object'], row['address'])
 
 376 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
 
 377 def check_location_property_osmline(context, oid, neg):
 
 378     """ Check that the given way is present in the interpolation table.
 
 380     with context.db.cursor() as cur:
 
 381         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
 
 382                        FROM location_property_osmline
 
 383                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
 
 387             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
 
 390         todo = list(range(len(list(context.table))))
 
 393                 row = context.table[i]
 
 394                 if (int(row['start']) == res['startnumber']
 
 395                     and int(row['end']) == res['endnumber']):
 
 399                 assert False, "Unexpected row " + str(res)
 
 401             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
 
 403         assert not todo, f"Unmatched lines in table: {list(context.table[i] for i in todo)}"
 
 405 @then("location_property_osmline contains(?P<exact> exactly)?")
 
 406 def check_place_contents(context, exact):
 
 407     """ Check contents of the interpolation table. Each row represents a table row
 
 408         and all data must match. Data not present in the expected table, may
 
 409         be arbitrary. The rows are identified via the 'object' column which must
 
 410         have an identifier of the form '<osm id>[:<startnumber>]'. When multiple
 
 411         rows match (for example because 'startnumber' was left out and there are
 
 412         multiple entries for the given OSM object) then all must match. All
 
 413         expected rows are expected to be present with at least one database row.
 
 414         When 'exactly' is given, there must not be additional rows in the database.
 
 416     with context.db.cursor() as cur:
 
 417         expected_content = set()
 
 418         for row in context.table:
 
 419             if ':' in row['object']:
 
 420                 nid, start = row['object'].split(':', 2)
 
 423                 nid, start = row['object'], None
 
 425             query = """SELECT *, ST_AsText(linegeo) as geomtxt,
 
 426                               ST_GeometryType(linegeo) as geometrytype
 
 427                        FROM location_property_osmline WHERE osm_id=%s"""
 
 429             if ':' in row['object']:
 
 430                 query += ' and startnumber = %s'
 
 431                 params = [int(val) for val in row['object'].split(':', 2)]
 
 433                 params = (int(row['object']), )
 
 435             cur.execute(query, params)
 
 436             assert cur.rowcount > 0, "No rows found for " + row['object']
 
 440                     expected_content.add((res['osm_id'], res['startnumber']))
 
 442                 DBRow(nid, res, context).assert_row(row, ['object'])
 
 445             cur.execute('SELECT osm_id, startnumber from location_property_osmline')
 
 446             actual = set([(r['osm_id'], r['startnumber']) for r in cur])
 
 447             assert expected_content == actual, \
 
 448                    f"Missing entries: {expected_content - actual}\n" \
 
 449                    f"Not expected in table: {actual - expected_content}"