]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tokenizer/legacy_tokenizer.py
Merge pull request #2401 from lonvia/port-add-data-to-python
[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 finalize_import(self, config):
123         """ Do any required postprocessing to make the tokenizer data ready
124             for use.
125         """
126         with connect(self.dsn) as conn:
127             sqlp = SQLPreprocessor(conn, config)
128             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
129
130
131     def update_sql_functions(self, config):
132         """ Reimport the SQL functions for this tokenizer.
133         """
134         with connect(self.dsn) as conn:
135             max_word_freq = properties.get_property(conn, DBCFG_MAXWORDFREQ)
136             modulepath = config.DATABASE_MODULE_PATH or \
137                          str((config.project_dir / 'module').resolve())
138             sqlp = SQLPreprocessor(conn, config)
139             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer.sql',
140                               max_word_freq=max_word_freq,
141                               modulepath=modulepath)
142
143
144     def check_database(self):
145         """ Check that the tokenizer is set up correctly.
146         """
147         hint = """\
148              The Postgresql extension nominatim.so was not correctly loaded.
149
150              Error: {error}
151
152              Hints:
153              * Check the output of the CMmake/make installation step
154              * Does nominatim.so exist?
155              * Does nominatim.so exist on the database server?
156              * Can nominatim.so be accessed by the database user?
157              """
158         with connect(self.dsn) as conn:
159             with conn.cursor() as cur:
160                 try:
161                     out = cur.scalar("SELECT make_standard_name('a')")
162                 except psycopg2.Error as err:
163                     return hint.format(error=str(err))
164
165         if out != 'a':
166             return hint.format(error='Unexpected result for make_standard_name()')
167
168         return None
169
170
171     def migrate_database(self, config):
172         """ Initialise the project directory of an existing database for
173             use with this tokenizer.
174
175             This is a special migration function for updating existing databases
176             to new software versions.
177         """
178         self.normalization = config.TERM_NORMALIZATION
179         module_dir = _install_module(config.DATABASE_MODULE_PATH,
180                                      config.lib_dir.module,
181                                      config.project_dir / 'module')
182
183         with connect(self.dsn) as conn:
184             _check_module(module_dir, conn)
185             self._save_config(conn, config)
186
187
188     def name_analyzer(self):
189         """ Create a new analyzer for tokenizing names and queries
190             using this tokinzer. Analyzers are context managers and should
191             be used accordingly:
192
193             ```
194             with tokenizer.name_analyzer() as analyzer:
195                 analyser.tokenize()
196             ```
197
198             When used outside the with construct, the caller must ensure to
199             call the close() function before destructing the analyzer.
200
201             Analyzers are not thread-safe. You need to instantiate one per thread.
202         """
203         normalizer = Transliterator.createFromRules("phrase normalizer",
204                                                     self.normalization)
205         return LegacyNameAnalyzer(self.dsn, normalizer)
206
207
208     def _install_php(self, config):
209         """ Install the php script for the tokenizer.
210         """
211         php_file = self.data_dir / "tokenizer.php"
212         php_file.write_text(dedent("""\
213             <?php
214             @define('CONST_Max_Word_Frequency', {0.MAX_WORD_FREQUENCY});
215             @define('CONST_Term_Normalization_Rules', "{0.TERM_NORMALIZATION}");
216             require_once('{0.lib_dir.php}/tokenizer/legacy_tokenizer.php');
217             """.format(config)))
218
219
220     def _init_db_tables(self, config):
221         """ Set up the word table and fill it with pre-computed word
222             frequencies.
223         """
224         with connect(self.dsn) as conn:
225             sqlp = SQLPreprocessor(conn, config)
226             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
227             conn.commit()
228
229         LOG.warning("Precomputing word tokens")
230         db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
231
232
233     def _save_config(self, conn, config):
234         """ Save the configuration that needs to remain stable for the given
235             database as database properties.
236         """
237         properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
238         properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
239
240
241 class LegacyNameAnalyzer:
242     """ The legacy analyzer uses the special Postgresql module for
243         splitting names.
244
245         Each instance opens a connection to the database to request the
246         normalization.
247     """
248
249     def __init__(self, dsn, normalizer):
250         self.conn = connect(dsn).connection
251         self.conn.autocommit = True
252         self.normalizer = normalizer
253         psycopg2.extras.register_hstore(self.conn)
254
255         self._cache = _TokenCache(self.conn)
256
257
258     def __enter__(self):
259         return self
260
261
262     def __exit__(self, exc_type, exc_value, traceback):
263         self.close()
264
265
266     def close(self):
267         """ Free all resources used by the analyzer.
268         """
269         if self.conn:
270             self.conn.close()
271             self.conn = None
272
273
274     def get_word_token_info(self, words):
275         """ Return token information for the given list of words.
276             If a word starts with # it is assumed to be a full name
277             otherwise is a partial name.
278
279             The function returns a list of tuples with
280             (original word, word token, word id).
281
282             The function is used for testing and debugging only
283             and not necessarily efficient.
284         """
285         with self.conn.cursor() as cur:
286             cur.execute("""SELECT t.term, word_token, word_id
287                            FROM word, (SELECT unnest(%s::TEXT[]) as term) t
288                            WHERE word_token = (CASE
289                                    WHEN left(t.term, 1) = '#' THEN
290                                      ' ' || make_standard_name(substring(t.term from 2))
291                                    ELSE
292                                      make_standard_name(t.term)
293                                    END)
294                                  and class is null and country_code is null""",
295                         (words, ))
296
297             return [(r[0], r[1], r[2]) for r in cur]
298
299
300     def normalize(self, phrase):
301         """ Normalize the given phrase, i.e. remove all properties that
302             are irrelevant for search.
303         """
304         return self.normalizer.transliterate(phrase)
305
306
307     @staticmethod
308     def normalize_postcode(postcode):
309         """ Convert the postcode to a standardized form.
310
311             This function must yield exactly the same result as the SQL function
312             'token_normalized_postcode()'.
313         """
314         return postcode.strip().upper()
315
316
317     def update_postcodes_from_db(self):
318         """ Update postcode tokens in the word table from the location_postcode
319             table.
320         """
321         with self.conn.cursor() as cur:
322             # This finds us the rows in location_postcode and word that are
323             # missing in the other table.
324             cur.execute("""SELECT * FROM
325                             (SELECT pc, word FROM
326                               (SELECT distinct(postcode) as pc FROM location_postcode) p
327                               FULL JOIN
328                               (SELECT word FROM word
329                                 WHERE class ='place' and type = 'postcode') w
330                               ON pc = word) x
331                            WHERE pc is null or word is null""")
332
333             to_delete = []
334             to_add = []
335
336             for postcode, word in cur:
337                 if postcode is None:
338                     to_delete.append(word)
339                 else:
340                     to_add.append(postcode)
341
342             if to_delete:
343                 cur.execute("""DELETE FROM WORD
344                                WHERE class ='place' and type = 'postcode'
345                                      and word = any(%s)
346                             """, (to_delete, ))
347             if to_add:
348                 cur.execute("""SELECT count(create_postcode_id(pc))
349                                FROM unnest(%s) as pc
350                             """, (to_add, ))
351
352
353
354     def update_special_phrases(self, phrases, should_replace):
355         """ Replace the search index for special phrases with the new phrases.
356         """
357         norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
358                             for p in phrases))
359
360         with self.conn.cursor() as cur:
361             # Get the old phrases.
362             existing_phrases = set()
363             cur.execute("""SELECT word, class, type, operator FROM word
364                            WHERE class != 'place'
365                                  OR (type != 'house' AND type != 'postcode')""")
366             for label, cls, typ, oper in cur:
367                 existing_phrases.add((label, cls, typ, oper or '-'))
368
369             to_add = norm_phrases - existing_phrases
370             to_delete = existing_phrases - norm_phrases
371
372             if to_add:
373                 cur.execute_values(
374                     """ INSERT INTO word (word_id, word_token, word, class, type,
375                                           search_name_count, operator)
376                         (SELECT nextval('seq_word'), ' ' || make_standard_name(name), name,
377                                 class, type, 0,
378                                 CASE WHEN op in ('in', 'near') THEN op ELSE null END
379                            FROM (VALUES %s) as v(name, class, type, op))""",
380                     to_add)
381
382             if to_delete and should_replace:
383                 cur.execute_values(
384                     """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
385                         WHERE word = name and class = in_class and type = in_type
386                               and ((op = '-' and operator is null) or op = operator)""",
387                     to_delete)
388
389         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
390                  len(norm_phrases), len(to_add), len(to_delete))
391
392
393     def add_country_names(self, country_code, names):
394         """ Add names for the given country to the search index.
395         """
396         with self.conn.cursor() as cur:
397             cur.execute(
398                 """INSERT INTO word (word_id, word_token, country_code)
399                    (SELECT nextval('seq_word'), lookup_token, %s
400                       FROM (SELECT DISTINCT ' ' || make_standard_name(n) as lookup_token
401                             FROM unnest(%s)n) y
402                       WHERE NOT EXISTS(SELECT * FROM word
403                                        WHERE word_token = lookup_token and country_code = %s))
404                 """, (country_code, list(names.values()), country_code))
405
406
407     def process_place(self, place):
408         """ Determine tokenizer information about the given place.
409
410             Returns a JSON-serialisable structure that will be handed into
411             the database via the token_info field.
412         """
413         token_info = _TokenInfo(self._cache)
414
415         names = place.get('name')
416
417         if names:
418             token_info.add_names(self.conn, names)
419
420             country_feature = place.get('country_feature')
421             if country_feature and re.fullmatch(r'[A-Za-z][A-Za-z]', country_feature):
422                 self.add_country_names(country_feature.lower(), names)
423
424         address = place.get('address')
425         if address:
426             self._process_place_address(token_info, address)
427
428         return token_info.data
429
430
431     def _process_place_address(self, token_info, address):
432         hnrs = []
433         addr_terms = []
434
435         for key, value in address.items():
436             if key == 'postcode':
437                 # Make sure the normalized postcode is present in the word table.
438                 if re.search(r'[:,;]', value) is None:
439                     self._cache.add_postcode(self.conn,
440                                              self.normalize_postcode(value))
441             elif key in ('housenumber', 'streetnumber', 'conscriptionnumber'):
442                 hnrs.append(value)
443             elif key == 'street':
444                 token_info.add_street(self.conn, value)
445             elif key == 'place':
446                 token_info.add_place(self.conn, value)
447             elif not key.startswith('_') and key not in ('country', 'full'):
448                 addr_terms.append((key, value))
449
450         if hnrs:
451             token_info.add_housenumbers(self.conn, hnrs)
452
453         if addr_terms:
454             token_info.add_address_terms(self.conn, addr_terms)
455
456
457
458 class _TokenInfo:
459     """ Collect token information to be sent back to the database.
460     """
461     def __init__(self, cache):
462         self.cache = cache
463         self.data = {}
464
465
466     def add_names(self, conn, names):
467         """ Add token information for the names of the place.
468         """
469         with conn.cursor() as cur:
470             # Create the token IDs for all names.
471             self.data['names'] = cur.scalar("SELECT make_keywords(%s)::text",
472                                             (names, ))
473
474
475     def add_housenumbers(self, conn, hnrs):
476         """ Extract housenumber information from the address.
477         """
478         if len(hnrs) == 1:
479             token = self.cache.get_housenumber(hnrs[0])
480             if token is not None:
481                 self.data['hnr_tokens'] = token
482                 self.data['hnr'] = hnrs[0]
483                 return
484
485         # split numbers if necessary
486         simple_list = []
487         for hnr in hnrs:
488             simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
489
490         if len(simple_list) > 1:
491             simple_list = list(set(simple_list))
492
493         with conn.cursor() as cur:
494             cur.execute("SELECT (create_housenumbers(%s)).* ", (simple_list, ))
495             self.data['hnr_tokens'], self.data['hnr'] = cur.fetchone()
496
497
498     def add_street(self, conn, street):
499         """ Add addr:street match terms.
500         """
501         def _get_street(name):
502             with conn.cursor() as cur:
503                 return cur.scalar("SELECT word_ids_from_name(%s)::text", (name, ))
504
505         self.data['street'] = self.cache.streets.get(street, _get_street)
506
507
508     def add_place(self, conn, place):
509         """ Add addr:place search and match terms.
510         """
511         def _get_place(name):
512             with conn.cursor() as cur:
513                 cur.execute("""SELECT make_keywords(hstore('name' , %s))::text,
514                                       word_ids_from_name(%s)::text""",
515                             (name, name))
516                 return cur.fetchone()
517
518         self.data['place_search'], self.data['place_match'] = \
519             self.cache.places.get(place, _get_place)
520
521
522     def add_address_terms(self, conn, terms):
523         """ Add additional address terms.
524         """
525         def _get_address_term(name):
526             with conn.cursor() as cur:
527                 cur.execute("""SELECT addr_ids_from_name(%s)::text,
528                                       word_ids_from_name(%s)::text""",
529                             (name, name))
530                 return cur.fetchone()
531
532         tokens = {}
533         for key, value in terms:
534             tokens[key] = self.cache.address_terms.get(value, _get_address_term)
535
536         self.data['addr'] = tokens
537
538
539 class _LRU:
540     """ Least recently used cache that accepts a generator function to
541         produce the item when there is a cache miss.
542     """
543
544     def __init__(self, maxsize=128, init_data=None):
545         self.data = init_data or OrderedDict()
546         self.maxsize = maxsize
547         if init_data is not None and len(init_data) > maxsize:
548             self.maxsize = len(init_data)
549
550     def get(self, key, generator):
551         """ Get the item with the given key from the cache. If nothing
552             is found in the cache, generate the value through the
553             generator function and store it in the cache.
554         """
555         value = self.data.get(key)
556         if value is not None:
557             self.data.move_to_end(key)
558         else:
559             value = generator(key)
560             if len(self.data) >= self.maxsize:
561                 self.data.popitem(last=False)
562             self.data[key] = value
563
564         return value
565
566
567 class _TokenCache:
568     """ Cache for token information to avoid repeated database queries.
569
570         This cache is not thread-safe and needs to be instantiated per
571         analyzer.
572     """
573     def __init__(self, conn):
574         # various LRU caches
575         self.streets = _LRU(maxsize=256)
576         self.places = _LRU(maxsize=128)
577         self.address_terms = _LRU(maxsize=1024)
578
579         # Lookup houseunumbers up to 100 and cache them
580         with conn.cursor() as cur:
581             cur.execute("""SELECT i, ARRAY[getorcreate_housenumber_id(i::text)]::text
582                            FROM generate_series(1, 100) as i""")
583             self._cached_housenumbers = {str(r[0]): r[1] for r in cur}
584
585         # For postcodes remember the ones that have already been added
586         self.postcodes = set()
587
588     def get_housenumber(self, number):
589         """ Get a housenumber token from the cache.
590         """
591         return self._cached_housenumbers.get(number)
592
593
594     def add_postcode(self, conn, postcode):
595         """ Make sure the given postcode is in the database.
596         """
597         if postcode not in self.postcodes:
598             with conn.cursor() as cur:
599                 cur.execute('SELECT create_postcode_id(%s)', (postcode, ))
600             self.postcodes.add(postcode)