]> git.openstreetmap.org Git - nominatim.git/blob - src/nominatim_db/tools/migration.py
0712f187ff51dd1b659b2995c3789387d5b2b75b
[nominatim.git] / src / nominatim_db / tools / migration.py
1 # SPDX-License-Identifier: GPL-3.0-or-later
2 #
3 # This file is part of Nominatim. (https://nominatim.org)
4 #
5 # Copyright (C) 2024 by the Nominatim developer community.
6 # For a full list of authors see the git log.
7 """
8 Functions for database migration to newer software versions.
9 """
10 from typing import List, Tuple, Callable, Any
11 import logging
12
13 from psycopg2 import sql as pysql
14
15 from nominatim_core.errors import UsageError
16 from nominatim_core.config import Configuration
17 from nominatim_core.db import properties
18 from nominatim_core.db.connection import connect, Connection
19 from ..version import NominatimVersion, NOMINATIM_VERSION, parse_version
20 from ..tokenizer import factory as tokenizer_factory
21 from . import refresh
22
23 LOG = logging.getLogger()
24
25 _MIGRATION_FUNCTIONS : List[Tuple[NominatimVersion, Callable[..., None]]] = []
26
27 def migrate(config: Configuration, paths: Any) -> int:
28     """ Check for the current database version and execute migrations,
29         if necesssary.
30     """
31     with connect(config.get_libpq_dsn()) as conn:
32         if conn.table_exists('nominatim_properties'):
33             db_version_str = properties.get_property(conn, 'database_version')
34         else:
35             db_version_str = None
36
37         if db_version_str is not None:
38             db_version = parse_version(db_version_str)
39
40             if db_version == NOMINATIM_VERSION:
41                 LOG.warning("Database already at latest version (%s)", db_version_str)
42                 return 0
43
44             LOG.info("Detected database version: %s", db_version_str)
45         else:
46             db_version = _guess_version(conn)
47
48
49         for version, func in _MIGRATION_FUNCTIONS:
50             if db_version < version or \
51                (db_version == (3, 5, 0, 99) and version == (3, 5, 0, 99)):
52                 title = func.__doc__ or ''
53                 LOG.warning("Running: %s (%s)", title.split('\n', 1)[0], version)
54                 kwargs = dict(conn=conn, config=config, paths=paths)
55                 func(**kwargs)
56                 conn.commit()
57
58         LOG.warning('Updating SQL functions.')
59         refresh.create_functions(conn, config)
60         tokenizer = tokenizer_factory.get_tokenizer_for_db(config)
61         tokenizer.update_sql_functions(config)
62
63         properties.set_property(conn, 'database_version', str(NOMINATIM_VERSION))
64
65         conn.commit()
66
67     return 0
68
69
70 def _guess_version(conn: Connection) -> NominatimVersion:
71     """ Guess a database version when there is no property table yet.
72         Only migrations for 3.6 and later are supported, so bail out
73         when the version seems older.
74     """
75     with conn.cursor() as cur:
76         # In version 3.6, the country_name table was updated. Check for that.
77         cnt = cur.scalar("""SELECT count(*) FROM
78                             (SELECT svals(name) FROM  country_name
79                              WHERE country_code = 'gb')x;
80                          """)
81         if cnt < 100:
82             LOG.fatal('It looks like your database was imported with a version '
83                       'prior to 3.6.0. Automatic migration not possible.')
84             raise UsageError('Migration not possible.')
85
86     return NominatimVersion(3, 5, 0, 99)
87
88
89
90 def _migration(major: int, minor: int, patch: int = 0,
91                dbpatch: int = 0) -> Callable[[Callable[..., None]], Callable[..., None]]:
92     """ Decorator for a single migration step. The parameters describe the
93         version after which the migration is applicable, i.e before changing
94         from the given version to the next, the migration is required.
95
96         All migrations are run in the order in which they are defined in this
97         file. Do not run global SQL scripts for migrations as you cannot be sure
98         that these scripts do the same in later versions.
99
100         Functions will always be reimported in full at the end of the migration
101         process, so the migration functions may leave a temporary state behind
102         there.
103     """
104     def decorator(func: Callable[..., None]) -> Callable[..., None]:
105         version = NominatimVersion(major, minor, patch, dbpatch)
106         _MIGRATION_FUNCTIONS.append((version, func))
107         return func
108
109     return decorator
110
111
112 @_migration(3, 5, 0, 99)
113 def import_status_timestamp_change(conn: Connection, **_: Any) -> None:
114     """ Add timezone to timestamp in status table.
115
116         The import_status table has been changed to include timezone information
117         with the time stamp.
118     """
119     with conn.cursor() as cur:
120         cur.execute("""ALTER TABLE import_status ALTER COLUMN lastimportdate
121                        TYPE timestamp with time zone;""")
122
123
124 @_migration(3, 5, 0, 99)
125 def add_nominatim_property_table(conn: Connection, config: Configuration, **_: Any) -> None:
126     """ Add nominatim_property table.
127     """
128     if not conn.table_exists('nominatim_properties'):
129         with conn.cursor() as cur:
130             cur.execute(pysql.SQL("""CREATE TABLE nominatim_properties (
131                                         property TEXT,
132                                         value TEXT);
133                                      GRANT SELECT ON TABLE nominatim_properties TO {};
134                                   """).format(pysql.Identifier(config.DATABASE_WEBUSER)))
135
136 @_migration(3, 6, 0, 0)
137 def change_housenumber_transliteration(conn: Connection, **_: Any) -> None:
138     """ Transliterate housenumbers.
139
140         The database schema switched from saving raw housenumbers in
141         placex.housenumber to saving transliterated ones.
142
143         Note: the function create_housenumber_id() has been dropped in later
144               versions.
145     """
146     with conn.cursor() as cur:
147         cur.execute("""CREATE OR REPLACE FUNCTION create_housenumber_id(housenumber TEXT)
148                        RETURNS TEXT AS $$
149                        DECLARE
150                          normtext TEXT;
151                        BEGIN
152                          SELECT array_to_string(array_agg(trans), ';')
153                            INTO normtext
154                            FROM (SELECT lookup_word as trans,
155                                         getorcreate_housenumber_id(lookup_word)
156                                  FROM (SELECT make_standard_name(h) as lookup_word
157                                        FROM regexp_split_to_table(housenumber, '[,;]') h) x) y;
158                          return normtext;
159                        END;
160                        $$ LANGUAGE plpgsql STABLE STRICT;""")
161         cur.execute("DELETE FROM word WHERE class = 'place' and type = 'house'")
162         cur.execute("""UPDATE placex
163                        SET housenumber = create_housenumber_id(housenumber)
164                        WHERE housenumber is not null""")
165
166
167 @_migration(3, 7, 0, 0)
168 def switch_placenode_geometry_index(conn: Connection, **_: Any) -> None:
169     """ Replace idx_placex_geometry_reverse_placeNode index.
170
171         Make the index slightly more permissive, so that it can also be used
172         when matching up boundaries and place nodes. It makes the index
173         idx_placex_adminname index unnecessary.
174     """
175     with conn.cursor() as cur:
176         cur.execute(""" CREATE INDEX IF NOT EXISTS idx_placex_geometry_placenode ON placex
177                         USING GIST (geometry)
178                         WHERE osm_type = 'N' and rank_search < 26
179                               and class = 'place' and type != 'postcode'
180                               and linked_place_id is null""")
181         cur.execute(""" DROP INDEX IF EXISTS idx_placex_adminname """)
182
183
184 @_migration(3, 7, 0, 1)
185 def install_legacy_tokenizer(conn: Connection, config: Configuration, **_: Any) -> None:
186     """ Setup legacy tokenizer.
187
188         If no other tokenizer has been configured yet, then create the
189         configuration for the backwards-compatible legacy tokenizer
190     """
191     if properties.get_property(conn, 'tokenizer') is None:
192         with conn.cursor() as cur:
193             for table in ('placex', 'location_property_osmline'):
194                 has_column = cur.scalar("""SELECT count(*) FROM information_schema.columns
195                                            WHERE table_name = %s
196                                            and column_name = 'token_info'""",
197                                         (table, ))
198                 if has_column == 0:
199                     cur.execute(pysql.SQL('ALTER TABLE {} ADD COLUMN token_info JSONB')
200                                 .format(pysql.Identifier(table)))
201         tokenizer = tokenizer_factory.create_tokenizer(config, init_db=False,
202                                                        module_name='legacy')
203
204         tokenizer.migrate_database(config) # type: ignore[attr-defined]
205
206
207 @_migration(4, 0, 99, 0)
208 def create_tiger_housenumber_index(conn: Connection, **_: Any) -> None:
209     """ Create idx_location_property_tiger_parent_place_id with included
210         house number.
211
212         The inclusion is needed for efficient lookup of housenumbers in
213         full address searches.
214     """
215     if conn.server_version_tuple() >= (11, 0, 0):
216         with conn.cursor() as cur:
217             cur.execute(""" CREATE INDEX IF NOT EXISTS
218                                 idx_location_property_tiger_housenumber_migrated
219                             ON location_property_tiger
220                             USING btree(parent_place_id)
221                             INCLUDE (startnumber, endnumber) """)
222
223
224 @_migration(4, 0, 99, 1)
225 def create_interpolation_index_on_place(conn: Connection, **_: Any) -> None:
226     """ Create idx_place_interpolations for lookup of interpolation lines
227         on updates.
228     """
229     with conn.cursor() as cur:
230         cur.execute("""CREATE INDEX IF NOT EXISTS idx_place_interpolations
231                        ON place USING gist(geometry)
232                        WHERE osm_type = 'W' and address ? 'interpolation'""")
233
234
235 @_migration(4, 0, 99, 2)
236 def add_step_column_for_interpolation(conn: Connection, **_: Any) -> None:
237     """ Add a new column 'step' to the interpolations table.
238
239         Also converts the data into the stricter format which requires that
240         startnumbers comply with the odd/even requirements.
241     """
242     if conn.table_has_column('location_property_osmline', 'step'):
243         return
244
245     with conn.cursor() as cur:
246         # Mark invalid all interpolations with no intermediate numbers.
247         cur.execute("""UPDATE location_property_osmline SET startnumber = null
248                        WHERE endnumber - startnumber <= 1 """)
249         # Align the start numbers where odd/even does not match.
250         cur.execute("""UPDATE location_property_osmline
251                        SET startnumber = startnumber + 1,
252                            linegeo = ST_LineSubString(linegeo,
253                                                       1.0 / (endnumber - startnumber)::float,
254                                                       1)
255                        WHERE (interpolationtype = 'odd' and startnumber % 2 = 0)
256                               or (interpolationtype = 'even' and startnumber % 2 = 1)
257                     """)
258         # Mark invalid odd/even interpolations with no intermediate numbers.
259         cur.execute("""UPDATE location_property_osmline SET startnumber = null
260                        WHERE interpolationtype in ('odd', 'even')
261                              and endnumber - startnumber = 2""")
262         # Finally add the new column and populate it.
263         cur.execute("ALTER TABLE location_property_osmline ADD COLUMN step SMALLINT")
264         cur.execute("""UPDATE location_property_osmline
265                          SET step = CASE WHEN interpolationtype = 'all'
266                                          THEN 1 ELSE 2 END
267                     """)
268
269
270 @_migration(4, 0, 99, 3)
271 def add_step_column_for_tiger(conn: Connection, **_: Any) -> None:
272     """ Add a new column 'step' to the tiger data table.
273     """
274     if conn.table_has_column('location_property_tiger', 'step'):
275         return
276
277     with conn.cursor() as cur:
278         cur.execute("ALTER TABLE location_property_tiger ADD COLUMN step SMALLINT")
279         cur.execute("""UPDATE location_property_tiger
280                          SET step = CASE WHEN interpolationtype = 'all'
281                                          THEN 1 ELSE 2 END
282                     """)
283
284
285 @_migration(4, 0, 99, 4)
286 def add_derived_name_column_for_country_names(conn: Connection, **_: Any) -> None:
287     """ Add a new column 'derived_name' which in the future takes the
288         country names as imported from OSM data.
289     """
290     if not conn.table_has_column('country_name', 'derived_name'):
291         with conn.cursor() as cur:
292             cur.execute("ALTER TABLE country_name ADD COLUMN derived_name public.HSTORE")
293
294
295 @_migration(4, 0, 99, 5)
296 def mark_internal_country_names(conn: Connection, config: Configuration, **_: Any) -> None:
297     """ Names from the country table should be marked as internal to prevent
298         them from being deleted. Only necessary for ICU tokenizer.
299     """
300     import psycopg2.extras # pylint: disable=import-outside-toplevel
301
302     tokenizer = tokenizer_factory.get_tokenizer_for_db(config)
303     with tokenizer.name_analyzer() as analyzer:
304         with conn.cursor() as cur:
305             psycopg2.extras.register_hstore(cur)
306             cur.execute("SELECT country_code, name FROM country_name")
307
308             for country_code, names in cur:
309                 if not names:
310                     names = {}
311                 names['countrycode'] = country_code
312                 analyzer.add_country_names(country_code, names)
313
314
315 @_migration(4, 1, 99, 0)
316 def add_place_deletion_todo_table(conn: Connection, **_: Any) -> None:
317     """ Add helper table for deleting data on updates.
318
319         The table is only necessary when updates are possible, i.e.
320         the database is not in freeze mode.
321     """
322     if conn.table_exists('place'):
323         with conn.cursor() as cur:
324             cur.execute("""CREATE TABLE IF NOT EXISTS place_to_be_deleted (
325                              osm_type CHAR(1),
326                              osm_id BIGINT,
327                              class TEXT,
328                              type TEXT,
329                              deferred BOOLEAN)""")
330
331
332 @_migration(4, 1, 99, 1)
333 def split_pending_index(conn: Connection, **_: Any) -> None:
334     """ Reorganise indexes for pending updates.
335     """
336     if conn.table_exists('place'):
337         with conn.cursor() as cur:
338             cur.execute("""CREATE INDEX IF NOT EXISTS idx_placex_rank_address_sector
339                            ON placex USING BTREE (rank_address, geometry_sector)
340                            WHERE indexed_status > 0""")
341             cur.execute("""CREATE INDEX IF NOT EXISTS idx_placex_rank_boundaries_sector
342                            ON placex USING BTREE (rank_search, geometry_sector)
343                            WHERE class = 'boundary' and type = 'administrative'
344                                  and indexed_status > 0""")
345             cur.execute("DROP INDEX IF EXISTS idx_placex_pendingsector")
346
347
348 @_migration(4, 2, 99, 0)
349 def enable_forward_dependencies(conn: Connection, **_: Any) -> None:
350     """ Create indexes for updates with forward dependency tracking (long-running).
351     """
352     if conn.table_exists('planet_osm_ways'):
353         with conn.cursor() as cur:
354             cur.execute("""SELECT * FROM pg_indexes
355                            WHERE tablename = 'planet_osm_ways'
356                                  and indexdef LIKE '%nodes%'""")
357             if cur.rowcount == 0:
358                 cur.execute("""CREATE OR REPLACE FUNCTION public.planet_osm_index_bucket(bigint[])
359                                RETURNS bigint[]
360                                LANGUAGE sql IMMUTABLE
361                                 AS $function$
362                                   SELECT ARRAY(SELECT DISTINCT unnest($1) >> 5)
363                                 $function$""")
364                 cur.execute("""CREATE INDEX planet_osm_ways_nodes_bucket_idx
365                                  ON planet_osm_ways
366                                  USING gin (planet_osm_index_bucket(nodes))
367                                  WITH (fastupdate=off)""")
368                 cur.execute("""CREATE INDEX planet_osm_rels_parts_idx
369                                  ON planet_osm_rels USING gin (parts)
370                                  WITH (fastupdate=off)""")
371                 cur.execute("ANALYZE planet_osm_ways")
372
373
374 @_migration(4, 2, 99, 1)
375 def add_improved_geometry_reverse_placenode_index(conn: Connection, **_: Any) -> None:
376     """ Create improved index for reverse lookup of place nodes.
377     """
378     with conn.cursor() as cur:
379         cur.execute("""CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPlaceNode
380                        ON placex
381                        USING gist (ST_Buffer(geometry, reverse_place_diameter(rank_search)))
382                        WHERE rank_address between 4 and 25 AND type != 'postcode'
383                          AND name is not null AND linked_place_id is null AND osm_type = 'N'
384                     """)
385
386 @_migration(4, 4, 99, 0)
387 def create_postcode_area_lookup_index(conn: Connection, **_: Any) -> None:
388     """ Create index needed for looking up postcode areas from postocde points.
389     """
390     with conn.cursor() as cur:
391         cur.execute("""CREATE INDEX IF NOT EXISTS idx_placex_postcode_areas
392                        ON placex USING BTREE (country_code, postcode)
393                        WHERE osm_type = 'R' AND class = 'boundary' AND type = 'postal_code'
394                     """)
395
396
397 @_migration(4, 4, 99, 1)
398 def create_postcode_parent_index(conn: Connection, **_: Any) -> None:
399     """ Create index needed for updating postcodes when a parent changes.
400     """
401     if conn.table_exists('planet_osm_ways'):
402         with conn.cursor() as cur:
403             cur.execute("""CREATE INDEX IF NOT EXISTS
404                              idx_location_postcode_parent_place_id
405                              ON location_postcode USING BTREE (parent_place_id)""")