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}"