]> git.openstreetmap.org Git - nominatim.git/blob - test/bdd/steps/steps_db_ops.py
Merge remote-tracking branch 'upstream/master'
[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     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
26                        WHERE c > 1""")
27         assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
28
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"
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         for r in context.table:
56             last_node = 0
57             last_way = 0
58             parts = []
59             if r['members']:
60                 members = []
61                 for m in r['members'].split(','):
62                     mid = NominatimID(m)
63                     if mid.typ == 'N':
64                         parts.insert(last_node, int(mid.oid))
65                         last_node += 1
66                         last_way += 1
67                     elif mid.typ == 'W':
68                         parts.insert(last_way, int(mid.oid))
69                         last_way += 1
70                     else:
71                         parts.append(int(mid.oid))
72
73                     members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
74             else:
75                 members = None
76
77             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
78
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)))
82
83 @given("the ways")
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.
87     """
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(',') ]
92
93             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
94                         (r['id'], nodes, list(tags)))
95
96 ################################ WHEN ##################################
97
98 @when("importing")
99 def import_and_index_data_from_place_table(context):
100     """ Import data previously set up in the place table.
101     """
102     context.nominatim.run_nominatim('import', '--continue', 'load-data',
103                                               '--index-noanalyse', '-q',
104                                               '--offline')
105
106     check_database_integrity(context)
107
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
110     # itself.
111     context.log_capture.buffer.clear()
112
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.
117     """
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)
122
123     context.nominatim.reindex_placex(context.db)
124     check_database_integrity(context)
125
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
128     # itself.
129     context.log_capture.buffer.clear()
130
131
132 @when("updating postcodes")
133 def update_postcodes(context):
134     """ Rerun the calculation of postcodes.
135     """
136     context.nominatim.run_nominatim('refresh', '--postcodes')
137
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.
143     """
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 {}')
148
149     context.nominatim.reindex_placex(context.db)
150
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
153     # itself.
154     context.log_capture.buffer.clear()
155
156 ################################ THEN ##################################
157
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.
168     """
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']
179
180             for res in cur:
181                 if exact:
182                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
183
184                 DBRow(nid, res, context).assert_row(row, ['object'])
185
186         if exact:
187             cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
188             assert expected_content == set([(r[0], r[1], r[2]) for r in cur])
189
190
191 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
192 def check_place_has_entry(context, table, oid):
193     """ Ensure that no database row for the given object exists. The ID
194         must be of the form '<NRW><osm id>[:<class>]'.
195     """
196     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
197         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
198         assert cur.rowcount == 0, \
199                "Found {} entries for ID {}".format(cur.rowcount, oid)
200
201
202 @then("search_name contains(?P<exclude> not)?")
203 def check_search_name_contents(context, exclude):
204     """ Check contents of place/placex tables. Each row represents a table row
205         and all data must match. Data not present in the expected table, may
206         be arbitry. The rows are identified via the 'object' column which must
207         have an identifier of the form '<NRW><osm id>[:<class>]'. All
208         expected rows are expected to be present with at least one database row.
209     """
210     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
211
212     with tokenizer.name_analyzer() as analyzer:
213         with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
214             for row in context.table:
215                 nid = NominatimID(row['object'])
216                 nid.row_by_place_id(cur, 'search_name',
217                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
218                 assert cur.rowcount > 0, "No rows found for " + row['object']
219
220                 for res in cur:
221                     db_row = DBRow(nid, res, context)
222                     for name, value in zip(row.headings, row.cells):
223                         if name in ('name_vector', 'nameaddress_vector'):
224                             items = [x.strip() for x in value.split(',')]
225                             tokens = analyzer.get_word_token_info(items)
226
227                             if not exclude:
228                                 assert len(tokens) >= len(items), \
229                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
230                             for word, token, wid in tokens:
231                                 if exclude:
232                                     assert wid not in res[name], \
233                                            "Found term for {}/{}: {}".format(nid, name, wid)
234                                 else:
235                                     assert wid in res[name], \
236                                            "Missing term for {}/{}: {}".format(nid, name, wid)
237                         elif name != 'object':
238                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
239
240 @then("search_name has no entry for (?P<oid>.*)")
241 def check_search_name_has_entry(context, oid):
242     """ Check that there is noentry in the search_name table for the given
243         objects. IDs are in format '<NRW><osm id>[:<class>]'.
244     """
245     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
246         NominatimID(oid).row_by_place_id(cur, 'search_name')
247
248         assert cur.rowcount == 0, \
249                "Found {} entries for ID {}".format(cur.rowcount, oid)
250
251 @then("location_postcode contains exactly")
252 def check_location_postcode(context):
253     """ Check full contents for location_postcode table. Each row represents a table row
254         and all data must match. Data not present in the expected table, may
255         be arbitry. The rows are identified via 'country' and 'postcode' columns.
256         All rows must be present as excepted and there must not be additional
257         rows.
258     """
259     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
260         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
261         assert cur.rowcount == len(list(context.table)), \
262             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
263
264         results = {}
265         for row in cur:
266             key = (row['country_code'], row['postcode'])
267             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
268             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
269
270         for row in context.table:
271             db_row = results.get((row['country'],row['postcode']))
272             assert db_row is not None, \
273                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
274
275             db_row.assert_row(row, ('country', 'postcode'))
276
277 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
278 def check_word_table_for_postcodes(context, exclude, postcodes):
279     """ Check that the tokenizer produces postcode tokens for the given
280         postcodes. The postcodes are a comma-separated list of postcodes.
281         Whitespace matters.
282     """
283     nctx = context.nominatim
284     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
285     with tokenizer.name_analyzer() as ana:
286         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
287
288     plist.sort()
289
290     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
291         if nctx.tokenizer != 'legacy':
292             cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
293                         (plist,))
294         else:
295             cur.execute("""SELECT word FROM word WHERE word = any(%s)
296                              and class = 'place' and type = 'postcode'""",
297                         (plist,))
298
299         found = [row[0] for row in cur]
300         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
301
302     if exclude:
303         assert len(found) == 0, f"Unexpected postcodes: {found}"
304     else:
305         assert set(found) == set(plist), \
306         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
307
308 @then("place_addressline contains")
309 def check_place_addressline(context):
310     """ Check the contents of the place_addressline table. Each row represents
311         a table row and all data must match. Data not present in the expected
312         table, may be arbitry. The rows are identified via the 'object' column,
313         representing the addressee and the 'address' column, representing the
314         address item.
315     """
316     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
317         for row in context.table:
318             nid = NominatimID(row['object'])
319             pid = nid.get_place_id(cur)
320             apid = NominatimID(row['address']).get_place_id(cur)
321             cur.execute(""" SELECT * FROM place_addressline
322                             WHERE place_id = %s AND address_place_id = %s""",
323                         (pid, apid))
324             assert cur.rowcount > 0, \
325                         "No rows found for place %s and address %s" % (row['object'], row['address'])
326
327             for res in cur:
328                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
329
330 @then("place_addressline doesn't contain")
331 def check_place_addressline_exclude(context):
332     """ Check that the place_addressline doesn't contain any entries for the
333         given addressee/address item pairs.
334     """
335     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
336         for row in context.table:
337             pid = NominatimID(row['object']).get_place_id(cur)
338             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
339             if apid is not None:
340                 cur.execute(""" SELECT * FROM place_addressline
341                                 WHERE place_id = %s AND address_place_id = %s""",
342                             (pid, apid))
343                 assert cur.rowcount == 0, \
344                     "Row found for place %s and address %s" % (row['object'], row['address'])
345
346 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
347 def check_location_property_osmline(context, oid, neg):
348     """ Check that the given way is present in the interpolation table.
349     """
350     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
351         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
352                        FROM location_property_osmline
353                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
354                     (oid, ))
355
356         if neg:
357             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
358             return
359
360         todo = list(range(len(list(context.table))))
361         for res in cur:
362             for i in todo:
363                 row = context.table[i]
364                 if (int(row['start']) == res['startnumber']
365                     and int(row['end']) == res['endnumber']):
366                     todo.remove(i)
367                     break
368             else:
369                 assert False, "Unexpected row " + str(res)
370
371             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
372
373         assert not todo
374
375