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 actual = set([(r[0], r[1], r[2]) for r in cur])
189 assert expected_content == actual, \
190 f"Missing entries: {expected_content - actual}\n" \
191 f"Not expected in table: {actual - expected_content}"
194 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
195 def check_place_has_entry(context, table, oid):
196 """ Ensure that no database row for the given object exists. The ID
197 must be of the form '<NRW><osm id>[:<class>]'.
199 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
200 NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
201 assert cur.rowcount == 0, \
202 "Found {} entries for ID {}".format(cur.rowcount, oid)
205 @then("search_name contains(?P<exclude> not)?")
206 def check_search_name_contents(context, exclude):
207 """ Check contents of place/placex tables. Each row represents a table row
208 and all data must match. Data not present in the expected table, may
209 be arbitry. The rows are identified via the 'object' column which must
210 have an identifier of the form '<NRW><osm id>[:<class>]'. All
211 expected rows are expected to be present with at least one database row.
213 tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
215 with tokenizer.name_analyzer() as analyzer:
216 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
217 for row in context.table:
218 nid = NominatimID(row['object'])
219 nid.row_by_place_id(cur, 'search_name',
220 ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
221 assert cur.rowcount > 0, "No rows found for " + row['object']
224 db_row = DBRow(nid, res, context)
225 for name, value in zip(row.headings, row.cells):
226 if name in ('name_vector', 'nameaddress_vector'):
227 items = [x.strip() for x in value.split(',')]
228 tokens = analyzer.get_word_token_info(items)
231 assert len(tokens) >= len(items), \
232 "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
233 for word, token, wid in tokens:
235 assert wid not in res[name], \
236 "Found term for {}/{}: {}".format(nid, name, wid)
238 assert wid in res[name], \
239 "Missing term for {}/{}: {}".format(nid, name, wid)
240 elif name != 'object':
241 assert db_row.contains(name, value), db_row.assert_msg(name, value)
243 @then("search_name has no entry for (?P<oid>.*)")
244 def check_search_name_has_entry(context, oid):
245 """ Check that there is noentry in the search_name table for the given
246 objects. IDs are in format '<NRW><osm id>[:<class>]'.
248 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
249 NominatimID(oid).row_by_place_id(cur, 'search_name')
251 assert cur.rowcount == 0, \
252 "Found {} entries for ID {}".format(cur.rowcount, oid)
254 @then("location_postcode contains exactly")
255 def check_location_postcode(context):
256 """ Check full contents for location_postcode table. Each row represents a table row
257 and all data must match. Data not present in the expected table, may
258 be arbitry. The rows are identified via 'country' and 'postcode' columns.
259 All rows must be present as excepted and there must not be additional
262 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
263 cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
264 assert cur.rowcount == len(list(context.table)), \
265 "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
269 key = (row['country_code'], row['postcode'])
270 assert key not in results, "Postcode table has duplicate entry: {}".format(row)
271 results[key] = DBRow((row['country_code'],row['postcode']), row, context)
273 for row in context.table:
274 db_row = results.get((row['country'],row['postcode']))
275 assert db_row is not None, \
276 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
278 db_row.assert_row(row, ('country', 'postcode'))
280 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
281 def check_word_table_for_postcodes(context, exclude, postcodes):
282 """ Check that the tokenizer produces postcode tokens for the given
283 postcodes. The postcodes are a comma-separated list of postcodes.
286 nctx = context.nominatim
287 tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
288 with tokenizer.name_analyzer() as ana:
289 plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
293 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
294 if nctx.tokenizer != 'legacy':
295 cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
298 cur.execute("""SELECT word FROM word WHERE word = any(%s)
299 and class = 'place' and type = 'postcode'""",
302 found = [row[0] for row in cur]
303 assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
306 assert len(found) == 0, f"Unexpected postcodes: {found}"
308 assert set(found) == set(plist), \
309 f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
311 @then("place_addressline contains")
312 def check_place_addressline(context):
313 """ Check the contents of the place_addressline table. Each row represents
314 a table row and all data must match. Data not present in the expected
315 table, may be arbitry. The rows are identified via the 'object' column,
316 representing the addressee and the 'address' column, representing the
319 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
320 for row in context.table:
321 nid = NominatimID(row['object'])
322 pid = nid.get_place_id(cur)
323 apid = NominatimID(row['address']).get_place_id(cur)
324 cur.execute(""" SELECT * FROM place_addressline
325 WHERE place_id = %s AND address_place_id = %s""",
327 assert cur.rowcount > 0, \
328 "No rows found for place %s and address %s" % (row['object'], row['address'])
331 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
333 @then("place_addressline doesn't contain")
334 def check_place_addressline_exclude(context):
335 """ Check that the place_addressline doesn't contain any entries for the
336 given addressee/address item pairs.
338 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
339 for row in context.table:
340 pid = NominatimID(row['object']).get_place_id(cur)
341 apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
343 cur.execute(""" SELECT * FROM place_addressline
344 WHERE place_id = %s AND address_place_id = %s""",
346 assert cur.rowcount == 0, \
347 "Row found for place %s and address %s" % (row['object'], row['address'])
349 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
350 def check_location_property_osmline(context, oid, neg):
351 """ Check that the given way is present in the interpolation table.
353 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
354 cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
355 FROM location_property_osmline
356 WHERE osm_id = %s AND startnumber IS NOT NULL""",
360 assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
363 todo = list(range(len(list(context.table))))
366 row = context.table[i]
367 if (int(row['start']) == res['startnumber']
368 and int(row['end']) == res['endnumber']):
372 assert False, "Unexpected row " + str(res)
374 DBRow(oid, res, context).assert_row(row, ('start', 'end'))
376 assert not todo, f"Unmatched lines in table: {list(context.table[i] for i in todo)}"