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