]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/legacy_tokenizer.py
move default country name creation to tokenizer
[nominatim.git] / nominatim / tokenizer / legacy_tokenizer.py
1 """
2 Tokenizer implementing normalisation as used before Nominatim 4.
3 """
4 from collections import OrderedDict
5 import logging
6 import re
7 import shutil
8
9 import psycopg2
10 import psycopg2.extras
11
12 from nominatim.db.connection import connect
13 from nominatim.db import properties
14 from nominatim.db import utils as db_utils
15 from nominatim.db.sql_preprocessor import SQLPreprocessor
16 from nominatim.errors import UsageError
17
18 DBCFG_NORMALIZATION = "tokenizer_normalization"
19 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
20
21 LOG = logging.getLogger()
22
23 def create(dsn, data_dir):
24     """ Create a new instance of the tokenizer provided by this module.
25     """
26     return LegacyTokenizer(dsn, data_dir)
27
28
29 def _install_module(config_module_path, src_dir, module_dir):
30     """ Copies the PostgreSQL normalisation module into the project
31         directory if necessary. For historical reasons the module is
32         saved in the '/module' subdirectory and not with the other tokenizer
33         data.
34
35         The function detects when the installation is run from the
36         build directory. It doesn't touch the module in that case.
37     """
38     # Custom module locations are simply used as is.
39     if config_module_path:
40         LOG.info("Using custom path for database module at '%s'", config_module_path)
41         return config_module_path
42
43     # Compatibility mode for builddir installations.
44     if module_dir.exists() and src_dir.samefile(module_dir):
45         LOG.info('Running from build directory. Leaving database module as is.')
46         return module_dir
47
48     # In any other case install the module in the project directory.
49     if not module_dir.exists():
50         module_dir.mkdir()
51
52     destfile = module_dir / 'nominatim.so'
53     shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
54     destfile.chmod(0o755)
55
56     LOG.info('Database module installed at %s', str(destfile))
57
58     return module_dir
59
60
61 def _check_module(module_dir, conn):
62     """ Try to use the PostgreSQL module to confirm that it is correctly
63         installed and accessible from PostgreSQL.
64     """
65     with conn.cursor() as cur:
66         try:
67             cur.execute("""CREATE FUNCTION nominatim_test_import_func(text)
68                            RETURNS text AS '{}/nominatim.so', 'transliteration'
69                            LANGUAGE c IMMUTABLE STRICT;
70                            DROP FUNCTION nominatim_test_import_func(text)
71                         """.format(module_dir))
72         except psycopg2.DatabaseError as err:
73             LOG.fatal("Error accessing database module: %s", err)
74             raise UsageError("Database module cannot be accessed.") from err
75
76
77 class LegacyTokenizer:
78     """ The legacy tokenizer uses a special PostgreSQL module to normalize
79         names and queries. The tokenizer thus implements normalization through
80         calls to the database.
81     """
82
83     def __init__(self, dsn, data_dir):
84         self.dsn = dsn
85         self.data_dir = data_dir
86         self.normalization = None
87
88
89     def init_new_db(self, config):
90         """ Set up a new tokenizer for the database.
91
92             This copies all necessary data in the project directory to make
93             sure the tokenizer remains stable even over updates.
94         """
95         module_dir = _install_module(config.DATABASE_MODULE_PATH,
96                                      config.lib_dir.module,
97                                      config.project_dir / 'module')
98
99         self.normalization = config.TERM_NORMALIZATION
100
101         with connect(self.dsn) as conn:
102             _check_module(module_dir, conn)
103             self._save_config(conn, config)
104             conn.commit()
105
106         self.update_sql_functions(config)
107         self._init_db_tables(config)
108
109
110     def init_from_project(self):
111         """ Initialise the tokenizer from the project directory.
112         """
113         with connect(self.dsn) as conn:
114             self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
115
116
117     def update_sql_functions(self, config):
118         """ Reimport the SQL functions for this tokenizer.
119         """
120         with connect(self.dsn) as conn:
121             max_word_freq = properties.get_property(conn, DBCFG_MAXWORDFREQ)
122             modulepath = config.DATABASE_MODULE_PATH or \
123                          str((config.project_dir / 'module').resolve())
124             sqlp = SQLPreprocessor(conn, config)
125             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer.sql',
126                               max_word_freq=max_word_freq,
127                               modulepath=modulepath)
128
129
130     def migrate_database(self, config):
131         """ Initialise the project directory of an existing database for
132             use with this tokenizer.
133
134             This is a special migration function for updating existing databases
135             to new software versions.
136         """
137         module_dir = _install_module(config.DATABASE_MODULE_PATH,
138                                      config.lib_dir.module,
139                                      config.project_dir / 'module')
140
141         with connect(self.dsn) as conn:
142             _check_module(module_dir, conn)
143             self._save_config(conn, config)
144
145
146     def name_analyzer(self):
147         """ Create a new analyzer for tokenizing names and queries
148             using this tokinzer. Analyzers are context managers and should
149             be used accordingly:
150
151             ```
152             with tokenizer.name_analyzer() as analyzer:
153                 analyser.tokenize()
154             ```
155
156             When used outside the with construct, the caller must ensure to
157             call the close() function before destructing the analyzer.
158
159             Analyzers are not thread-safe. You need to instantiate one per thread.
160         """
161         return LegacyNameAnalyzer(self.dsn)
162
163
164     def _init_db_tables(self, config):
165         """ Set up the word table and fill it with pre-computed word
166             frequencies.
167         """
168         with connect(self.dsn) as conn:
169             sqlp = SQLPreprocessor(conn, config)
170             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
171             conn.commit()
172
173         LOG.warning("Precomputing word tokens")
174         db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
175
176
177     def _save_config(self, conn, config):
178         """ Save the configuration that needs to remain stable for the given
179             database as database properties.
180         """
181         properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
182         properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
183
184
185
186 class LegacyNameAnalyzer:
187     """ The legacy analyzer uses the special Postgresql module for
188         splitting names.
189
190         Each instance opens a connection to the database to request the
191         normalization.
192     """
193
194     def __init__(self, dsn):
195         self.conn = connect(dsn).connection
196         self.conn.autocommit = True
197         psycopg2.extras.register_hstore(self.conn)
198
199         self._cache = _TokenCache(self.conn)
200
201
202     def __enter__(self):
203         return self
204
205
206     def __exit__(self, exc_type, exc_value, traceback):
207         self.close()
208
209
210     def close(self):
211         """ Free all resources used by the analyzer.
212         """
213         if self.conn:
214             self.conn.close()
215             self.conn = None
216
217
218     def add_postcodes_from_db(self):
219         """ Add postcodes from the location_postcode table to the word table.
220         """
221         with self.conn.cursor() as cur:
222             cur.execute("""SELECT count(create_postcode_id(pc))
223                            FROM (SELECT distinct(postcode) as pc
224                                  FROM location_postcode) x""")
225
226
227     def add_country_names(self, country_code, names):
228         """ Add names for the given country to the search index.
229         """
230         with self.conn.cursor() as cur:
231             cur.execute(
232                 """INSERT INTO word (word_id, word_token, country_code)
233                    (SELECT nextval('seq_word'), lookup_token, %s
234                       FROM (SELECT ' ' || make_standard_name(n) as lookup_token
235                             FROM unnest(%s)n) y
236                       WHERE NOT EXISTS(SELECT * FROM word
237                                        WHERE word_token = lookup_token and country_code = %s))
238                 """, (country_code, names, country_code))
239
240
241     def process_place(self, place):
242         """ Determine tokenizer information about the given place.
243
244             Returns a JSON-serialisable structure that will be handed into
245             the database via the token_info field.
246         """
247         token_info = _TokenInfo(self._cache)
248
249         names = place.get('name')
250
251         if names:
252             token_info.add_names(self.conn, names)
253
254             country_feature = place.get('country_feature')
255             if country_feature and re.fullmatch(r'[A-Za-z][A-Za-z]', country_feature):
256                 self.add_country_names(country_feature.lower(), list(names.values()))
257
258         address = place.get('address')
259
260         if address:
261             hnrs = []
262             addr_terms = []
263             for key, value in address.items():
264                 if key == 'postcode':
265                     self._add_postcode(value)
266                 elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
267                     hnrs.append(value)
268                 elif key == 'street':
269                     token_info.add_street(self.conn, value)
270                 elif key == 'place':
271                     token_info.add_place(self.conn, value)
272                 elif not key.startswith('_') and \
273                      key not in ('country', 'full'):
274                     addr_terms.append((key, value))
275
276             if hnrs:
277                 token_info.add_housenumbers(self.conn, hnrs)
278
279             if addr_terms:
280                 token_info.add_address_terms(self.conn, addr_terms)
281
282         return token_info.data
283
284
285     def _add_postcode(self, postcode):
286         """ Make sure the normalized postcode is present in the word table.
287         """
288         def _create_postcode_from_db(pcode):
289             with self.conn.cursor() as cur:
290                 cur.execute('SELECT create_postcode_id(%s)', (pcode, ))
291
292         if re.search(r'[:,;]', postcode) is None:
293             self._cache.postcodes.get(postcode.strip().upper(), _create_postcode_from_db)
294
295
296 class _TokenInfo:
297     """ Collect token information to be sent back to the database.
298     """
299     def __init__(self, cache):
300         self.cache = cache
301         self.data = {}
302
303
304     def add_names(self, conn, names):
305         """ Add token information for the names of the place.
306         """
307         with conn.cursor() as cur:
308             # Create the token IDs for all names.
309             self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
310                                             (names, ))
311
312
313     def add_housenumbers(self, conn, hnrs):
314         """ Extract housenumber information from the address.
315         """
316         if len(hnrs) == 1:
317             token = self.cache.get_housenumber(hnrs[0])
318             if token is not None:
319                 self.data['hnr_tokens'] = token
320                 self.data['hnr'] = hnrs[0]
321                 return
322
323         # split numbers if necessary
324         simple_list = []
325         for hnr in hnrs:
326             simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
327
328         if len(simple_list) > 1:
329             simple_list = list(set(simple_list))
330
331         with conn.cursor() as cur:
332             cur.execute("SELECT (create_housenumbers(%s)).* ", (simple_list, ))
333             self.data['hnr_tokens'], self.data['hnr'] = cur.fetchone()
334
335
336     def add_street(self, conn, street):
337         """ Add addr:street match terms.
338         """
339         def _get_street(name):
340             with conn.cursor() as cur:
341                 return cur.scalar("SELECT word_ids_from_name(%s)::text", (name, ))
342
343         self.data['street'] = self.cache.streets.get(street, _get_street)
344
345
346     def add_place(self, conn, place):
347         """ Add addr:place search and match terms.
348         """
349         def _get_place(name):
350             with conn.cursor() as cur:
351                 cur.execute("""SELECT (addr_ids_from_name(%s)
352                                        || getorcreate_name_id(make_standard_name(%s), ''))::text,
353                                       word_ids_from_name(%s)::text""",
354                             (name, name, name))
355                 return cur.fetchone()
356
357         self.data['place_search'], self.data['place_match'] = \
358             self.cache.places.get(place, _get_place)
359
360
361     def add_address_terms(self, conn, terms):
362         """ Add additional address terms.
363         """
364         def _get_address_term(name):
365             with conn.cursor() as cur:
366                 cur.execute("""SELECT addr_ids_from_name(%s)::text,
367                                       word_ids_from_name(%s)::text""",
368                             (name, name))
369                 return cur.fetchone()
370
371         tokens = {}
372         for key, value in terms:
373             tokens[key] = self.cache.address_terms.get(value, _get_address_term)
374
375         self.data['addr'] = tokens
376
377
378 class _LRU:
379     """ Least recently used cache that accepts a generator function to
380         produce the item when there is a cache miss.
381     """
382
383     def __init__(self, maxsize=128, init_data=None):
384         self.data = init_data or OrderedDict()
385         self.maxsize = maxsize
386         if init_data is not None and len(init_data) > maxsize:
387             self.maxsize = len(init_data)
388
389     def get(self, key, generator):
390         """ Get the item with the given key from the cache. If nothing
391             is found in the cache, generate the value through the
392             generator function and store it in the cache.
393         """
394         value = self.data.get(key)
395         if value is not None:
396             self.data.move_to_end(key)
397         else:
398             value = generator(key)
399             if len(self.data) >= self.maxsize:
400                 self.data.popitem(last=False)
401             self.data[key] = value
402
403         return value
404
405
406 class _TokenCache:
407     """ Cache for token information to avoid repeated database queries.
408
409         This cache is not thread-safe and needs to be instantiated per
410         analyzer.
411     """
412     def __init__(self, conn):
413         # various LRU caches
414         self.streets = _LRU(maxsize=256)
415         self.places = _LRU(maxsize=128)
416         self.address_terms = _LRU(maxsize=1024)
417
418         # Lookup houseunumbers up to 100 and cache them
419         with conn.cursor() as cur:
420             cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
421                            FROM generate_series(1, 100) as i""")
422             self._cached_housenumbers = {str(r[0]) : r[1] for r in cur}
423
424         # Get postcodes that are already saved
425         postcodes = OrderedDict()
426         with conn.cursor() as cur:
427             cur.execute("""SELECT word FROM word
428                            WHERE class ='place' and type = 'postcode'""")
429             for row in cur:
430                 postcodes[row[0]] = None
431         self.postcodes = _LRU(maxsize=32, init_data=postcodes)
432
433     def get_housenumber(self, number):
434         """ Get a housenumber token from the cache.
435         """
436         return self._cached_housenumbers.get(number)