]> git.openstreetmap.org Git - nominatim.git/blob - test/bdd/steps/steps_db_ops.py
release 4.5.0.post7
[nominatim.git] / test / bdd / steps / steps_db_ops.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 import logging
8 from itertools import chain
9
10 import psycopg
11 from psycopg import sql as pysql
12
13 from place_inserter import PlaceColumn
14 from table_compare import NominatimID, DBRow
15
16 from nominatim_db.indexer import indexer
17 from nominatim_db.tokenizer import factory as tokenizer_factory
18
19 def check_database_integrity(context):
20     """ Check some generic constraints on the tables.
21     """
22     with context.db.cursor(row_factory=psycopg.rows.tuple_row) as cur:
23         # place_addressline should not have duplicate (place_id, address_place_id)
24         cur.execute("""SELECT count(*) FROM
25                         (SELECT place_id, address_place_id, count(*) as c
26                          FROM place_addressline GROUP BY place_id, address_place_id) x
27                        WHERE c > 1""")
28         assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
29
30         # word table must not have empty word_tokens
31         cur.execute("SELECT count(*) FROM word WHERE word_token = ''")
32         assert cur.fetchone()[0] == 0, "Empty word tokens found in word table"
33
34
35
36 ################################ GIVEN ##################################
37
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.
42     """
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')
48
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.
53     """
54     with context.db.cursor() as cur:
55         cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
56         row = cur.fetchone()
57         if row is None or row['value'] == '1':
58             for r in context.table:
59                 last_node = 0
60                 last_way = 0
61                 parts = []
62                 if r['members']:
63                     members = []
64                     for m in r['members'].split(','):
65                         mid = NominatimID(m)
66                         if mid.typ == 'N':
67                             parts.insert(last_node, int(mid.oid))
68                             last_node += 1
69                             last_way += 1
70                         elif mid.typ == 'W':
71                             parts.insert(last_way, int(mid.oid))
72                             last_way += 1
73                         else:
74                             parts.append(int(mid.oid))
75
76                         members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
77                 else:
78                     members = None
79
80                 tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
81
82                 cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags)
83                                VALUES (%s, %s, %s, %s, %s, %s)""",
84                             (r['id'], last_node, last_way, parts, members, list(tags)))
85         else:
86             for r in context.table:
87                 if r['members']:
88                     members = []
89                     for m in r['members'].split(','):
90                         mid = NominatimID(m)
91                         members.append({'ref': mid.oid, 'role': mid.cls or '', 'type': mid.typ})
92                 else:
93                     members = []
94
95                 tags = {h[5:]: r[h] for h in r.headings if h.startswith("tags+")}
96
97                 cur.execute("""INSERT INTO planet_osm_rels (id, tags, members)
98                                VALUES (%s, %s, %s)""",
99                             (r['id'], psycopg.types.json.Json(tags),
100                              psycopg.types.json.Json(members)))
101
102 @given("the ways")
103 def add_data_to_planet_ways(context):
104     """ Add entries into the osm2pgsql way middle table. This is necessary for
105         tests on that that looks up node ids in this table.
106     """
107     with context.db.cursor() as cur:
108         cur.execute("SELECT value FROM osm2pgsql_properties WHERE property = 'db_format'")
109         row = cur.fetchone()
110         json_tags = row is not None and row['value'] != '1'
111         for r in context.table:
112             if json_tags:
113                 tags = psycopg.types.json.Json({h[5:]: r[h] for h in r.headings if h.startswith("tags+")})
114             else:
115                 tags = list(chain.from_iterable([(h[5:], r[h])
116                                                  for h in r.headings if h.startswith("tags+")]))
117             nodes = [ int(x.strip()) for x in r['nodes'].split(',') ]
118
119             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
120                         (r['id'], nodes, tags))
121
122 ################################ WHEN ##################################
123
124 @when("importing")
125 def import_and_index_data_from_place_table(context):
126     """ Import data previously set up in the place table.
127     """
128     context.nominatim.run_nominatim('import', '--continue', 'load-data',
129                                               '--index-noanalyse', '-q',
130                                               '--offline')
131
132     check_database_integrity(context)
133
134     # Remove the output of the input, when all was right. Otherwise it will be
135     # output when there are errors that had nothing to do with the import
136     # itself.
137     context.log_capture.buffer.clear()
138
139 @when("updating places")
140 def update_place_table(context):
141     """ Update the place table with the given data. Also runs all triggers
142         related to updates and reindexes the new data.
143     """
144     context.nominatim.run_nominatim('refresh', '--functions')
145     with context.db.cursor() as cur:
146         for row in context.table:
147             col = PlaceColumn(context).add_row(row, False)
148             col.db_delete(cur)
149             col.db_insert(cur)
150         cur.execute('SELECT flush_deleted_places()')
151
152     context.nominatim.reindex_placex(context.db)
153     check_database_integrity(context)
154
155     # Remove the output of the input, when all was right. Otherwise it will be
156     # output when there are errors that had nothing to do with the import
157     # itself.
158     context.log_capture.buffer.clear()
159
160
161 @when("updating postcodes")
162 def update_postcodes(context):
163     """ Rerun the calculation of postcodes.
164     """
165     context.nominatim.run_nominatim('refresh', '--postcodes')
166
167 @when("marking for delete (?P<oids>.*)")
168 def delete_places(context, oids):
169     """ Remove entries from the place table. Multiple ids may be given
170         separated by commas. Also runs all triggers
171         related to updates and reindexes the new data.
172     """
173     context.nominatim.run_nominatim('refresh', '--functions')
174     with context.db.cursor() as cur:
175         cur.execute('TRUNCATE place_to_be_deleted')
176         for oid in oids.split(','):
177             NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
178         cur.execute('SELECT flush_deleted_places()')
179
180     context.nominatim.reindex_placex(context.db)
181
182     # Remove the output of the input, when all was right. Otherwise it will be
183     # output when there are errors that had nothing to do with the import
184     # itself.
185     context.log_capture.buffer.clear()
186
187 ################################ THEN ##################################
188
189 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
190 def check_place_contents(context, table, exact):
191     """ Check contents of place/placex tables. Each row represents a table row
192         and all data must match. Data not present in the expected table, may
193         be arbitrary. The rows are identified via the 'object' column which must
194         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
195         rows match (for example because 'class' was left out and there are
196         multiple entries for the given OSM object) then all must match. All
197         expected rows are expected to be present with at least one database row.
198         When 'exactly' is given, there must not be additional rows in the database.
199     """
200     with context.db.cursor() as cur:
201         expected_content = set()
202         for row in context.table:
203             nid = NominatimID(row['object'])
204             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
205             if table == 'placex':
206                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
207             query += " FROM %s WHERE {}" % (table, )
208             nid.query_osm_id(cur, query)
209             assert cur.rowcount > 0, "No rows found for " + row['object']
210
211             for res in cur:
212                 if exact:
213                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
214
215                 DBRow(nid, res, context).assert_row(row, ['object'])
216
217         if exact:
218             cur.execute(pysql.SQL('SELECT osm_type, osm_id, class from')
219                         + pysql.Identifier(table))
220             actual = set([(r['osm_type'], r['osm_id'], r['class']) for r in cur])
221             assert expected_content == actual, \
222                    f"Missing entries: {expected_content - actual}\n" \
223                    f"Not expected in table: {actual - expected_content}"
224
225
226 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
227 def check_place_has_entry(context, table, oid):
228     """ Ensure that no database row for the given object exists. The ID
229         must be of the form '<NRW><osm id>[:<class>]'.
230     """
231     with context.db.cursor() as cur:
232         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
233         assert cur.rowcount == 0, \
234                "Found {} entries for ID {}".format(cur.rowcount, oid)
235
236
237 @then("search_name contains(?P<exclude> not)?")
238 def check_search_name_contents(context, exclude):
239     """ Check contents of place/placex tables. Each row represents a table row
240         and all data must match. Data not present in the expected table, may
241         be arbitrary. The rows are identified via the 'object' column which must
242         have an identifier of the form '<NRW><osm id>[:<class>]'. All
243         expected rows are expected to be present with at least one database row.
244     """
245     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
246
247     with tokenizer.name_analyzer() as analyzer:
248         with context.db.cursor() as cur:
249             for row in context.table:
250                 nid = NominatimID(row['object'])
251                 nid.row_by_place_id(cur, 'search_name',
252                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
253                 assert cur.rowcount > 0, "No rows found for " + row['object']
254
255                 for res in cur:
256                     db_row = DBRow(nid, res, context)
257                     for name, value in zip(row.headings, row.cells):
258                         if name in ('name_vector', 'nameaddress_vector'):
259                             items = [x.strip() for x in value.split(',')]
260                             tokens = analyzer.get_word_token_info(items)
261
262                             if not exclude:
263                                 assert len(tokens) >= len(items), \
264                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
265                             for word, token, wid in tokens:
266                                 if exclude:
267                                     assert wid not in res[name], \
268                                            "Found term for {}/{}: {}".format(nid, name, wid)
269                                 else:
270                                     assert wid in res[name], \
271                                            "Missing term for {}/{}: {}".format(nid, name, wid)
272                         elif name != 'object':
273                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
274
275 @then("search_name has no entry for (?P<oid>.*)")
276 def check_search_name_has_entry(context, oid):
277     """ Check that there is noentry in the search_name table for the given
278         objects. IDs are in format '<NRW><osm id>[:<class>]'.
279     """
280     with context.db.cursor() as cur:
281         NominatimID(oid).row_by_place_id(cur, 'search_name')
282
283         assert cur.rowcount == 0, \
284                "Found {} entries for ID {}".format(cur.rowcount, oid)
285
286 @then("location_postcode contains exactly")
287 def check_location_postcode(context):
288     """ Check full contents for location_postcode table. Each row represents a table row
289         and all data must match. Data not present in the expected table, may
290         be arbitrary. The rows are identified via 'country' and 'postcode' columns.
291         All rows must be present as excepted and there must not be additional
292         rows.
293     """
294     with context.db.cursor() as cur:
295         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
296         assert cur.rowcount == len(list(context.table)), \
297             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
298
299         results = {}
300         for row in cur:
301             key = (row['country_code'], row['postcode'])
302             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
303             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
304
305         for row in context.table:
306             db_row = results.get((row['country'],row['postcode']))
307             assert db_row is not None, \
308                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
309
310             db_row.assert_row(row, ('country', 'postcode'))
311
312 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
313 def check_word_table_for_postcodes(context, exclude, postcodes):
314     """ Check that the tokenizer produces postcode tokens for the given
315         postcodes. The postcodes are a comma-separated list of postcodes.
316         Whitespace matters.
317     """
318     nctx = context.nominatim
319     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
320     with tokenizer.name_analyzer() as ana:
321         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
322
323     plist.sort()
324
325     with context.db.cursor() as cur:
326         cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
327                     (plist,))
328
329         found = [row['word'] for row in cur]
330         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
331
332     if exclude:
333         assert len(found) == 0, f"Unexpected postcodes: {found}"
334     else:
335         assert set(found) == set(plist), \
336         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
337
338 @then("place_addressline contains")
339 def check_place_addressline(context):
340     """ Check the contents of the place_addressline table. Each row represents
341         a table row and all data must match. Data not present in the expected
342         table, may be arbitrary. The rows are identified via the 'object' column,
343         representing the addressee and the 'address' column, representing the
344         address item.
345     """
346     with context.db.cursor() as cur:
347         for row in context.table:
348             nid = NominatimID(row['object'])
349             pid = nid.get_place_id(cur)
350             apid = NominatimID(row['address']).get_place_id(cur)
351             cur.execute(""" SELECT * FROM place_addressline
352                             WHERE place_id = %s AND address_place_id = %s""",
353                         (pid, apid))
354             assert cur.rowcount > 0, \
355                         "No rows found for place %s and address %s" % (row['object'], row['address'])
356
357             for res in cur:
358                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
359
360 @then("place_addressline doesn't contain")
361 def check_place_addressline_exclude(context):
362     """ Check that the place_addressline doesn't contain any entries for the
363         given addressee/address item pairs.
364     """
365     with context.db.cursor() as cur:
366         for row in context.table:
367             pid = NominatimID(row['object']).get_place_id(cur)
368             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
369             if apid is not None:
370                 cur.execute(""" SELECT * FROM place_addressline
371                                 WHERE place_id = %s AND address_place_id = %s""",
372                             (pid, apid))
373                 assert cur.rowcount == 0, \
374                     "Row found for place %s and address %s" % (row['object'], row['address'])
375
376 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
377 def check_location_property_osmline(context, oid, neg):
378     """ Check that the given way is present in the interpolation table.
379     """
380     with context.db.cursor() as cur:
381         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
382                        FROM location_property_osmline
383                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
384                     (oid, ))
385
386         if neg:
387             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
388             return
389
390         todo = list(range(len(list(context.table))))
391         for res in cur:
392             for i in todo:
393                 row = context.table[i]
394                 if (int(row['start']) == res['startnumber']
395                     and int(row['end']) == res['endnumber']):
396                     todo.remove(i)
397                     break
398             else:
399                 assert False, "Unexpected row " + str(res)
400
401             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
402
403         assert not todo, f"Unmatched lines in table: {list(context.table[i] for i in todo)}"
404
405 @then("location_property_osmline contains(?P<exact> exactly)?")
406 def check_place_contents(context, exact):
407     """ Check contents of the interpolation table. Each row represents a table row
408         and all data must match. Data not present in the expected table, may
409         be arbitrary. The rows are identified via the 'object' column which must
410         have an identifier of the form '<osm id>[:<startnumber>]'. When multiple
411         rows match (for example because 'startnumber' was left out and there are
412         multiple entries for the given OSM object) then all must match. All
413         expected rows are expected to be present with at least one database row.
414         When 'exactly' is given, there must not be additional rows in the database.
415     """
416     with context.db.cursor() as cur:
417         expected_content = set()
418         for row in context.table:
419             if ':' in row['object']:
420                 nid, start = row['object'].split(':', 2)
421                 start = int(start)
422             else:
423                 nid, start = row['object'], None
424
425             query = """SELECT *, ST_AsText(linegeo) as geomtxt,
426                               ST_GeometryType(linegeo) as geometrytype
427                        FROM location_property_osmline WHERE osm_id=%s"""
428
429             if ':' in row['object']:
430                 query += ' and startnumber = %s'
431                 params = [int(val) for val in row['object'].split(':', 2)]
432             else:
433                 params = (int(row['object']), )
434
435             cur.execute(query, params)
436             assert cur.rowcount > 0, "No rows found for " + row['object']
437
438             for res in cur:
439                 if exact:
440                     expected_content.add((res['osm_id'], res['startnumber']))
441
442                 DBRow(nid, res, context).assert_row(row, ['object'])
443
444         if exact:
445             cur.execute('SELECT osm_id, startnumber from location_property_osmline')
446             actual = set([(r['osm_id'], r['startnumber']) for r in cur])
447             assert expected_content == actual, \
448                    f"Missing entries: {expected_content - actual}\n" \
449                    f"Not expected in table: {actual - expected_content}"
450