1 # SPDX-License-Identifier: GPL-2.0-only
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2022 by the Nominatim developer community.
 
   6 # For a full list of authors see the git log.
 
   8 from itertools import chain
 
  10 import psycopg2.extras
 
  12 from place_inserter import PlaceColumn
 
  13 from table_compare import NominatimID, DBRow
 
  15 from nominatim.indexer import indexer
 
  16 from nominatim.tokenizer import factory as tokenizer_factory
 
  18 def check_database_integrity(context):
 
  19     """ Check some generic constraints on the tables.
 
  21     with context.db.cursor() as cur:
 
  22         # place_addressline should not have duplicate (place_id, address_place_id)
 
  23         cur.execute("""SELECT count(*) FROM
 
  24                         (SELECT place_id, address_place_id, count(*) as c
 
  25                          FROM place_addressline GROUP BY place_id, address_place_id) x
 
  27         assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
 
  29         # word table must not have empty word_tokens
 
  30         if context.nominatim.tokenizer != 'legacy':
 
  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         for r in context.table:
 
  61                 for m in r['members'].split(','):
 
  64                         parts.insert(last_node, int(mid.oid))
 
  68                         parts.insert(last_way, int(mid.oid))
 
  71                         parts.append(int(mid.oid))
 
  73                     members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
 
  77             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
 
  79             cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags)
 
  80                            VALUES (%s, %s, %s, %s, %s, %s)""",
 
  81                         (r['id'], last_node, last_way, parts, members, list(tags)))
 
  84 def add_data_to_planet_ways(context):
 
  85     """ Add entries into the osm2pgsql way middle table. This is necessary for
 
  86         tests on that that looks up node ids in this table.
 
  88     with context.db.cursor() as cur:
 
  89         for r in context.table:
 
  90             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
 
  91             nodes = [ int(x.strip()) for x in r['nodes'].split(',') ]
 
  93             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
 
  94                         (r['id'], nodes, list(tags)))
 
  96 ################################ WHEN ##################################
 
  99 def import_and_index_data_from_place_table(context):
 
 100     """ Import data previously set up in the place table.
 
 102     context.nominatim.run_nominatim('import', '--continue', 'load-data',
 
 103                                               '--index-noanalyse', '-q',
 
 106     check_database_integrity(context)
 
 108     # Remove the output of the input, when all was right. Otherwise it will be
 
 109     # output when there are errors that had nothing to do with the import
 
 111     context.log_capture.buffer.clear()
 
 113 @when("updating places")
 
 114 def update_place_table(context):
 
 115     """ Update the place table with the given data. Also runs all triggers
 
 116         related to updates and reindexes the new data.
 
 118     context.nominatim.run_nominatim('refresh', '--functions')
 
 119     with context.db.cursor() as cur:
 
 120         for row in context.table:
 
 121             col = PlaceColumn(context).add_row(row, False)
 
 124         cur.execute('SELECT flush_deleted_places()')
 
 126     context.nominatim.reindex_placex(context.db)
 
 127     check_database_integrity(context)
 
 129     # Remove the output of the input, when all was right. Otherwise it will be
 
 130     # output when there are errors that had nothing to do with the import
 
 132     context.log_capture.buffer.clear()
 
 135 @when("updating postcodes")
 
 136 def update_postcodes(context):
 
 137     """ Rerun the calculation of postcodes.
 
 139     context.nominatim.run_nominatim('refresh', '--postcodes')
 
 141 @when("marking for delete (?P<oids>.*)")
 
 142 def delete_places(context, oids):
 
 143     """ Remove entries from the place table. Multiple ids may be given
 
 144         separated by commas. Also runs all triggers
 
 145         related to updates and reindexes the new data.
 
 147     context.nominatim.run_nominatim('refresh', '--functions')
 
 148     with context.db.cursor() as cur:
 
 149         cur.execute('TRUNCATE place_to_be_deleted')
 
 150         for oid in oids.split(','):
 
 151             NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
 
 152         cur.execute('SELECT flush_deleted_places()')
 
 154     context.nominatim.reindex_placex(context.db)
 
 156     # Remove the output of the input, when all was right. Otherwise it will be
 
 157     # output when there are errors that had nothing to do with the import
 
 159     context.log_capture.buffer.clear()
 
 161 ################################ THEN ##################################
 
 163 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
 
 164 def check_place_contents(context, table, exact):
 
 165     """ Check contents of place/placex tables. Each row represents a table row
 
 166         and all data must match. Data not present in the expected table, may
 
 167         be arbitry. The rows are identified via the 'object' column which must
 
 168         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
 
 169         rows match (for example because 'class' was left out and there are
 
 170         multiple entries for the given OSM object) then all must match. All
 
 171         expected rows are expected to be present with at least one database row.
 
 172         When 'exactly' is given, there must not be additional rows in the database.
 
 174     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 175         expected_content = set()
 
 176         for row in context.table:
 
 177             nid = NominatimID(row['object'])
 
 178             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
 
 179             if table == 'placex':
 
 180                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
 
 181             query += " FROM %s WHERE {}" % (table, )
 
 182             nid.query_osm_id(cur, query)
 
 183             assert cur.rowcount > 0, "No rows found for " + row['object']
 
 187                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
 
 189                 DBRow(nid, res, context).assert_row(row, ['object'])
 
 192             cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
 
 193             actual = set([(r[0], r[1], r[2]) for r in cur])
 
 194             assert expected_content == actual, \
 
 195                    f"Missing entries: {expected_content - actual}\n" \
 
 196                    f"Not expected in table: {actual - expected_content}"
 
 199 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
 
 200 def check_place_has_entry(context, table, oid):
 
 201     """ Ensure that no database row for the given object exists. The ID
 
 202         must be of the form '<NRW><osm id>[:<class>]'.
 
 204     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 205         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
 
 206         assert cur.rowcount == 0, \
 
 207                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 210 @then("search_name contains(?P<exclude> not)?")
 
 211 def check_search_name_contents(context, exclude):
 
 212     """ Check contents of place/placex tables. Each row represents a table row
 
 213         and all data must match. Data not present in the expected table, may
 
 214         be arbitry. The rows are identified via the 'object' column which must
 
 215         have an identifier of the form '<NRW><osm id>[:<class>]'. All
 
 216         expected rows are expected to be present with at least one database row.
 
 218     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
 
 220     with tokenizer.name_analyzer() as analyzer:
 
 221         with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 222             for row in context.table:
 
 223                 nid = NominatimID(row['object'])
 
 224                 nid.row_by_place_id(cur, 'search_name',
 
 225                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
 
 226                 assert cur.rowcount > 0, "No rows found for " + row['object']
 
 229                     db_row = DBRow(nid, res, context)
 
 230                     for name, value in zip(row.headings, row.cells):
 
 231                         if name in ('name_vector', 'nameaddress_vector'):
 
 232                             items = [x.strip() for x in value.split(',')]
 
 233                             tokens = analyzer.get_word_token_info(items)
 
 236                                 assert len(tokens) >= len(items), \
 
 237                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
 
 238                             for word, token, wid in tokens:
 
 240                                     assert wid not in res[name], \
 
 241                                            "Found term for {}/{}: {}".format(nid, name, wid)
 
 243                                     assert wid in res[name], \
 
 244                                            "Missing term for {}/{}: {}".format(nid, name, wid)
 
 245                         elif name != 'object':
 
 246                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
 
 248 @then("search_name has no entry for (?P<oid>.*)")
 
 249 def check_search_name_has_entry(context, oid):
 
 250     """ Check that there is noentry in the search_name table for the given
 
 251         objects. IDs are in format '<NRW><osm id>[:<class>]'.
 
 253     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 254         NominatimID(oid).row_by_place_id(cur, 'search_name')
 
 256         assert cur.rowcount == 0, \
 
 257                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 259 @then("location_postcode contains exactly")
 
 260 def check_location_postcode(context):
 
 261     """ Check full contents for location_postcode table. Each row represents a table row
 
 262         and all data must match. Data not present in the expected table, may
 
 263         be arbitry. The rows are identified via 'country' and 'postcode' columns.
 
 264         All rows must be present as excepted and there must not be additional
 
 267     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 268         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
 
 269         assert cur.rowcount == len(list(context.table)), \
 
 270             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
 
 274             key = (row['country_code'], row['postcode'])
 
 275             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
 
 276             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
 
 278         for row in context.table:
 
 279             db_row = results.get((row['country'],row['postcode']))
 
 280             assert db_row is not None, \
 
 281                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
 
 283             db_row.assert_row(row, ('country', 'postcode'))
 
 285 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
 
 286 def check_word_table_for_postcodes(context, exclude, postcodes):
 
 287     """ Check that the tokenizer produces postcode tokens for the given
 
 288         postcodes. The postcodes are a comma-separated list of postcodes.
 
 291     nctx = context.nominatim
 
 292     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
 
 293     with tokenizer.name_analyzer() as ana:
 
 294         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
 
 298     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 299         if nctx.tokenizer != 'legacy':
 
 300             cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
 
 303             cur.execute("""SELECT word FROM word WHERE word = any(%s)
 
 304                              and class = 'place' and type = 'postcode'""",
 
 307         found = [row[0] for row in cur]
 
 308         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
 
 311         assert len(found) == 0, f"Unexpected postcodes: {found}"
 
 313         assert set(found) == set(plist), \
 
 314         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
 
 316 @then("place_addressline contains")
 
 317 def check_place_addressline(context):
 
 318     """ Check the contents of the place_addressline table. Each row represents
 
 319         a table row and all data must match. Data not present in the expected
 
 320         table, may be arbitry. The rows are identified via the 'object' column,
 
 321         representing the addressee and the 'address' column, representing the
 
 324     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 325         for row in context.table:
 
 326             nid = NominatimID(row['object'])
 
 327             pid = nid.get_place_id(cur)
 
 328             apid = NominatimID(row['address']).get_place_id(cur)
 
 329             cur.execute(""" SELECT * FROM place_addressline
 
 330                             WHERE place_id = %s AND address_place_id = %s""",
 
 332             assert cur.rowcount > 0, \
 
 333                         "No rows found for place %s and address %s" % (row['object'], row['address'])
 
 336                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
 
 338 @then("place_addressline doesn't contain")
 
 339 def check_place_addressline_exclude(context):
 
 340     """ Check that the place_addressline doesn't contain any entries for the
 
 341         given addressee/address item pairs.
 
 343     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 344         for row in context.table:
 
 345             pid = NominatimID(row['object']).get_place_id(cur)
 
 346             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
 
 348                 cur.execute(""" SELECT * FROM place_addressline
 
 349                                 WHERE place_id = %s AND address_place_id = %s""",
 
 351                 assert cur.rowcount == 0, \
 
 352                     "Row found for place %s and address %s" % (row['object'], row['address'])
 
 354 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
 
 355 def check_location_property_osmline(context, oid, neg):
 
 356     """ Check that the given way is present in the interpolation table.
 
 358     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 359         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
 
 360                        FROM location_property_osmline
 
 361                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
 
 365             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
 
 368         todo = list(range(len(list(context.table))))
 
 371                 row = context.table[i]
 
 372                 if (int(row['start']) == res['startnumber']
 
 373                     and int(row['end']) == res['endnumber']):
 
 377                 assert False, "Unexpected row " + str(res)
 
 379             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
 
 381         assert not todo, f"Unmatched lines in table: {list(context.table[i] for i in todo)}"
 
 383 @then("location_property_osmline contains(?P<exact> exactly)?")
 
 384 def check_place_contents(context, exact):
 
 385     """ Check contents of the interpolation table. Each row represents a table row
 
 386         and all data must match. Data not present in the expected table, may
 
 387         be arbitry. The rows are identified via the 'object' column which must
 
 388         have an identifier of the form '<osm id>[:<startnumber>]'. When multiple
 
 389         rows match (for example because 'startnumber' was left out and there are
 
 390         multiple entries for the given OSM object) then all must match. All
 
 391         expected rows are expected to be present with at least one database row.
 
 392         When 'exactly' is given, there must not be additional rows in the database.
 
 394     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 395         expected_content = set()
 
 396         for row in context.table:
 
 397             if ':' in row['object']:
 
 398                 nid, start = row['object'].split(':', 2)
 
 401                 nid, start = row['object'], None
 
 403             query = """SELECT *, ST_AsText(linegeo) as geomtxt,
 
 404                               ST_GeometryType(linegeo) as geometrytype
 
 405                        FROM location_property_osmline WHERE osm_id=%s"""
 
 407             if ':' in row['object']:
 
 408                 query += ' and startnumber = %s'
 
 409                 params = [int(val) for val in row['object'].split(':', 2)]
 
 411                 params = (int(row['object']), )
 
 413             cur.execute(query, params)
 
 414             assert cur.rowcount > 0, "No rows found for " + row['object']
 
 418                     expected_content.add((res['osm_id'], res['startnumber']))
 
 420                 DBRow(nid, res, context).assert_row(row, ['object'])
 
 423             cur.execute('SELECT osm_id, startnumber from location_property_osmline')
 
 424             actual = set([(r[0], r[1]) for r in cur])
 
 425             assert expected_content == actual, \
 
 426                    f"Missing entries: {expected_content - actual}\n" \
 
 427                    f"Not expected in table: {actual - expected_content}"