1 # SPDX-License-Identifier: GPL-3.0-or-later
3 # This file is part of Nominatim. (https://nominatim.org)
5 # Copyright (C) 2026 by the Nominatim developer community.
6 # For a full list of authors see the git log.
8 Tests for functions to maintain the artificial postcode table.
15 from psycopg.rows import tuple_row
17 from nominatim_db.tools import postcodes
18 from nominatim_db.data import country_info
20 import dummy_tokenizer
24 def insert_implicit_postcode(placex_row, place_postcode_row, country_row):
25 """ Insert data into the placex and place table
26 which can then be used to compute one postcode.
28 def _insert_implicit_postcode(osm_id, country, geometry, postcode, in_placex=False):
29 country_row(country=country, names={"name": country})
32 placex_row(osm_id=osm_id, country=country, geom=geometry,
34 address={'postcode': postcode})
36 place_postcode_row(osm_id=osm_id, centroid=geometry,
37 country=country, postcode=postcode)
38 return _insert_implicit_postcode
42 def insert_postcode_area(place_postcode_row, country_row):
43 """ Insert an area around a centroid to the postcode table.
45 def _do(osm_id, country, postcode, x, y):
46 country_row(country=country, names={"name": country})
48 x1, x2, y1, y2 = x - 0.001, x + 0.001, y - 0.001, y + 0.001
49 place_postcode_row(osm_type='R', osm_id=osm_id, postcode=postcode, country=country,
50 centroid=f"POINT({x} {y})",
51 geom=f"POLYGON(({x1} {y1}, {x1} {y2}, {x2} {y2}, {x2} {y1}, {x1} {y1}))")
56 def postcode_update(dsn, temp_db_conn):
57 tokenizer = dummy_tokenizer.DummyTokenizer(None)
59 def _do(data_path=None):
60 with temp_db_conn.cursor() as cur:
61 cur.execute("""CREATE TRIGGER location_postcodes_before_update
62 BEFORE UPDATE ON location_postcodes
63 FOR EACH ROW EXECUTE PROCEDURE postcodes_update()""")
64 cur.execute("""CREATE TRIGGER location_postcodes_before_delete
65 BEFORE DELETE ON location_postcodes
66 FOR EACH ROW EXECUTE PROCEDURE postcodes_delete()""")
67 cur.execute("""CREATE TRIGGER location_postcodes_before_insert
68 BEFORE INSERT ON location_postcodes
69 FOR EACH ROW EXECUTE PROCEDURE postcodes_insert()""")
71 postcodes.update_postcodes(dsn, data_path, tokenizer)
76 @pytest.fixture(autouse=True)
77 def setup(self, def_config, postcode_table, placex_table, place_postcode_table,
78 load_sql, temp_db_conn):
79 self.conn = temp_db_conn
80 country_info.setup_country_config(def_config)
81 load_sql('functions/postcode_triggers.sql')
83 temp_db_conn.execute("""
84 CREATE OR REPLACE FUNCTION get_country_code(place geometry)
89 CREATE OR REPLACE FUNCTION expand_by_meters(geom GEOMETRY, meters FLOAT)
90 RETURNS GEOMETRY AS $$
91 SELECT ST_Envelope(ST_Buffer(geom::geography, meters, 1)::geometry)
97 with self.conn.cursor(row_factory=tuple_row) as cur:
98 cur.execute("""SELECT osm_id, country_code, postcode,
99 ST_X(centroid), ST_Y(centroid)
100 FROM location_postcodes""")
101 return {r for r in cur}
103 def test_postcodes_empty(self, postcode_update):
106 assert not self.row_set
108 @pytest.mark.parametrize('in_placex', [True, False])
109 def test_postcodes_add_new_point(self, postcode_update, postcode_row,
110 insert_implicit_postcode, in_placex):
111 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', '9486', in_placex)
112 postcode_row('yy', '9486', 99, 34)
116 assert self.row_set == {(None, 'xx', '9486', 10, 12), }
118 def test_postcodes_add_new_area(self, postcode_update, insert_postcode_area):
119 insert_postcode_area(345, 'de', '10445', 23.5, 46.2)
123 assert self.row_set == {(345, 'de', '10445', 23.5, 46.2)}
125 @pytest.mark.parametrize('in_placex', [True, False])
126 def test_postcodes_add_area_and_point(self, postcode_update, insert_postcode_area,
127 insert_implicit_postcode, in_placex):
128 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', '10445', in_placex)
129 insert_postcode_area(345, 'xx', '10445', 23.5, 46.2)
133 assert self.row_set == {(345, 'xx', '10445', 23.5, 46.2)}
135 @pytest.mark.parametrize('in_placex', [True, False])
136 def test_postcodes_add_point_within_area(self, postcode_update, insert_postcode_area,
137 insert_implicit_postcode, in_placex):
138 insert_implicit_postcode(1, 'xx', 'POINT(23.5 46.2)', '10446', in_placex)
139 insert_postcode_area(345, 'xx', '10445', 23.5, 46.2)
143 assert self.row_set == {(345, 'xx', '10445', 23.5, 46.2)}
145 @pytest.mark.parametrize('coords', [(99, 34), (10, 34), (99, 12),
146 (9, 34), (9, 11), (23, 11)])
147 def test_postcodes_replace_coordinates(self, postcode_update, postcode_row, tmp_path,
148 insert_implicit_postcode, coords):
149 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
150 postcode_row('xx', 'AB 4511', *coords)
152 postcode_update(tmp_path)
154 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12)}
156 def test_postcodes_replace_coordinates_close(self, postcode_update, postcode_row,
157 insert_implicit_postcode):
158 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
159 postcode_row('xx', 'AB 4511', 10, 11.99999999)
163 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 11.99999999)}
165 def test_postcodes_remove_point(self, postcode_update, postcode_row,
166 insert_implicit_postcode):
167 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
168 postcode_row('xx', 'badname', 10, 12)
172 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12)}
174 def test_postcodes_ignore_empty_country(self, postcode_update, insert_implicit_postcode):
175 insert_implicit_postcode(1, None, 'POINT(10 12)', 'AB 4511')
177 assert not self.row_set
179 def test_postcodes_remove_all(self, postcode_update, postcode_row, place_postcode_table):
180 postcode_row('ch', '5613', 10, 12)
183 assert not self.row_set
185 def test_postcodes_multi_country(self, postcode_update,
186 insert_implicit_postcode):
187 insert_implicit_postcode(1, 'de', 'POINT(10 12)', '54451')
188 insert_implicit_postcode(2, 'cc', 'POINT(100 56)', 'DD23 T')
189 insert_implicit_postcode(3, 'de', 'POINT(10.3 11.0)', '54452')
190 insert_implicit_postcode(4, 'cc', 'POINT(10.3 11.0)', '54452')
194 assert self.row_set == {(None, 'de', '54451', 10, 12),
195 (None, 'de', '54452', 10.3, 11.0),
196 (None, 'cc', '54452', 10.3, 11.0),
197 (None, 'cc', 'DD23 T', 100, 56)}
199 @pytest.mark.parametrize("gzipped", [True, False])
200 def test_postcodes_extern_jsonl(self, postcode_update, tmp_path,
201 insert_implicit_postcode, gzipped):
202 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
204 extfile = tmp_path / 'xx_postcodes_geometry.jsonl'
206 json.dumps({'properties': {'postcode': 'CD 4511'},
207 # Centroid : 0.5, 0.5
208 'geometry': {'type': 'Polygon', 'coordinates': [[
209 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}}) + '\n' +
210 json.dumps({'properties': {'postcode': '822114', 'lat': 0.1, 'lon': 0.2},
211 'geometry': {'type': 'Polygon', 'coordinates': [[
212 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}}) + '\n', encoding='utf-8')
215 subprocess.run(['gzip', str(extfile)])
216 assert not extfile.is_file()
218 postcode_update(tmp_path)
220 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12),
221 (None, 'xx', 'CD 4511', 0.5, 0.5),
222 (None, 'xx', '822114', 0.2, 0.1)}
224 def test_postcodes_extern_jsonl_invalid_data(self, postcode_update, tmp_path,
225 insert_implicit_postcode):
226 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
228 extfile = tmp_path / 'xx_postcodes_geometry.jsonl'
231 '{"geometry": {"type": "Polygon"}, "properties": {"postcode": "BAD"}\n'
233 + json.dumps({'properties': {'postcode': 'NOGEOM'}}) + '\n'
234 # Invalid geometry type
235 + json.dumps({'geometry': {'type': 'Point', 'coordinates': [0, 0]},
236 'properties': {'postcode': 'BADGEOM'}}) + '\n'
238 + json.dumps({'properties': {'lat': 0, 'lon': 0},
239 'geometry': {'type': 'Polygon', 'coordinates': [[
240 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}}) + '\n'
242 + json.dumps({'geometry': {'type': 'Polygon', 'coordinates': [[
243 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]},
244 'properties': {'postcode': 'GOOD'}}) + '\n', encoding='utf-8')
246 postcode_update(tmp_path)
248 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12),
249 (None, 'xx', 'GOOD', 0.5, 0.5)}
251 def test_postcodes_extern_jsonl_overwrite_past_import(self, postcode_update, tmp_path,
252 postcode_row, country_row):
253 country_row(country="xx", names={"name": "xx"})
254 postcode_row('xx', '822114', 83.8, 24.1, True) # area from past geometry import
255 postcode_row('xx', '110000', 77.2, 28.6, True)
257 extfile = tmp_path / 'xx_postcodes_geometry.jsonl'
259 # overwrites past postcode
260 json.dumps({'properties': {'postcode': '822114'},
261 'geometry': {'type': 'Polygon', 'coordinates': [[
262 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}}) + '\n', encoding='utf-8')
264 postcode_update(tmp_path)
266 # pc 110000 no longer exist in import file, thus deleted
267 assert self.row_set == {(None, 'xx', '822114', 0.5, 0.5)}
269 @pytest.mark.parametrize("gzipped", [True, False])
270 def test_postcodes_extern_csv(self, postcode_update, tmp_path,
271 insert_implicit_postcode, gzipped):
272 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
274 extfile = tmp_path / 'xx_postcodes.csv'
275 extfile.write_text("postcode,lat,lon\nAB 4511,-4,-1\nCD 4511,-5, -10", encoding='utf-8')
278 subprocess.run(['gzip', str(extfile)])
279 assert not extfile.is_file()
281 postcode_update(tmp_path)
283 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12),
284 (None, 'xx', 'CD 4511', -10, -5)}
286 def test_postcodes_extern_csv_bad_column(self, postcode_update, tmp_path,
287 insert_implicit_postcode):
288 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
290 extfile = tmp_path / 'xx_postcodes.csv'
291 extfile.write_text("postode,lat,lon\nAB 4511,-4,-1\nCD 4511,-5, -10", encoding='utf-8')
293 postcode_update(tmp_path)
295 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12)}
297 def test_postcodes_extern_csv_bad_number(self, postcode_update, insert_implicit_postcode,
299 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', 'AB 4511')
301 extfile = tmp_path / 'xx_postcodes.csv'
303 "postcode,lat,lon\nXX 4511,-4,NaN\nCD 4511,-5, -10\n34,200,0", encoding='utf-8')
305 postcode_update(tmp_path)
307 assert self.row_set == {(None, 'xx', 'AB 4511', 10, 12),
308 (None, 'xx', 'CD 4511', -10, -5)}
310 def test_postcodes_import_precedence(self, postcode_update, tmp_path,
311 insert_implicit_postcode, insert_postcode_area):
312 # osm area precedes all external imoprts
313 insert_postcode_area(3, 'xx', '110000', 77.2, 28.6)
314 # guessed postcode area from osm points, should be overwritten by geometry import
315 insert_implicit_postcode(1, 'xx', 'POINT(10 12)', '822114')
316 insert_implicit_postcode(2, 'xx', 'POINT(0 12)', '822114')
318 josnlfile = tmp_path / 'xx_postcodes_geometry.jsonl'
319 josnlfile.write_text(
320 # ignored, osm area takes precedence over geometry import
321 json.dumps({'properties': {'postcode': '110000', 'lat': 0.1, 'lon': 0.2},
322 'geometry': {'type': 'Polygon', 'coordinates': [[
323 [0, 0], [0, 1], [1, 1], [1, 0], [0, 0]]]}}) + '\n' +
324 # geometry import takes precedence over guessed postcode from osm points
325 json.dumps({'properties': {'postcode': '822114'},
326 'geometry': {'type': 'Polygon', 'coordinates': [[
327 [0, 0], [0, 10], [10, 10], [10, 0], [0, 0]]]}}) + '\n' +
328 # geometry import takes precedence over csv import
329 json.dumps({'properties': {'postcode': '822115'},
330 'geometry': {'type': 'Polygon', 'coordinates': [[
331 [0, 0], [0, 2], [2, 2], [2, 0], [0, 0]]]}}) + '\n', encoding='utf-8')
333 csvfile = tmp_path / 'xx_postcodes.csv'
336 "822115,0.2,0.2\n" # ignored, geometry import takes precedence over csv import
337 "822116,-5,-10", # added
340 postcode_update(tmp_path)
342 assert self.row_set == {(None, 'xx', '822114', 5, 5),
343 (None, 'xx', '822115', 1, 1),
344 (3, 'xx', '110000', 77.2, 28.6),
345 (None, 'xx', '822116', -10, -5)}
347 def test_no_placex_entry(self, postcode_update, temp_db_cursor, place_postcode_row):
348 # Rewrite the get_country_code function to verify its execution.
349 temp_db_cursor.execute("""
350 CREATE OR REPLACE FUNCTION get_country_code(place geometry) RETURNS TEXT AS $$
351 SELECT 'yy' $$ LANGUAGE sql""")
352 place_postcode_row(centroid='POINT(10 12)', postcode='AB 4511')
355 assert self.row_set == {(None, 'yy', 'AB 4511', 10, 12)}
357 def test_discard_badly_formatted_postcodes(self, postcode_update, place_postcode_row):
358 place_postcode_row(centroid='POINT(10 12)', country='fr', postcode='AB 4511')
361 assert not self.row_set
364 def test_can_compute(dsn, table_factory):
365 assert not postcodes.can_compute(dsn)
366 table_factory('place_postcode')
367 assert postcodes.can_compute(dsn)