]> git.openstreetmap.org Git - nominatim.git/blob - test/bdd/steps/steps_db_ops.py
98ed6e1c3f247c3432a68d3502305c32be96f923
[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 ################################ THEN ##################################
146
147 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
148 def check_place_contents(context, table, exact):
149     """ Check contents of place/placex tables. Each row represents a table row
150         and all data must match. Data not present in the expected table, may
151         be arbitry. The rows are identified via the 'object' column which must
152         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
153         rows match (for example because 'class' was left out and there are
154         multiple entries for the given OSM object) then all must match. All
155         expected rows are expected to be present with at least one database row.
156         When 'exactly' is given, there must not be additional rows in the database.
157     """
158     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
159         expected_content = set()
160         for row in context.table:
161             nid = NominatimID(row['object'])
162             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
163             if table == 'placex':
164                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
165             query += " FROM %s WHERE {}" % (table, )
166             nid.query_osm_id(cur, query)
167             assert cur.rowcount > 0, "No rows found for " + row['object']
168
169             for res in cur:
170                 if exact:
171                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
172
173                 DBRow(nid, res, context).assert_row(row, ['object'])
174
175         if exact:
176             cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
177             assert expected_content == set([(r[0], r[1], r[2]) for r in cur])
178
179
180 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
181 def check_place_has_entry(context, table, oid):
182     """ Ensure that no database row for the given object exists. The ID
183         must be of the form '<NRW><osm id>[:<class>]'.
184     """
185     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
186         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
187         assert cur.rowcount == 0, \
188                "Found {} entries for ID {}".format(cur.rowcount, oid)
189
190
191 @then("search_name contains(?P<exclude> not)?")
192 def check_search_name_contents(context, exclude):
193     """ Check contents of place/placex tables. Each row represents a table row
194         and all data must match. Data not present in the expected table, may
195         be arbitry. The rows are identified via the 'object' column which must
196         have an identifier of the form '<NRW><osm id>[:<class>]'. All
197         expected rows are expected to be present with at least one database row.
198     """
199     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
200
201     with tokenizer.name_analyzer() as analyzer:
202         with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
203             for row in context.table:
204                 nid = NominatimID(row['object'])
205                 nid.row_by_place_id(cur, 'search_name',
206                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
207                 assert cur.rowcount > 0, "No rows found for " + row['object']
208
209                 for res in cur:
210                     db_row = DBRow(nid, res, context)
211                     for name, value in zip(row.headings, row.cells):
212                         if name in ('name_vector', 'nameaddress_vector'):
213                             items = [x.strip() for x in value.split(',')]
214                             tokens = analyzer.get_word_token_info(items)
215
216                             if not exclude:
217                                 assert len(tokens) >= len(items), \
218                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
219                             for word, token, wid in tokens:
220                                 if exclude:
221                                     assert wid not in res[name], \
222                                            "Found term for {}/{}: {}".format(nid, name, wid)
223                                 else:
224                                     assert wid in res[name], \
225                                            "Missing term for {}/{}: {}".format(nid, name, wid)
226                         elif name != 'object':
227                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
228
229 @then("search_name has no entry for (?P<oid>.*)")
230 def check_search_name_has_entry(context, oid):
231     """ Check that there is noentry in the search_name table for the given
232         objects. IDs are in format '<NRW><osm id>[:<class>]'.
233     """
234     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
235         NominatimID(oid).row_by_place_id(cur, 'search_name')
236
237         assert cur.rowcount == 0, \
238                "Found {} entries for ID {}".format(cur.rowcount, oid)
239
240 @then("location_postcode contains exactly")
241 def check_location_postcode(context):
242     """ Check full contents for location_postcode table. Each row represents a table row
243         and all data must match. Data not present in the expected table, may
244         be arbitry. The rows are identified via 'country' and 'postcode' columns.
245         All rows must be present as excepted and there must not be additional
246         rows.
247     """
248     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
249         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
250         assert cur.rowcount == len(list(context.table)), \
251             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
252
253         results = {}
254         for row in cur:
255             key = (row['country_code'], row['postcode'])
256             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
257             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
258
259         for row in context.table:
260             db_row = results.get((row['country'],row['postcode']))
261             assert db_row is not None, \
262                 f"Missing row for country '{row['country']}' postcode '{row['postcode']}'."
263
264             db_row.assert_row(row, ('country', 'postcode'))
265
266 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
267 def check_word_table_for_postcodes(context, exclude, postcodes):
268     """ Check that the tokenizer produces postcode tokens for the given
269         postcodes. The postcodes are a comma-separated list of postcodes.
270         Whitespace matters.
271     """
272     nctx = context.nominatim
273     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
274     with tokenizer.name_analyzer() as ana:
275         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
276
277     plist.sort()
278
279     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
280         if nctx.tokenizer != 'legacy':
281             cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
282                         (plist,))
283         else:
284             cur.execute("""SELECT word FROM word WHERE word = any(%s)
285                              and class = 'place' and type = 'postcode'""",
286                         (plist,))
287
288         found = [row[0] for row in cur]
289         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
290
291     if exclude:
292         assert len(found) == 0, f"Unexpected postcodes: {found}"
293     else:
294         assert set(found) == set(plist), \
295         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
296
297 @then("place_addressline contains")
298 def check_place_addressline(context):
299     """ Check the contents of the place_addressline table. Each row represents
300         a table row and all data must match. Data not present in the expected
301         table, may be arbitry. The rows are identified via the 'object' column,
302         representing the addressee and the 'address' column, representing the
303         address item.
304     """
305     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
306         for row in context.table:
307             nid = NominatimID(row['object'])
308             pid = nid.get_place_id(cur)
309             apid = NominatimID(row['address']).get_place_id(cur)
310             cur.execute(""" SELECT * FROM place_addressline
311                             WHERE place_id = %s AND address_place_id = %s""",
312                         (pid, apid))
313             assert cur.rowcount > 0, \
314                         "No rows found for place %s and address %s" % (row['object'], row['address'])
315
316             for res in cur:
317                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
318
319 @then("place_addressline doesn't contain")
320 def check_place_addressline_exclude(context):
321     """ Check that the place_addressline doesn't contain any entries for the
322         given addressee/address item pairs.
323     """
324     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
325         for row in context.table:
326             pid = NominatimID(row['object']).get_place_id(cur)
327             apid = NominatimID(row['address']).get_place_id(cur, allow_empty=True)
328             if apid is not None:
329                 cur.execute(""" SELECT * FROM place_addressline
330                                 WHERE place_id = %s AND address_place_id = %s""",
331                             (pid, apid))
332                 assert cur.rowcount == 0, \
333                     "Row found for place %s and address %s" % (row['object'], row['address'])
334
335 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
336 def check_location_property_osmline(context, oid, neg):
337     """ Check that the given way is present in the interpolation table.
338     """
339     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
340         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
341                        FROM location_property_osmline
342                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
343                     (oid, ))
344
345         if neg:
346             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
347             return
348
349         todo = list(range(len(list(context.table))))
350         for res in cur:
351             for i in todo:
352                 row = context.table[i]
353                 if (int(row['start']) == res['startnumber']
354                     and int(row['end']) == res['endnumber']):
355                     todo.remove(i)
356                     break
357             else:
358                 assert False, "Unexpected row " + str(res)
359
360             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
361
362         assert not todo
363
364