]> git.openstreetmap.org Git - nominatim.git/blob - test/bdd/steps/steps_db_ops.py
bdd tests: do not query word table directly
[nominatim.git] / test / bdd / steps / steps_db_ops.py
1 import logging
2 from itertools import chain
3
4 import psycopg2.extras
5
6 from place_inserter import PlaceColumn
7 from table_compare import NominatimID, DBRow
8
9 from nominatim.indexer import indexer
10 from nominatim.tokenizer import factory as tokenizer_factory
11
12 def check_database_integrity(context):
13     """ Check some generic constraints on the tables.
14     """
15     # place_addressline should not have duplicate (place_id, address_place_id)
16     cur = context.db.cursor()
17     cur.execute("""SELECT count(*) FROM
18                     (SELECT place_id, address_place_id, count(*) as c
19                      FROM place_addressline GROUP BY place_id, address_place_id) x
20                    WHERE c > 1""")
21     assert cur.fetchone()[0] == 0, "Duplicates found in place_addressline"
22
23
24 ################################ GIVEN ##################################
25
26 @given("the (?P<named>named )?places")
27 def add_data_to_place_table(context, named):
28     """ Add entries into the place table. 'named places' makes sure that
29         the entries get a random name when none is explicitly given.
30     """
31     with context.db.cursor() as cur:
32         cur.execute('ALTER TABLE place DISABLE TRIGGER place_before_insert')
33         for row in context.table:
34             PlaceColumn(context).add_row(row, named is not None).db_insert(cur)
35         cur.execute('ALTER TABLE place ENABLE TRIGGER place_before_insert')
36
37 @given("the relations")
38 def add_data_to_planet_relations(context):
39     """ Add entries into the osm2pgsql relation middle table. This is needed
40         for tests on data that looks up members.
41     """
42     with context.db.cursor() as cur:
43         for r in context.table:
44             last_node = 0
45             last_way = 0
46             parts = []
47             if r['members']:
48                 members = []
49                 for m in r['members'].split(','):
50                     mid = NominatimID(m)
51                     if mid.typ == 'N':
52                         parts.insert(last_node, int(mid.oid))
53                         last_node += 1
54                         last_way += 1
55                     elif mid.typ == 'W':
56                         parts.insert(last_way, int(mid.oid))
57                         last_way += 1
58                     else:
59                         parts.append(int(mid.oid))
60
61                     members.extend((mid.typ.lower() + mid.oid, mid.cls or ''))
62             else:
63                 members = None
64
65             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
66
67             cur.execute("""INSERT INTO planet_osm_rels (id, way_off, rel_off, parts, members, tags)
68                            VALUES (%s, %s, %s, %s, %s, %s)""",
69                         (r['id'], last_node, last_way, parts, members, list(tags)))
70
71 @given("the ways")
72 def add_data_to_planet_ways(context):
73     """ Add entries into the osm2pgsql way middle table. This is necessary for
74         tests on that that looks up node ids in this table.
75     """
76     with context.db.cursor() as cur:
77         for r in context.table:
78             tags = chain.from_iterable([(h[5:], r[h]) for h in r.headings if h.startswith("tags+")])
79             nodes = [ int(x.strip()) for x in r['nodes'].split(',') ]
80
81             cur.execute("INSERT INTO planet_osm_ways (id, nodes, tags) VALUES (%s, %s, %s)",
82                         (r['id'], nodes, list(tags)))
83
84 ################################ WHEN ##################################
85
86 @when("importing")
87 def import_and_index_data_from_place_table(context):
88     """ Import data previously set up in the place table.
89     """
90     nctx = context.nominatim
91
92     tokenizer = tokenizer_factory.create_tokenizer(nctx.get_test_config())
93     context.nominatim.copy_from_place(context.db)
94
95     # XXX use tool function as soon as it is ported
96     with context.db.cursor() as cur:
97         with (context.nominatim.src_dir / 'lib-sql' / 'postcode_tables.sql').open('r') as fd:
98             cur.execute(fd.read())
99         cur.execute("""
100             INSERT INTO location_postcode
101              (place_id, indexed_status, country_code, postcode, geometry)
102             SELECT nextval('seq_place'), 1, country_code,
103                    upper(trim (both ' ' from address->'postcode')) as pc,
104                    ST_Centroid(ST_Collect(ST_Centroid(geometry)))
105               FROM placex
106              WHERE address ? 'postcode' AND address->'postcode' NOT SIMILAR TO '%(,|;)%'
107                    AND geometry IS NOT null
108              GROUP BY country_code, pc""")
109
110     # Call directly as the refresh function does not include postcodes.
111     indexer.LOG.setLevel(logging.ERROR)
112     indexer.Indexer(context.nominatim.get_libpq_dsn(), tokenizer, 1).index_full(analyse=False)
113
114     check_database_integrity(context)
115
116 @when("updating places")
117 def update_place_table(context):
118     """ Update the place table with the given data. Also runs all triggers
119         related to updates and reindexes the new data.
120     """
121     context.nominatim.run_nominatim('refresh', '--functions')
122     with context.db.cursor() as cur:
123         for row in context.table:
124             PlaceColumn(context).add_row(row, False).db_insert(cur)
125
126     context.nominatim.reindex_placex(context.db)
127     check_database_integrity(context)
128
129 @when("updating postcodes")
130 def update_postcodes(context):
131     """ Rerun the calculation of postcodes.
132     """
133     context.nominatim.run_nominatim('refresh', '--postcodes')
134
135 @when("marking for delete (?P<oids>.*)")
136 def delete_places(context, oids):
137     """ Remove entries from the place table. Multiple ids may be given
138         separated by commas. Also runs all triggers
139         related to updates and reindexes the new data.
140     """
141     context.nominatim.run_nominatim('refresh', '--functions')
142     with context.db.cursor() as cur:
143         for oid in oids.split(','):
144             NominatimID(oid).query_osm_id(cur, 'DELETE FROM place WHERE {}')
145
146     context.nominatim.reindex_placex(context.db)
147
148 ################################ THEN ##################################
149
150 @then("(?P<table>placex|place) contains(?P<exact> exactly)?")
151 def check_place_contents(context, table, exact):
152     """ Check contents of place/placex tables. Each row represents a table row
153         and all data must match. Data not present in the expected table, may
154         be arbitry. The rows are identified via the 'object' column which must
155         have an identifier of the form '<NRW><osm id>[:<class>]'. When multiple
156         rows match (for example because 'class' was left out and there are
157         multiple entries for the given OSM object) then all must match. All
158         expected rows are expected to be present with at least one database row.
159         When 'exactly' is given, there must not be additional rows in the database.
160     """
161     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
162         expected_content = set()
163         for row in context.table:
164             nid = NominatimID(row['object'])
165             query = 'SELECT *, ST_AsText(geometry) as geomtxt, ST_GeometryType(geometry) as geometrytype'
166             if table == 'placex':
167                 query += ' ,ST_X(centroid) as cx, ST_Y(centroid) as cy'
168             query += " FROM %s WHERE {}" % (table, )
169             nid.query_osm_id(cur, query)
170             assert cur.rowcount > 0, "No rows found for " + row['object']
171
172             for res in cur:
173                 if exact:
174                     expected_content.add((res['osm_type'], res['osm_id'], res['class']))
175
176                 DBRow(nid, res, context).assert_row(row, ['object'])
177
178         if exact:
179             cur.execute('SELECT osm_type, osm_id, class from {}'.format(table))
180             assert expected_content == set([(r[0], r[1], r[2]) for r in cur])
181
182
183 @then("(?P<table>placex|place) has no entry for (?P<oid>.*)")
184 def check_place_has_entry(context, table, oid):
185     """ Ensure that no database row for the given object exists. The ID
186         must be of the form '<NRW><osm id>[:<class>]'.
187     """
188     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
189         NominatimID(oid).query_osm_id(cur, "SELECT * FROM %s where {}" % table)
190         assert cur.rowcount == 0, \
191                "Found {} entries for ID {}".format(cur.rowcount, oid)
192
193
194 @then("search_name contains(?P<exclude> not)?")
195 def check_search_name_contents(context, exclude):
196     """ Check contents of place/placex tables. Each row represents a table row
197         and all data must match. Data not present in the expected table, may
198         be arbitry. The rows are identified via the 'object' column which must
199         have an identifier of the form '<NRW><osm id>[:<class>]'. All
200         expected rows are expected to be present with at least one database row.
201     """
202     tokenizer = tokenizer_factory.get_tokenizer_for_db(context.nominatim.get_test_config())
203
204     with tokenizer.name_analyzer() as analyzer:
205         with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
206             for row in context.table:
207                 nid = NominatimID(row['object'])
208                 nid.row_by_place_id(cur, 'search_name',
209                                     ['ST_X(centroid) as cx', 'ST_Y(centroid) as cy'])
210                 assert cur.rowcount > 0, "No rows found for " + row['object']
211
212                 for res in cur:
213                     db_row = DBRow(nid, res, context)
214                     for name, value in zip(row.headings, row.cells):
215                         if name in ('name_vector', 'nameaddress_vector'):
216                             items = [x.strip() for x in value.split(',')]
217                             tokens = analyzer.get_word_token_info(items)
218
219                             if not exclude:
220                                 assert len(tokens) >= len(items), \
221                                        "No word entry found for {}. Entries found: {!s}".format(value, len(tokens))
222                             for word, token, wid in tokens:
223                                 if exclude:
224                                     assert wid not in res[name], \
225                                            "Found term for {}/{}: {}".format(nid, name, wid)
226                                 else:
227                                     assert wid in res[name], \
228                                            "Missing term for {}/{}: {}".format(nid, name, wid)
229                         elif name != 'object':
230                             assert db_row.contains(name, value), db_row.assert_msg(name, value)
231
232 @then("search_name has no entry for (?P<oid>.*)")
233 def check_search_name_has_entry(context, oid):
234     """ Check that there is noentry in the search_name table for the given
235         objects. IDs are in format '<NRW><osm id>[:<class>]'.
236     """
237     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
238         NominatimID(oid).row_by_place_id(cur, 'search_name')
239
240         assert cur.rowcount == 0, \
241                "Found {} entries for ID {}".format(cur.rowcount, oid)
242
243 @then("location_postcode contains exactly")
244 def check_location_postcode(context):
245     """ Check full contents for location_postcode table. Each row represents a table row
246         and all data must match. Data not present in the expected table, may
247         be arbitry. The rows are identified via 'country' and 'postcode' columns.
248         All rows must be present as excepted and there must not be additional
249         rows.
250     """
251     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
252         cur.execute("SELECT *, ST_AsText(geometry) as geomtxt FROM location_postcode")
253         assert cur.rowcount == len(list(context.table)), \
254             "Postcode table has {} rows, expected {}.".format(cur.rowcount, len(list(context.table)))
255
256         results = {}
257         for row in cur:
258             key = (row['country_code'], row['postcode'])
259             assert key not in results, "Postcode table has duplicate entry: {}".format(row)
260             results[key] = DBRow((row['country_code'],row['postcode']), row, context)
261
262         for row in context.table:
263             db_row = results.get((row['country'],row['postcode']))
264             assert db_row is not None, \
265                 "Missing row for country '{r['country']}' postcode '{r['postcode']}'.".format(r=row)
266
267             db_row.assert_row(row, ('country', 'postcode'))
268
269 @then("word contains(?P<exclude> not)?")
270 def check_word_table(context, exclude):
271     """ Check the contents of the word table. Each row represents a table row
272         and all data must match. Data not present in the expected table, may
273         be arbitry. The rows are identified via all given columns.
274     """
275     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
276         for row in context.table:
277             wheres = ' AND '.join(["{} = %s".format(h) for h in row.headings])
278             cur.execute("SELECT * from word WHERE " + wheres, list(row.cells))
279             if exclude:
280                 assert cur.rowcount == 0, "Row still in word table: %s" % '/'.join(values)
281             else:
282                 assert cur.rowcount > 0, "Row not in word table: %s" % '/'.join(values)
283
284
285 @then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
286 def check_word_table_for_postcodes(context, exclude, postcodes):
287     """ Check that the tokenizer produces postcode tokens for the given
288         postcodes. The postcodes are a comma-separated list of postcodes.
289         Whitespace matters.
290     """
291     nctx = context.nominatim
292     tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
293     with tokenizer.name_analyzer() as ana:
294         plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
295
296     plist.sort()
297
298     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
299         if nctx.tokenizer == 'legacy_icu':
300             cur.execute("""SELECT info->>'postcode' FROM word
301                            WHERE type = 'P' and info->>'postcode' = any(%s)""",
302                         (plist,))
303         else:
304             cur.execute("""SELECT word FROM word WHERE word = any(%s)
305                              and class = 'place' and type = 'postcode'""",
306                         (plist,))
307
308         found = [row[0] for row in cur]
309         assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
310
311     if exclude:
312         assert len(found) == 0, f"Unexpected postcodes: {found}"
313     else:
314         assert set(found) == set(plist), \
315         f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
316
317 @then("place_addressline contains")
318 def check_place_addressline(context):
319     """ Check the contents of the place_addressline table. Each row represents
320         a table row and all data must match. Data not present in the expected
321         table, may be arbitry. The rows are identified via the 'object' column,
322         representing the addressee and the 'address' column, representing the
323         address item.
324     """
325     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
326         for row in context.table:
327             nid = NominatimID(row['object'])
328             pid = nid.get_place_id(cur)
329             apid = NominatimID(row['address']).get_place_id(cur)
330             cur.execute(""" SELECT * FROM place_addressline
331                             WHERE place_id = %s AND address_place_id = %s""",
332                         (pid, apid))
333             assert cur.rowcount > 0, \
334                         "No rows found for place %s and address %s" % (row['object'], row['address'])
335
336             for res in cur:
337                 DBRow(nid, res, context).assert_row(row, ('address', 'object'))
338
339 @then("place_addressline doesn't contain")
340 def check_place_addressline_exclude(context):
341     """ Check that the place_addressline doesn't contain any entries for the
342         given addressee/address item pairs.
343     """
344     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
345         for row in context.table:
346             pid = NominatimID(row['object']).get_place_id(cur)
347             apid = NominatimID(row['address']).get_place_id(cur)
348             cur.execute(""" SELECT * FROM place_addressline
349                             WHERE place_id = %s AND address_place_id = %s""",
350                         (pid, apid))
351             assert cur.rowcount == 0, \
352                 "Row found for place %s and address %s" % (row['object'], row['address'])
353
354 @then("W(?P<oid>\d+) expands to(?P<neg> no)? interpolation")
355 def check_location_property_osmline(context, oid, neg):
356     """ Check that the given way is present in the interpolation table.
357     """
358     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
359         cur.execute("""SELECT *, ST_AsText(linegeo) as geomtxt
360                        FROM location_property_osmline
361                        WHERE osm_id = %s AND startnumber IS NOT NULL""",
362                     (oid, ))
363
364         if neg:
365             assert cur.rowcount == 0, "Interpolation found for way {}.".format(oid)
366             return
367
368         todo = list(range(len(list(context.table))))
369         for res in cur:
370             for i in todo:
371                 row = context.table[i]
372                 if (int(row['start']) == res['startnumber']
373                     and int(row['end']) == res['endnumber']):
374                     todo.remove(i)
375                     break
376             else:
377                 assert False, "Unexpected row " + str(res)
378
379             DBRow(oid, res, context).assert_row(row, ('start', 'end'))
380
381         assert not todo
382
383