]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/legacy_tokenizer.py
move houseunumber handling to tokenizer
[nominatim.git] / nominatim / tokenizer / legacy_tokenizer.py
1 """
2 Tokenizer implementing normalisation as used before Nominatim 4.
3 """
4 import logging
5 import re
6 import shutil
7
8 import psycopg2
9 import psycopg2.extras
10
11 from nominatim.db.connection import connect
12 from nominatim.db import properties
13 from nominatim.db import utils as db_utils
14 from nominatim.db.sql_preprocessor import SQLPreprocessor
15 from nominatim.errors import UsageError
16
17 DBCFG_NORMALIZATION = "tokenizer_normalization"
18 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
19
20 LOG = logging.getLogger()
21
22 def create(dsn, data_dir):
23     """ Create a new instance of the tokenizer provided by this module.
24     """
25     return LegacyTokenizer(dsn, data_dir)
26
27
28 def _install_module(config_module_path, src_dir, module_dir):
29     """ Copies the PostgreSQL normalisation module into the project
30         directory if necessary. For historical reasons the module is
31         saved in the '/module' subdirectory and not with the other tokenizer
32         data.
33
34         The function detects when the installation is run from the
35         build directory. It doesn't touch the module in that case.
36     """
37     # Custom module locations are simply used as is.
38     if config_module_path:
39         LOG.info("Using custom path for database module at '%s'", config_module_path)
40         return config_module_path
41
42     # Compatibility mode for builddir installations.
43     if module_dir.exists() and src_dir.samefile(module_dir):
44         LOG.info('Running from build directory. Leaving database module as is.')
45         return module_dir
46
47     # In any other case install the module in the project directory.
48     if not module_dir.exists():
49         module_dir.mkdir()
50
51     destfile = module_dir / 'nominatim.so'
52     shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
53     destfile.chmod(0o755)
54
55     LOG.info('Database module installed at %s', str(destfile))
56
57     return module_dir
58
59
60 def _check_module(module_dir, conn):
61     """ Try to use the PostgreSQL module to confirm that it is correctly
62         installed and accessible from PostgreSQL.
63     """
64     with conn.cursor() as cur:
65         try:
66             cur.execute("""CREATE FUNCTION nominatim_test_import_func(text)
67                            RETURNS text AS '{}/nominatim.so', 'transliteration'
68                            LANGUAGE c IMMUTABLE STRICT;
69                            DROP FUNCTION nominatim_test_import_func(text)
70                         """.format(module_dir))
71         except psycopg2.DatabaseError as err:
72             LOG.fatal("Error accessing database module: %s", err)
73             raise UsageError("Database module cannot be accessed.") from err
74
75
76 class LegacyTokenizer:
77     """ The legacy tokenizer uses a special PostgreSQL module to normalize
78         names and queries. The tokenizer thus implements normalization through
79         calls to the database.
80     """
81
82     def __init__(self, dsn, data_dir):
83         self.dsn = dsn
84         self.data_dir = data_dir
85         self.normalization = None
86
87
88     def init_new_db(self, config):
89         """ Set up a new tokenizer for the database.
90
91             This copies all necessary data in the project directory to make
92             sure the tokenizer remains stable even over updates.
93         """
94         module_dir = _install_module(config.DATABASE_MODULE_PATH,
95                                      config.lib_dir.module,
96                                      config.project_dir / 'module')
97
98         self.normalization = config.TERM_NORMALIZATION
99
100         with connect(self.dsn) as conn:
101             _check_module(module_dir, conn)
102             self._save_config(conn, config)
103             conn.commit()
104
105         self.update_sql_functions(config)
106         self._init_db_tables(config)
107
108
109     def init_from_project(self):
110         """ Initialise the tokenizer from the project directory.
111         """
112         with connect(self.dsn) as conn:
113             self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
114
115
116     def update_sql_functions(self, config):
117         """ Reimport the SQL functions for this tokenizer.
118         """
119         with connect(self.dsn) as conn:
120             max_word_freq = properties.get_property(conn, DBCFG_MAXWORDFREQ)
121             modulepath = config.DATABASE_MODULE_PATH or \
122                          str((config.project_dir / 'module').resolve())
123             sqlp = SQLPreprocessor(conn, config)
124             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer.sql',
125                               max_word_freq=max_word_freq,
126                               modulepath=modulepath)
127
128
129     def migrate_database(self, config):
130         """ Initialise the project directory of an existing database for
131             use with this tokenizer.
132
133             This is a special migration function for updating existing databases
134             to new software versions.
135         """
136         module_dir = _install_module(config.DATABASE_MODULE_PATH,
137                                      config.lib_dir.module,
138                                      config.project_dir / 'module')
139
140         with connect(self.dsn) as conn:
141             _check_module(module_dir, conn)
142             self._save_config(conn, config)
143
144
145     def name_analyzer(self):
146         """ Create a new analyzer for tokenizing names and queries
147             using this tokinzer. Analyzers are context managers and should
148             be used accordingly:
149
150             ```
151             with tokenizer.name_analyzer() as analyzer:
152                 analyser.tokenize()
153             ```
154
155             When used outside the with construct, the caller must ensure to
156             call the close() function before destructing the analyzer.
157
158             Analyzers are not thread-safe. You need to instantiate one per thread.
159         """
160         return LegacyNameAnalyzer(self.dsn)
161
162
163     def _init_db_tables(self, config):
164         """ Set up the word table and fill it with pre-computed word
165             frequencies.
166         """
167         with connect(self.dsn) as conn:
168             sqlp = SQLPreprocessor(conn, config)
169             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
170             conn.commit()
171
172         LOG.warning("Precomputing word tokens")
173         db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
174
175
176     def _save_config(self, conn, config):
177         """ Save the configuration that needs to remain stable for the given
178             database as database properties.
179         """
180         properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
181         properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
182
183
184
185 class LegacyNameAnalyzer:
186     """ The legacy analyzer uses the special Postgresql module for
187         splitting names.
188
189         Each instance opens a connection to the database to request the
190         normalization.
191     """
192
193     def __init__(self, dsn):
194         self.conn = connect(dsn).connection
195         self.conn.autocommit = True
196         psycopg2.extras.register_hstore(self.conn)
197
198         self._cache = _TokenCache(self.conn)
199
200
201     def __enter__(self):
202         return self
203
204
205     def __exit__(self, exc_type, exc_value, traceback):
206         self.close()
207
208
209     def close(self):
210         """ Free all resources used by the analyzer.
211         """
212         if self.conn:
213             self.conn.close()
214             self.conn = None
215
216     def process_place(self, place):
217         """ Determine tokenizer information about the given place.
218
219             Returns a JSON-serialisable structure that will be handed into
220             the database via the token_info field.
221         """
222         token_info = _TokenInfo(self._cache)
223
224         token_info.add_names(self.conn, place.get('name'), place.get('country_feature'))
225
226         address = place.get('address')
227
228         if address:
229             token_info.add_housenumbers(self.conn, address)
230
231         return token_info.data
232
233
234 class _TokenInfo:
235     """ Collect token information to be sent back to the database.
236     """
237     def __init__(self, cache):
238         self.cache = cache
239         self.data = {}
240
241
242     def add_names(self, conn, names, country_feature):
243         """ Add token information for the names of the place.
244         """
245         if not names:
246             return
247
248         with conn.cursor() as cur:
249             # Create the token IDs for all names.
250             self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
251                                             (names, ))
252
253             # Add country tokens to word table if necessary.
254             if country_feature and re.fullmatch(r'[A-Za-z][A-Za-z]', country_feature):
255                 cur.execute("SELECT create_country(%s, %s)",
256                             (names, country_feature.lower()))
257
258
259     def add_housenumbers(self, conn, address):
260         """ Extract housenumber information from the address.
261         """
262         hnrs = [v for k, v in address.items()
263                 if k in ('housenumber', 'streetnumber', 'conscriptionnumber')]
264
265         if not hnrs:
266             return
267
268         if len(hnrs) == 1:
269             token = self.cache.get_housenumber(hnrs[0])
270             if token is not None:
271                 self.data['hnr_tokens'] = token
272                 self.data['hnr'] = hnrs[0]
273                 return
274
275         # split numbers if necessary
276         simple_list = []
277         for hnr in hnrs:
278             simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
279
280         if len(simple_list) > 1:
281             simple_list = list(set(simple_list))
282
283         with conn.cursor() as cur:
284             cur.execute("SELECT (create_housenumbers(%s)).* ", (simple_list, ))
285             self.data['hnr_tokens'], self.data['hnr'] = cur.fetchone()
286
287
288 class _TokenCache:
289     """ Cache for token information to avoid repeated database queries.
290
291         This cache is not thread-safe and needs to be instantiated per
292         analyzer.
293     """
294     def __init__(self, conn):
295         # Lookup houseunumbers up to 100 and cache them
296         with conn.cursor() as cur:
297             cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
298                            FROM generate_series(1, 100) as i""")
299             self._cached_housenumbers = {str(r[0]) : r[1] for r in cur}
300
301
302     def get_housenumber(self, number):
303         """ Get a housenumber token from the cache.
304         """
305         return self._cached_housenumbers.get(number)