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             PlaceColumn(context).add_row(row, False).db_insert(cur)
 
 123     context.nominatim.reindex_placex(context.db)
 
 124     check_database_integrity(context)
 
 126     # Remove the output of the input, when all was right. Otherwise it will be
 
 127     # output when there are errors that had nothing to do with the import
 
 129     context.log_capture.buffer.clear()
 
 132 @when("updating postcodes")
 
 133 def update_postcodes(context):
 
 134     """ Rerun the calculation of postcodes.
 
 136     context.nominatim.run_nominatim('refresh', '--postcodes')
 
 138 @when("marking for delete (?P<oids>.*)")
 
 139 def delete_places(context, oids):
 
 140     """ Remove entries from the place table. Multiple ids may be given
 
 141         separated by commas. 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 oid in oids.split(','):
 
 147             NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
 
 149     context.nominatim.reindex_placex(context.db)
 
 151     # Remove the output of the input, when all was right. Otherwise it will be
 
 152     # output when there are errors that had nothing to do with the import
 
 154     context.log_capture.buffer.clear()
 
 156 ################################ THEN ##################################
 
 158 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
 
 159 def check_place_contents(context, table, exact):
 
 160     """ Check contents of place/placex tables. Each row represents a table row
 
 161         and all data must match. Data not present in the expected table, may
 
 162         be arbitry. The rows are identified via the 'object' column which must
 
 163         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
 
 164         rows match (for example because 'class' was left out and there are
 
 165         multiple entries for the given OSM object) then all must match. All
 
 166         expected rows are expected to be present with at least one database row.
 
 167         When 'exactly' is given, there must not be additional rows in the database.
 
 169     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 170         expected_content = set()
 
 171         for row in context.table:
 
 172             nid = NominatimID(row['object'])
 
 173             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
 
 174             if table == 'placex':
 
 175                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
 
 176             query += " FROM %s WHERE {}" % (table, )
 
 177             nid.query_osm_id(cur, query)
 
 178             assert cur.rowcount > 0, "No rows found for " + row['object']
 
 182                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
 
 184                 DBRow(nid, res, context).assert_row(row, ['object'])
 
 187             cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
 
 188             assert expected_content == set([(r[0], r[1], r[2]) for r in cur])
 
 191 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
 
 192 def check_place_has_entry(context, table, oid):
 
 193     """ Ensure that no database row for the given object exists. The ID
 
 194         must be of the form '<NRW><osm id>[:<class>]'.
 
 196     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 197         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
 
 198         assert cur.rowcount == 0, \
 
 199                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 202 @then("search_name contains(?P<exclude> not)?")
 
 203 def check_search_name_contents(context, exclude):
 
 204     """ Check contents of place/placex tables. Each row represents a table row
 
 205         and all data must match. Data not present in the expected table, may
 
 206         be arbitry. The rows are identified via the 'object' column which must
 
 207         have an identifier of the form '<NRW><osm id>[:<class>]'. All
 
 208         expected rows are expected to be present with at least one database row.
 
 210     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
 
 212     with tokenizer.name_analyzer() as analyzer:
 
 213         with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 214             for row in context.table:
 
 215                 nid = NominatimID(row['object'])
 
 216                 nid.row_by_place_id(cur, 'search_name',
 
 217                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
 
 218                 assert cur.rowcount > 0, "No rows found for " + row['object']
 
 221                     db_row = DBRow(nid, res, context)
 
 222                     for name, value in zip(row.headings, row.cells):
 
 223                         if name in ('name_vector', 'nameaddress_vector'):
 
 224                             items = [x.strip() for x in value.split(',')]
 
 225                             tokens = analyzer.get_word_token_info(items)
 
 228                                 assert len(tokens) >= len(items), \
 
 229                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
 
 230                             for word, token, wid in tokens:
 
 232                                     assert wid not in res[name], \
 
 233                                            "Found term for {}/{}: {}".format(nid, name, wid)
 
 235                                     assert wid in res[name], \
 
 236                                            "Missing term for {}/{}: {}".format(nid, name, wid)
 
 237                         elif name != 'object':
 
 238                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
 
 240 @then("search_name has no entry for (?P<oid>.*)")
 
 241 def check_search_name_has_entry(context, oid):
 
 242     """ Check that there is noentry in the search_name table for the given
 
 243         objects. IDs are in format '<NRW><osm id>[:<class>]'.
 
 245     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 246         NominatimID(oid).row_by_place_id(cur, 'search_name')
 
 248         assert cur.rowcount == 0, \
 
 249                "Found {} entries for ID {}".format(cur.rowcount, oid)
 
 251 @then("location_postcode contains exactly")
 
 252 def check_location_postcode(context):
 
 253     """ Check full contents for location_postcode table. Each row represents a table row
 
 254         and all data must match. Data not present in the expected table, may
 
 255         be arbitry. The rows are identified via 'country' and 'postcode' columns.
 
 256         All rows must be present as excepted and there must not be additional
 
 259     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 260         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
 
 261         assert cur.rowcount == len(list(context.table)), \
 
 262             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
 
 266             key = (row['country_code'], row['postcode'])
 
 267             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
 
 268             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
 
 270         for row in context.table:
 
 271             db_row = results.get((row['country'],row['postcode']))
 
 272             assert db_row is not None, \
 
 273                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
 
 275             db_row.assert_row(row, ('country', 'postcode'))
 
 277 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
 
 278 def check_word_table_for_postcodes(context, exclude, postcodes):
 
 279     """ Check that the tokenizer produces postcode tokens for the given
 
 280         postcodes. The postcodes are a comma-separated list of postcodes.
 
 283     nctx = context.nominatim
 
 284     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
 
 285     with tokenizer.name_analyzer() as ana:
 
 286         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
 
 290     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 291         if nctx.tokenizer != 'legacy':
 
 292             cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
 
 295             cur.execute("""SELECT word FROM word WHERE word = any(%s)
 
 296                              and class = 'place' and type = 'postcode'""",
 
 299         found = [row[0] for row in cur]
 
 300         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
 
 303         assert len(found) == 0, f"Unexpected postcodes: {found}"
 
 305         assert set(found) == set(plist), \
 
 306         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
 
 308 @then("place_addressline contains")
 
 309 def check_place_addressline(context):
 
 310     """ Check the contents of the place_addressline table. Each row represents
 
 311         a table row and all data must match. Data not present in the expected
 
 312         table, may be arbitry. The rows are identified via the 'object' column,
 
 313         representing the addressee and the 'address' column, representing the
 
 316     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 317         for row in context.table:
 
 318             nid = NominatimID(row['object'])
 
 319             pid = nid.get_place_id(cur)
 
 320             apid = NominatimID(row['address']).get_place_id(cur)
 
 321             cur.execute(""" SELECT * FROM place_addressline
 
 322                             WHERE place_id = %s AND address_place_id = %s""",
 
 324             assert cur.rowcount > 0, \
 
 325                         "No rows found for place %s and address %s" % (row['object'], row['address'])
 
 328                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
 
 330 @then("place_addressline doesn't contain")
 
 331 def check_place_addressline_exclude(context):
 
 332     """ Check that the place_addressline doesn't contain any entries for the
 
 333         given addressee/address item pairs.
 
 335     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 336         for row in context.table:
 
 337             pid = NominatimID(row['object']).get_place_id(cur)
 
 338             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
 
 340                 cur.execute(""" SELECT * FROM place_addressline
 
 341                                 WHERE place_id = %s AND address_place_id = %s""",
 
 343                 assert cur.rowcount == 0, \
 
 344                     "Row found for place %s and address %s" % (row['object'], row['address'])
 
 346 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
 
 347 def check_location_property_osmline(context, oid, neg):
 
 348     """ Check that the given way is present in the interpolation table.
 
 350     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
 
 351         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
 
 352                        FROM location_property_osmline
 
 353                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
 
 357             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
 
 360         todo = list(range(len(list(context.table))))
 
 363                 row = context.table[i]
 
 364                 if (int(row['start']) == res['startnumber']
 
 365                     and int(row['end']) == res['endnumber']):
 
 369                 assert False, "Unexpected row " + str(res)
 
 371             DBRow(oid, res, context).assert_row(row, ('start', 'end'))