]> git.openstreetmap.org Git - nominatim.git/blob - test/bdd/steps/steps_db_ops.py
bdd: move update tests from scenes to grid descriptions
[nominatim.git] / test / bdd / steps / steps_db_ops.py
1 # SPDX-License-Identifier: GPL-2.0-only
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2022 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 psycopg2.extras
11
12 from place_inserter import PlaceColumn
13 from table_compare import NominatimID, DBRow
14
15 from nominatim.indexer import indexer
16 from nominatim.tokenizer import factory as tokenizer_factory
17
18 def check_database_integrity(context):
19     """ Check some generic constraints on the tables.
20     """
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
26                    WHERE c > 1""")
27     assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
28
29
30 ################################ GIVEN ##################################
31
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.
36     """
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')
42
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.
47     """
48     with context.db.cursor() as cur:
49         for r in context.table:
50             last_node = 0
51             last_way = 0
52             parts = []
53             if r['members']:
54                 members = []
55                 for m in r['members'].split(','):
56                     mid = NominatimID(m)
57                     if mid.typ == 'N':
58                         parts.insert(last_node, int(mid.oid))
59                         last_node += 1
60                         last_way += 1
61                     elif mid.typ == 'W':
62                         parts.insert(last_way, int(mid.oid))
63                         last_way += 1
64                     else:
65                         parts.append(int(mid.oid))
66
67                     members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
68             else:
69                 members = None
70
71             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
72
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)))
76
77 @given("the ways")
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.
81     """
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(',') ]
86
87             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
88                         (r['id'], nodes, list(tags)))
89
90 ################################ WHEN ##################################
91
92 @when("importing")
93 def import_and_index_data_from_place_table(context):
94     """ Import data previously set up in the place table.
95     """
96     context.nominatim.run_nominatim('import', '--continue', 'load-data',
97                                               '--index-noanalyse', '-q',
98                                               '--offline')
99
100     check_database_integrity(context)
101
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
104     # itself.
105     context.log_capture.buffer.clear()
106
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.
111     """
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)
116
117     context.nominatim.reindex_placex(context.db)
118     check_database_integrity(context)
119
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
122     # itself.
123     context.log_capture.buffer.clear()
124
125
126 @when("updating postcodes")
127 def update_postcodes(context):
128     """ Rerun the calculation of postcodes.
129     """
130     context.nominatim.run_nominatim('refresh', '--postcodes')
131
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.
137     """
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 {}')
142
143     context.nominatim.reindex_placex(context.db)
144
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
147     # itself.
148     context.log_capture.buffer.clear()
149
150 ################################ THEN ##################################
151
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.
162     """
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']
173
174             for res in cur:
175                 if exact:
176                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
177
178                 DBRow(nid, res, context).assert_row(row, ['object'])
179
180         if exact:
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])
183
184
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>]'.
189     """
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)
194
195
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.
203     """
204     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
205
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']
213
214                 for res in cur:
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)
220
221                             if not exclude:
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:
225                                 if exclude:
226                                     assert wid not in res[name], \
227                                            "Found term for {}/{}: {}".format(nid, name, wid)
228                                 else:
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)
233
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>]'.
238     """
239     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
240         NominatimID(oid).row_by_place_id(cur, 'search_name')
241
242         assert cur.rowcount == 0, \
243                "Found {} entries for ID {}".format(cur.rowcount, oid)
244
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
251         rows.
252     """
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)))
257
258         results = {}
259         for row in cur:
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)
263
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']}'."
268
269             db_row.assert_row(row, ('country', 'postcode'))
270
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.
275         Whitespace matters.
276     """
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(',')]
281
282     plist.sort()
283
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)",
287                         (plist,))
288         else:
289             cur.execute("""SELECT word FROM word WHERE word = any(%s)
290                              and class = 'place' and type = 'postcode'""",
291                         (plist,))
292
293         found = [row[0] for row in cur]
294         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
295
296     if exclude:
297         assert len(found) == 0, f"Unexpected postcodes: {found}"
298     else:
299         assert set(found) == set(plist), \
300         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
301
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
308         address item.
309     """
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""",
317                         (pid, apid))
318             assert cur.rowcount > 0, \
319                         "No rows found for place %s and address %s" % (row['object'], row['address'])
320
321             for res in cur:
322                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
323
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.
328     """
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)
333             if apid is not None:
334                 cur.execute(""" SELECT * FROM place_addressline
335                                 WHERE place_id = %s AND address_place_id = %s""",
336                             (pid, apid))
337                 assert cur.rowcount == 0, \
338                     "Row found for place %s and address %s" % (row['object'], row['address'])
339
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.
343     """
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""",
348                     (oid, ))
349
350         if neg:
351             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
352             return
353
354         todo = list(range(len(list(context.table))))
355         for res in cur:
356             for i in todo:
357                 row = context.table[i]
358                 if (int(row['start']) == res['startnumber']
359                     and int(row['end']) == res['endnumber']):
360                     todo.remove(i)
361                     break
362             else:
363                 assert False, "Unexpected row " + str(res)
364
365             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
366
367         assert not todo
368
369