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 # place_addressline should not have duplicate (place_id, address_place_id)
22 cur = context.db.cursor()
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"
30 ################################ GIVEN ##################################
32 @given("the (?P<named>named )?places")
33 def add_data_to_place_table(context, named):
34 """ Add entries into the place table. 'named places' makes sure that
35 the entries get a random name when none is explicitly given.
37 with context.db.cursor() as cur:
38 cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert')
39 for row in context.table:
40 PlaceColumn(context).add_row(row, named is not None).db_insert(cur)
41 cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert')
43 @given("the relations")
44 def add_data_to_planet_relations(context):
45 """ Add entries into the osm2pgsql relation middle table. This is needed
46 for tests on data that looks up members.
48 with context.db.cursor() as cur:
49 for r in context.table:
55 for m in r['members'].split(','):
58 parts.insert(last_node, int(mid.oid))
62 parts.insert(last_way, int(mid.oid))
65 parts.append(int(mid.oid))
67 members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
71 tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
73 cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags)
74 VALUES (%s, %s, %s, %s, %s, %s)""",
75 (r['id'], last_node, last_way, parts, members, list(tags)))
78 def add_data_to_planet_ways(context):
79 """ Add entries into the osm2pgsql way middle table. This is necessary for
80 tests on that that looks up node ids in this table.
82 with context.db.cursor() as cur:
83 for r in context.table:
84 tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
85 nodes = [ int(x.strip()) for x in r['nodes'].split(',') ]
87 cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
88 (r['id'], nodes, list(tags)))
90 ################################ WHEN ##################################
93 def import_and_index_data_from_place_table(context):
94 """ Import data previously set up in the place table.
96 context.nominatim.run_nominatim('import', '--continue', 'load-data',
97 '--index-noanalyse', '-q',
100 check_database_integrity(context)
102 # Remove the output of the input, when all was right. Otherwise it will be
103 # output when there are errors that had nothing to do with the import
105 context.log_capture.buffer.clear()
107 @when("updating places")
108 def update_place_table(context):
109 """ Update the place table with the given data. Also runs all triggers
110 related to updates and reindexes the new data.
112 context.nominatim.run_nominatim('refresh', '--functions')
113 with context.db.cursor() as cur:
114 for row in context.table:
115 PlaceColumn(context).add_row(row, False).db_insert(cur)
117 context.nominatim.reindex_placex(context.db)
118 check_database_integrity(context)
120 # Remove the output of the input, when all was right. Otherwise it will be
121 # output when there are errors that had nothing to do with the import
123 context.log_capture.buffer.clear()
126 @when("updating postcodes")
127 def update_postcodes(context):
128 """ Rerun the calculation of postcodes.
130 context.nominatim.run_nominatim('refresh', '--postcodes')
132 @when("marking for delete (?P<oids>.*)")
133 def delete_places(context, oids):
134 """ Remove entries from the place table. Multiple ids may be given
135 separated by commas. Also runs all triggers
136 related to updates and reindexes the new data.
138 context.nominatim.run_nominatim('refresh', '--functions')
139 with context.db.cursor() as cur:
140 for oid in oids.split(','):
141 NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
143 context.nominatim.reindex_placex(context.db)
145 # Remove the output of the input, when all was right. Otherwise it will be
146 # output when there are errors that had nothing to do with the import
148 context.log_capture.buffer.clear()
150 ################################ THEN ##################################
152 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
153 def check_place_contents(context, table, exact):
154 """ Check contents of place/placex tables. Each row represents a table row
155 and all data must match. Data not present in the expected table, may
156 be arbitry. The rows are identified via the 'object' column which must
157 have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
158 rows match (for example because 'class' was left out and there are
159 multiple entries for the given OSM object) then all must match. All
160 expected rows are expected to be present with at least one database row.
161 When 'exactly' is given, there must not be additional rows in the database.
163 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
164 expected_content = set()
165 for row in context.table:
166 nid = NominatimID(row['object'])
167 query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
168 if table == 'placex':
169 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
170 query += " FROM %s WHERE {}" % (table, )
171 nid.query_osm_id(cur, query)
172 assert cur.rowcount > 0, "No rows found for " + row['object']
176 expected_content.add((res['osm_type'], res['osm_id'], res['class']))
178 DBRow(nid, res, context).assert_row(row, ['object'])
181 cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
182 assert expected_content == set([(r[0], r[1], r[2]) for r in cur])
185 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
186 def check_place_has_entry(context, table, oid):
187 """ Ensure that no database row for the given object exists. The ID
188 must be of the form '<NRW><osm id>[:<class>]'.
190 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
191 NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
192 assert cur.rowcount == 0, \
193 "Found {} entries for ID {}".format(cur.rowcount, oid)
196 @then("search_name contains(?P<exclude> not)?")
197 def check_search_name_contents(context, exclude):
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 arbitry. The rows are identified via the 'object' column which must
201 have an identifier of the form '<NRW><osm id>[:<class>]'. All
202 expected rows are expected to be present with at least one database row.
204 tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
206 with tokenizer.name_analyzer() as analyzer:
207 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
208 for row in context.table:
209 nid = NominatimID(row['object'])
210 nid.row_by_place_id(cur, 'search_name',
211 ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
212 assert cur.rowcount > 0, "No rows found for " + row['object']
215 db_row = DBRow(nid, res, context)
216 for name, value in zip(row.headings, row.cells):
217 if name in ('name_vector', 'nameaddress_vector'):
218 items = [x.strip() for x in value.split(',')]
219 tokens = analyzer.get_word_token_info(items)
222 assert len(tokens) >= len(items), \
223 "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
224 for word, token, wid in tokens:
226 assert wid not in res[name], \
227 "Found term for {}/{}: {}".format(nid, name, wid)
229 assert wid in res[name], \
230 "Missing term for {}/{}: {}".format(nid, name, wid)
231 elif name != 'object':
232 assert db_row.contains(name, value), db_row.assert_msg(name, value)
234 @then("search_name has no entry for (?P<oid>.*)")
235 def check_search_name_has_entry(context, oid):
236 """ Check that there is noentry in the search_name table for the given
237 objects. IDs are in format '<NRW><osm id>[:<class>]'.
239 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
240 NominatimID(oid).row_by_place_id(cur, 'search_name')
242 assert cur.rowcount == 0, \
243 "Found {} entries for ID {}".format(cur.rowcount, oid)
245 @then("location_postcode contains exactly")
246 def check_location_postcode(context):
247 """ Check full contents for location_postcode table. Each row represents a table row
248 and all data must match. Data not present in the expected table, may
249 be arbitry. The rows are identified via 'country' and 'postcode' columns.
250 All rows must be present as excepted and there must not be additional
253 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
254 cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
255 assert cur.rowcount == len(list(context.table)), \
256 "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
260 key = (row['country_code'], row['postcode'])
261 assert key not in results, "Postcode table has duplicate entry: {}".format(row)
262 results[key] = DBRow((row['country_code'],row['postcode']), row, context)
264 for row in context.table:
265 db_row = results.get((row['country'],row['postcode']))
266 assert db_row is not None, \
267 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
269 db_row.assert_row(row, ('country', 'postcode'))
271 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
272 def check_word_table_for_postcodes(context, exclude, postcodes):
273 """ Check that the tokenizer produces postcode tokens for the given
274 postcodes. The postcodes are a comma-separated list of postcodes.
277 nctx = context.nominatim
278 tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
279 with tokenizer.name_analyzer() as ana:
280 plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
284 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
285 if nctx.tokenizer != 'legacy':
286 cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
289 cur.execute("""SELECT word FROM word WHERE word = any(%s)
290 and class = 'place' and type = 'postcode'""",
293 found = [row[0] for row in cur]
294 assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
297 assert len(found) == 0, f"Unexpected postcodes: {found}"
299 assert set(found) == set(plist), \
300 f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
302 @then("place_addressline contains")
303 def check_place_addressline(context):
304 """ Check the contents of the place_addressline table. Each row represents
305 a table row and all data must match. Data not present in the expected
306 table, may be arbitry. The rows are identified via the 'object' column,
307 representing the addressee and the 'address' column, representing the
310 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
311 for row in context.table:
312 nid = NominatimID(row['object'])
313 pid = nid.get_place_id(cur)
314 apid = NominatimID(row['address']).get_place_id(cur)
315 cur.execute(""" SELECT * FROM place_addressline
316 WHERE place_id = %s AND address_place_id = %s""",
318 assert cur.rowcount > 0, \
319 "No rows found for place %s and address %s" % (row['object'], row['address'])
322 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
324 @then("place_addressline doesn't contain")
325 def check_place_addressline_exclude(context):
326 """ Check that the place_addressline doesn't contain any entries for the
327 given addressee/address item pairs.
329 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
330 for row in context.table:
331 pid = NominatimID(row['object']).get_place_id(cur)
332 apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
334 cur.execute(""" SELECT * FROM place_addressline
335 WHERE place_id = %s AND address_place_id = %s""",
337 assert cur.rowcount == 0, \
338 "Row found for place %s and address %s" % (row['object'], row['address'])
340 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
341 def check_location_property_osmline(context, oid, neg):
342 """ Check that the given way is present in the interpolation table.
344 with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
345 cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
346 FROM location_property_osmline
347 WHERE osm_id = %s AND startnumber IS NOT NULL""",
351 assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
354 todo = list(range(len(list(context.table))))
357 row = context.table[i]
358 if (int(row['start']) == res['startnumber']
359 and int(row['end']) == res['endnumber']):
363 assert False, "Unexpected row " + str(res)
365 DBRow(oid, res, context).assert_row(row, ('start', 'end'))