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