]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tools/migration.py
type annotations for DB utils
[nominatim.git] / nominatim / tools / migration.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 """
8 Functions for database migration to newer software versions.
9 """
10 from typing import List, Tuple, Callable
11 import logging
12
13 from psycopg2 import sql as pysql
14
15 from nominatim.db import properties
16 from nominatim.db.connection import connect
17 from nominatim.version import NOMINATIM_VERSION, version_str
18 from nominatim.tools import refresh
19 from nominatim.tokenizer import factory as tokenizer_factory
20 from nominatim.errors import UsageError
21
22 LOG = logging.getLogger()
23
24 _MIGRATION_FUNCTIONS : List[Tuple[str, Callable]] = []
25
26 def migrate(config, paths):
27     """ Check for the current database version and execute migrations,
28         if necesssary.
29     """
30     with connect(config.get_libpq_dsn()) as conn:
31         if conn.table_exists('nominatim_properties'):
32             db_version_str = properties.get_property(conn, 'database_version')
33         else:
34             db_version_str = None
35
36         if db_version_str is not None:
37             parts = db_version_str.split('.')
38             db_version = tuple(int(x) for x in parts[:2] + parts[2].split('-'))
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         has_run_migration = False
50         for version, func in _MIGRATION_FUNCTIONS:
51             if db_version <= version:
52                 LOG.warning("Runnning: %s (%s)", func.__doc__.split('\n', 1)[0],
53                             version_str(version))
54                 kwargs = dict(conn=conn, config=config, paths=paths)
55                 func(**kwargs)
56                 conn.commit()
57                 has_run_migration = True
58
59         if has_run_migration:
60             LOG.warning('Updating SQL functions.')
61             refresh.create_functions(conn, config)
62             tokenizer = tokenizer_factory.get_tokenizer_for_db(config)
63             tokenizer.update_sql_functions(config)
64
65         properties.set_property(conn, 'database_version', version_str())
66
67         conn.commit()
68
69     return 0
70
71
72 def _guess_version(conn):
73     """ Guess a database version when there is no property table yet.
74         Only migrations for 3.6 and later are supported, so bail out
75         when the version seems older.
76     """
77     with conn.cursor() as cur:
78         # In version 3.6, the country_name table was updated. Check for that.
79         cnt = cur.scalar("""SELECT count(*) FROM
80                             (SELECT svals(name) FROM  country_name
81                              WHERE country_code = 'gb')x;
82                          """)
83         if cnt < 100:
84             LOG.fatal('It looks like your database was imported with a version '
85                       'prior to 3.6.0. Automatic migration not possible.')
86             raise UsageError('Migration not possible.')
87
88     return (3, 5, 0, 99)
89
90
91
92 def _migration(major, minor, patch=0, dbpatch=0):
93     """ Decorator for a single migration step. The parameters describe the
94         version after which the migration is applicable, i.e before changing
95         from the given version to the next, the migration is required.
96
97         All migrations are run in the order in which they are defined in this
98         file. Do not run global SQL scripts for migrations as you cannot be sure
99         that these scripts do the same in later versions.
100
101         Functions will always be reimported in full at the end of the migration
102         process, so the migration functions may leave a temporary state behind
103         there.
104     """
105     def decorator(func):
106         _MIGRATION_FUNCTIONS.append(((major, minor, patch, dbpatch), func))
107         return func
108
109     return decorator
110
111
112 @_migration(3, 5, 0, 99)
113 def import_status_timestamp_change(conn, **_):
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, config, **_):
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, **_):
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, **_):
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, config, **_):
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)
205
206
207 @_migration(4, 0, 99, 0)
208 def create_tiger_housenumber_index(conn, **_):
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, **_):
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, **_):
237     """ Add a new column 'step' to the interpolations table.
238
239         Also convers 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, **_):
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, **_):
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, config, **_):
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)