1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2025 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 from itertools import chain
10 from psycopg import sql as pysql
12 from place_inserter import PlaceColumn
13 from table_compare import NominatimID, DBRow
15 from nominatim_db.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(row_factory=psycopg.rows.tuple_row) 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 cur.execute("SELECT count(*) FROM word WHERE word_token = ''")
31 assert cur.fetchone()[0] == 0, "Empty word tokens found in word table"
33 # GIVEN ##################################
36 @given("the (?P<named>named )?places")
37 def add_data_to_place_table(context, named):
38 """ Add entries into the place table. 'named places' makes sure that
39 the entries get a random name when none is explicitly given.
41 with context.db.cursor() as cur:
42 cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert')
43 for row in context.table:
44 PlaceColumn(context).add_row(row, named is not None).db_insert(cur)
45 cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert')
48 @given("the relations")
49 def add_data_to_planet_relations(context):
50 """ Add entries into the osm2pgsql relation middle table. This is needed
51 for tests on data that looks up members.
53 with context.db.cursor() as cur:
54 cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
56 if row is None or row['value'] == '1':
57 for r in context.table:
63 for m in r['members'].split(','):
66 parts.insert(last_node, int(mid.oid))
70 parts.insert(last_way, int(mid.oid))
73 parts.append(int(mid.oid))
75 members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
79 tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings
80 if h.startswith("tags+")])
82 cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off,
84 VALUES (%s, %s, %s, %s, %s, %s)""",
85 (r['id'], last_node, last_way, parts, members, list(tags)))
87 for r in context.table:
90 for m in r['members'].split(','):
92 members.append({'ref': mid.oid, 'role': mid.cls or '', 'type': mid.typ})
96 tags = {h[5:]: r[h] for h in r.headings if h.startswith("tags+")}
98 cur.execute("""INSERT INTO planet_osm_rels (id, tags, members)
99 VALUES (%s, %s, %s)""",
100 (r['id'], psycopg.types.json.Json(tags),
101 psycopg.types.json.Json(members)))
105 def add_data_to_planet_ways(context):
106 """ Add entries into the osm2pgsql way middle table. This is necessary for
107 tests on that that looks up node ids in this table.
109 with context.db.cursor() as cur:
110 cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
112 json_tags = row is not None and row['value'] != '1'
113 for r in context.table:
115 tags = psycopg.types.json.Json({h[5:]: r[h] for h in r.headings
116 if h.startswith("tags+")})
118 tags = list(chain.from_iterable([(h[5:], r[h])
119 for h in r.headings if h.startswith("tags+")]))
120 nodes = [int(x.strip()) for x in r['nodes'].split(',')]
122 cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
123 (r['id'], nodes, tags))
125 # WHEN ##################################
129 def import_and_index_data_from_place_table(context):
130 """ Import data previously set up in the place table.
132 context.nominatim.run_nominatim('import', '--continue', 'load-data',
133 '--index-noanalyse', '-q',
136 check_database_integrity(context)
138 # Remove the output of the input, when all was right. Otherwise it will be
139 # output when there are errors that had nothing to do with the import
141 context.log_capture.buffer.clear()
144 @when("updating places")
145 def update_place_table(context):
146 """ Update the place table with the given data. Also runs all triggers
147 related to updates and reindexes the new data.
149 context.nominatim.run_nominatim('refresh', '--functions')
150 with context.db.cursor() as cur:
151 for row in context.table:
152 col = PlaceColumn(context).add_row(row, False)
155 cur.execute('SELECT flush_deleted_places()')
157 context.nominatim.reindex_placex(context.db)
158 check_database_integrity(context)
160 # Remove the output of the input, when all was right. Otherwise it will be
161 # output when there are errors that had nothing to do with the import
163 context.log_capture.buffer.clear()
166 @when("updating postcodes")
167 def update_postcodes(context):
168 """ Rerun the calculation of postcodes.
170 context.nominatim.run_nominatim('refresh', '--postcodes')
173 @when("marking for delete (?P<oids>.*)")
174 def delete_places(context, oids):
175 """ Remove entries from the place table. Multiple ids may be given
176 separated by commas. Also runs all triggers
177 related to updates and reindexes the new data.
179 context.nominatim.run_nominatim('refresh', '--functions')
180 with context.db.cursor() as cur:
181 cur.execute('TRUNCATE place_to_be_deleted')
182 for oid in oids.split(','):
183 NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
184 cur.execute('SELECT flush_deleted_places()')
186 context.nominatim.reindex_placex(context.db)
188 # Remove the output of the input, when all was right. Otherwise it will be
189 # output when there are errors that had nothing to do with the import
191 context.log_capture.buffer.clear()
193 # THEN ##################################
196 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
197 def check_place_contents(context, table, exact):
198 """ Check contents of place/placex tables. Each row represents a table row
199 and all data must match. Data not present in the expected table, may
200 be arbitrary. The rows are identified via the 'object' column which must
201 have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
202 rows match (for example because 'class' was left out and there are
203 multiple entries for the given OSM object) then all must match. All
204 expected rows are expected to be present with at least one database row.
205 When 'exactly' is given, there must not be additional rows in the database.
207 with context.db.cursor() as cur:
208 expected_content = set()
209 for row in context.table:
210 nid = NominatimID(row['object'])
211 query = """SELECT *, ST_AsText(geometry) as geomtxt,
212 ST_GeometryType(geometry) as geometrytype """
213 if table == 'placex':
214 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
215 query += " FROM %s WHERE {}" % (table, )
216 nid.query_osm_id(cur, query)
217 assert cur.rowcount > 0, "No rows found for " + row['object']
221 expected_content.add((res['osm_type'], res['osm_id'], res['class']))
223 DBRow(nid, res, context).assert_row(row, ['object'])
226 cur.execute(pysql.SQL('SELECT osm_type, osm_id, class from')
227 + pysql.Identifier(table))
228 actual = set([(r['osm_type'], r['osm_id'], r['class']) for r in cur])
229 assert expected_content == actual, \
230 f"Missing entries: {expected_content - actual}\n" \
231 f"Not expected in table: {actual - expected_content}"
234 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
235 def check_place_has_entry(context, table, oid):
236 """ Ensure that no database row for the given object exists. The ID
237 must be of the form '<NRW><osm id>[:<class>]'.
239 with context.db.cursor() as cur:
240 NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
241 assert cur.rowcount == 0, \
242 "Found {} entries for ID {}".format(cur.rowcount, oid)
245 @then("search_name contains(?P<exclude> not)?")
246 def check_search_name_contents(context, exclude):
247 """ Check contents of place/placex tables. Each row represents a table row
248 and all data must match. Data not present in the expected table, may
249 be arbitrary. The rows are identified via the 'object' column which must
250 have an identifier of the form '<NRW><osm id>[:<class>]'. All
251 expected rows are expected to be present with at least one database row.
253 tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
255 with tokenizer.name_analyzer() as analyzer:
256 with context.db.cursor() as cur:
257 for row in context.table:
258 nid = NominatimID(row['object'])
259 nid.row_by_place_id(cur, 'search_name',
260 ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
261 assert cur.rowcount > 0, "No rows found for " + row['object']
264 db_row = DBRow(nid, res, context)
265 for name, value in zip(row.headings, row.cells):
266 if name in ('name_vector', 'nameaddress_vector'):
267 items = [x.strip() for x in value.split(',')]
268 tokens = analyzer.get_word_token_info(items)
271 assert len(tokens) >= len(items), \
272 f"No word entry found for {value}. Entries found: {len(tokens)}"
273 for word, token, wid in tokens:
275 assert wid not in res[name], \
276 "Found term for {}/{}: {}".format(nid, name, wid)
278 assert wid in res[name], \
279 "Missing term for {}/{}: {}".format(nid, name, wid)
280 elif name != 'object':
281 assert db_row.contains(name, value), db_row.assert_msg(name, value)
284 @then("search_name has no entry for (?P<oid>.*)")
285 def check_search_name_has_entry(context, oid):
286 """ Check that there is noentry in the search_name table for the given
287 objects. IDs are in format '<NRW><osm id>[:<class>]'.
289 with context.db.cursor() as cur:
290 NominatimID(oid).row_by_place_id(cur, 'search_name')
292 assert cur.rowcount == 0, \
293 "Found {} entries for ID {}".format(cur.rowcount, oid)
296 @then("location_postcode contains exactly")
297 def check_location_postcode(context):
298 """ Check full contents for location_postcode table. Each row represents a table row
299 and all data must match. Data not present in the expected table, may
300 be arbitrary. The rows are identified via 'country' and 'postcode' columns.
301 All rows must be present as excepted and there must not be additional
304 with context.db.cursor() as cur:
305 cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
306 assert cur.rowcount == len(list(context.table)), \
307 "Postcode table has {cur.rowcount} rows, expected {len(list(context.table))}."
311 key = (row['country_code'], row['postcode'])
312 assert key not in results, "Postcode table has duplicate entry: {}".format(row)
313 results[key] = DBRow((row['country_code'], row['postcode']), row, context)
315 for row in context.table:
316 db_row = results.get((row['country'], row['postcode']))
317 assert db_row is not None, \
318 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
320 db_row.assert_row(row, ('country', 'postcode'))
323 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
324 def check_word_table_for_postcodes(context, exclude, postcodes):
325 """ Check that the tokenizer produces postcode tokens for the given
326 postcodes. The postcodes are a comma-separated list of postcodes.
329 nctx = context.nominatim
330 tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
331 with tokenizer.name_analyzer() as ana:
332 plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
336 with context.db.cursor() as cur:
337 cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
340 found = [row['word'] for row in cur]
341 assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
344 assert len(found) == 0, f"Unexpected postcodes: {found}"
346 assert set(found) == set(plist), \
347 f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
350 @then("place_addressline contains")
351 def check_place_addressline(context):
352 """ Check the contents of the place_addressline table. Each row represents
353 a table row and all data must match. Data not present in the expected
354 table, may be arbitrary. The rows are identified via the 'object' column,
355 representing the addressee and the 'address' column, representing the
358 with context.db.cursor() as cur:
359 for row in context.table:
360 nid = NominatimID(row['object'])
361 pid = nid.get_place_id(cur)
362 apid = NominatimID(row['address']).get_place_id(cur)
363 cur.execute(""" SELECT * FROM place_addressline
364 WHERE place_id = %s AND address_place_id = %s""",
366 assert cur.rowcount > 0, \
367 f"No rows found for place {row['object']} and address {row['address']}."
370 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
373 @then("place_addressline doesn't contain")
374 def check_place_addressline_exclude(context):
375 """ Check that the place_addressline doesn't contain any entries for the
376 given addressee/address item pairs.
378 with context.db.cursor() as cur:
379 for row in context.table:
380 pid = NominatimID(row['object']).get_place_id(cur)
381 apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
383 cur.execute(""" SELECT * FROM place_addressline
384 WHERE place_id = %s AND address_place_id = %s""",
386 assert cur.rowcount == 0, \
387 f"Row found for place {row['object']} and address {row['address']}."
390 @then(r"W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
391 def check_location_property_osmline(context, oid, neg):
392 """ Check that the given way is present in the interpolation table.
394 with context.db.cursor() as cur:
395 cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
396 FROM location_property_osmline
397 WHERE osm_id = %s AND startnumber IS NOT NULL""",
401 assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
404 todo = list(range(len(list(context.table))))
407 row = context.table[i]
408 if (int(row['start']) == res['startnumber']
409 and int(row['end']) == res['endnumber']):
413 assert False, "Unexpected row " + str(res)
415 DBRow(oid, res, context).assert_row(row, ('start', 'end'))
417 assert not todo, f"Unmatched lines in table: {list(context.table[i] for i in todo)}"
420 @then("location_property_osmline contains(?P<exact> exactly)?")
421 def check_osmline_contents(context, exact):
422 """ Check contents of the interpolation table. Each row represents a table row
423 and all data must match. Data not present in the expected table, may
424 be arbitrary. The rows are identified via the 'object' column which must
425 have an identifier of the form '<osm id>[:<startnumber>]'. When multiple
426 rows match (for example because 'startnumber' was left out and there are
427 multiple entries for the given OSM object) then all must match. All
428 expected rows are expected to be present with at least one database row.
429 When 'exactly' is given, there must not be additional rows in the database.
431 with context.db.cursor() as cur:
432 expected_content = set()
433 for row in context.table:
434 if ':' in row['object']:
435 nid, start = row['object'].split(':', 2)
438 nid, start = row['object'], None
440 query = """SELECT *, ST_AsText(linegeo) as geomtxt,
441 ST_GeometryType(linegeo) as geometrytype
442 FROM location_property_osmline WHERE osm_id=%s"""
444 if ':' in row['object']:
445 query += ' and startnumber = %s'
446 params = [int(val) for val in row['object'].split(':', 2)]
448 params = (int(row['object']), )
450 cur.execute(query, params)
451 assert cur.rowcount > 0, "No rows found for " + row['object']
455 expected_content.add((res['osm_id'], res['startnumber']))
457 DBRow(nid, res, context).assert_row(row, ['object'])
460 cur.execute('SELECT osm_id, startnumber from location_property_osmline')
461 actual = set([(r['osm_id'], r['startnumber']) for r in cur])
462 assert expected_content == actual, \
463 f"Missing entries: {expected_content - actual}\n" \
464 f"Not expected in table: {actual - expected_content}"