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