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