1 # SPDX-License-Identifier: GPL-2.0-only
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2022 by the Nominatim developer community.
 
   6 # For a full list of authors see the git log.
 
   8 Tokenizer implementing normalisation as used before Nominatim 4.
 
  10 from collections import OrderedDict
 
  14 from textwrap import dedent
 
  16 from icu import Transliterator
 
  18 import psycopg2.extras
 
  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
 
  27 DBCFG_NORMALIZATION = "tokenizer_normalization"
 
  28 DBCFG_MAXWORDFREQ = "tokenizer_maxwordfreq"
 
  30 LOG = logging.getLogger()
 
  32 def create(dsn, data_dir):
 
  33     """ Create a new instance of the tokenizer provided by this module.
 
  35     return LegacyTokenizer(dsn, data_dir)
 
  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
 
  44         The function detects when the installation is run from the
 
  45         build directory. It doesn't touch the module in that case.
 
  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
 
  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.')
 
  57     # In any other case install the module in the project directory.
 
  58     if not module_dir.exists():
 
  61     destfile = module_dir / 'nominatim.so'
 
  62     shutil.copy(str(src_dir / 'nominatim.so'), str(destfile))
 
  65     LOG.info('Database module installed at %s', str(destfile))
 
  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.
 
  74     with conn.cursor() as cur:
 
  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
 
  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.
 
  92     def __init__(self, dsn, data_dir):
 
  94         self.data_dir = data_dir
 
  95         self.normalization = None
 
  98     def init_new_db(self, config, init_db=True):
 
  99         """ Set up a new tokenizer for the database.
 
 101             This copies all necessary data in the project directory to make
 
 102             sure the tokenizer remains stable even over updates.
 
 104         module_dir = _install_module(config.DATABASE_MODULE_PATH,
 
 105                                      config.lib_dir.module,
 
 106                                      config.project_dir / 'module')
 
 108         self.normalization = config.TERM_NORMALIZATION
 
 110         self._install_php(config, overwrite=True)
 
 112         with connect(self.dsn) as conn:
 
 113             _check_module(module_dir, conn)
 
 114             self._save_config(conn, config)
 
 118             self.update_sql_functions(config)
 
 119             self._init_db_tables(config)
 
 122     def init_from_project(self, config):
 
 123         """ Initialise the tokenizer from the project directory.
 
 125         with connect(self.dsn) as conn:
 
 126             self.normalization = properties.get_property(conn, DBCFG_NORMALIZATION)
 
 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')
 
 133         self._install_php(config, overwrite=False)
 
 135     def finalize_import(self, config):
 
 136         """ Do any required postprocessing to make the tokenizer data ready
 
 139         with connect(self.dsn) as conn:
 
 140             sqlp = SQLPreprocessor(conn, config)
 
 141             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
 
 144     def update_sql_functions(self, config):
 
 145         """ Reimport the SQL functions for this tokenizer.
 
 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)
 
 157     def check_database(self, _):
 
 158         """ Check that the tokenizer is set up correctly.
 
 161              The Postgresql extension nominatim.so was not correctly loaded.
 
 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?
 
 171         with connect(self.dsn) as conn:
 
 172             with conn.cursor() as cur:
 
 174                     out = cur.scalar("SELECT make_standard_name('a')")
 
 175                 except psycopg2.Error as err:
 
 176                     return hint.format(error=str(err))
 
 179             return hint.format(error='Unexpected result for make_standard_name()')
 
 184     def migrate_database(self, config):
 
 185         """ Initialise the project directory of an existing database for
 
 186             use with this tokenizer.
 
 188             This is a special migration function for updating existing databases
 
 189             to new software versions.
 
 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')
 
 196         with connect(self.dsn) as conn:
 
 197             _check_module(module_dir, conn)
 
 198             self._save_config(conn, config)
 
 201     def update_statistics(self):
 
 202         """ Recompute the frequency of full words.
 
 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")
 
 221     def update_word_tokens(self):
 
 222         """ No house-keeping implemented for the legacy tokenizer.
 
 224         LOG.info("No tokenizer clean-up available.")
 
 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
 
 233             with tokenizer.name_analyzer() as analyzer:
 
 237             When used outside the with construct, the caller must ensure to
 
 238             call the close() function before destructing the analyzer.
 
 240             Analyzers are not thread-safe. You need to instantiate one per thread.
 
 242         normalizer = Transliterator.createFromRules("phrase normalizer",
 
 244         return LegacyNameAnalyzer(self.dsn, normalizer)
 
 247     def _install_php(self, config, overwrite=True):
 
 248         """ Install the php script for the tokenizer.
 
 250         php_file = self.data_dir / "tokenizer.php"
 
 252         if not php_file.exists() or overwrite:
 
 253             php_file.write_text(dedent("""\
 
 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');
 
 261     def _init_db_tables(self, config):
 
 262         """ Set up the word table and fill it with pre-computed word
 
 265         with connect(self.dsn) as conn:
 
 266             sqlp = SQLPreprocessor(conn, config)
 
 267             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
 
 270         LOG.warning("Precomputing word tokens")
 
 271         db_utils.execute_file(self.dsn, config.lib_dir.data / 'words.sql')
 
 274     def _save_config(self, conn, config):
 
 275         """ Save the configuration that needs to remain stable for the given
 
 276             database as database properties.
 
 278         properties.set_property(conn, DBCFG_NORMALIZATION, self.normalization)
 
 279         properties.set_property(conn, DBCFG_MAXWORDFREQ, config.MAX_WORD_FREQUENCY)
 
 282 class LegacyNameAnalyzer(AbstractAnalyzer):
 
 283     """ The legacy analyzer uses the special Postgresql module for
 
 286         Each instance opens a connection to the database to request the
 
 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)
 
 296         self._cache = _TokenCache(self.conn)
 
 300         """ Free all resources used by the analyzer.
 
 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.
 
 312             The function returns a list of tuples with
 
 313             (original word, word token, word id).
 
 315             The function is used for testing and debugging only
 
 316             and not necessarily efficient.
 
 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))
 
 325                                      make_standard_name(t.term)
 
 327                                  and class is null and country_code is null""",
 
 330             return [(r[0], r[1], r[2]) for r in cur]
 
 333     def normalize(self, phrase):
 
 334         """ Normalize the given phrase, i.e. remove all properties that
 
 335             are irrelevant for search.
 
 337         return self.normalizer.transliterate(phrase)
 
 341     def normalize_postcode(postcode):
 
 342         """ Convert the postcode to a standardized form.
 
 344             This function must yield exactly the same result as the SQL function
 
 345             'token_normalized_postcode()'.
 
 347         return postcode.strip().upper()
 
 350     def update_postcodes_from_db(self):
 
 351         """ Update postcode tokens in the word table from the location_postcode
 
 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
 
 361                               (SELECT word FROM word
 
 362                                 WHERE class ='place' and type = 'postcode') w
 
 364                            WHERE pc is null or word is null""")
 
 369             for postcode, word in cur:
 
 371                     to_delete.append(word)
 
 373                     to_add.append(postcode)
 
 376                 cur.execute("""DELETE FROM WORD
 
 377                                WHERE class ='place' and type = 'postcode'
 
 381                 cur.execute("""SELECT count(create_postcode_id(pc))
 
 382                                FROM unnest(%s) as pc
 
 387     def update_special_phrases(self, phrases, should_replace):
 
 388         """ Replace the search index for special phrases with the new phrases.
 
 390         norm_phrases = set(((self.normalize(p[0]), p[1], p[2], p[3])
 
 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 '-'))
 
 402             to_add = norm_phrases - existing_phrases
 
 403             to_delete = existing_phrases - norm_phrases
 
 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,
 
 411                                 CASE WHEN op in ('in', 'near') THEN op ELSE null END
 
 412                            FROM (VALUES %s) as v(name, class, type, op))""",
 
 415             if to_delete and should_replace:
 
 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)""",
 
 422         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
 
 423                  len(norm_phrases), len(to_add), len(to_delete))
 
 426     def add_country_names(self, country_code, names):
 
 427         """ Add names for the given country to the search index.
 
 429         with self.conn.cursor() as cur:
 
 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
 
 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))
 
 440     def process_place(self, place):
 
 441         """ Determine tokenizer information about the given place.
 
 443             Returns a JSON-serialisable structure that will be handed into
 
 444             the database via the token_info field.
 
 446         token_info = _TokenInfo(self._cache)
 
 451             token_info.add_names(self.conn, names)
 
 453             if place.is_country():
 
 454                 self.add_country_names(place.country_code, names)
 
 456         address = place.address
 
 458             self._process_place_address(token_info, address)
 
 460         return token_info.data
 
 463     def _process_place_address(self, token_info, address):
 
 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'):
 
 475             elif key == 'street':
 
 476                 token_info.add_street(self.conn, value)
 
 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))
 
 483             token_info.add_housenumbers(self.conn, hnrs)
 
 486             token_info.add_address_terms(self.conn, addr_terms)
 
 491     """ Collect token information to be sent back to the database.
 
 493     def __init__(self, cache):
 
 498     def add_names(self, conn, names):
 
 499         """ Add token information for the names of the place.
 
 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",
 
 507     def add_housenumbers(self, conn, hnrs):
 
 508         """ Extract housenumber information from the address.
 
 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]
 
 517         # split numbers if necessary
 
 520             simple_list.extend((x.strip() for x in re.split(r'[;,]', hnr)))
 
 522         if len(simple_list) > 1:
 
 523             simple_list = list(set(simple_list))
 
 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()
 
 530     def add_street(self, conn, street):
 
 531         """ Add addr:street match terms.
 
 533         def _get_street(name):
 
 534             with conn.cursor() as cur:
 
 535                 return cur.scalar("SELECT word_ids_from_name(%s)::text", (name, ))
 
 537         tokens = self.cache.streets.get(street, _get_street)
 
 539             self.data['street'] = tokens
 
 542     def add_place(self, conn, place):
 
 543         """ Add addr:place search and match terms.
 
 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""",
 
 550                 return cur.fetchone()
 
 552         self.data['place_search'], self.data['place_match'] = \
 
 553             self.cache.places.get(place, _get_place)
 
 556     def add_address_terms(self, conn, terms):
 
 557         """ Add additional address terms.
 
 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""",
 
 564                 return cur.fetchone()
 
 567         for key, value in terms:
 
 568             items = self.cache.address_terms.get(value, _get_address_term)
 
 569             if items[0] or items[1]:
 
 573             self.data['addr'] = tokens
 
 577     """ Least recently used cache that accepts a generator function to
 
 578         produce the item when there is a cache miss.
 
 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)
 
 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.
 
 592         value = self.data.get(key)
 
 593         if value is not None:
 
 594             self.data.move_to_end(key)
 
 596             value = generator(key)
 
 597             if len(self.data) >= self.maxsize:
 
 598                 self.data.popitem(last=False)
 
 599             self.data[key] = value
 
 605     """ Cache for token information to avoid repeated database queries.
 
 607         This cache is not thread-safe and needs to be instantiated per
 
 610     def __init__(self, conn):
 
 612         self.streets = _LRU(maxsize=256)
 
 613         self.places = _LRU(maxsize=128)
 
 614         self.address_terms = _LRU(maxsize=1024)
 
 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}
 
 622         # For postcodes remember the ones that have already been added
 
 623         self.postcodes = set()
 
 625     def get_housenumber(self, number):
 
 626         """ Get a housenumber token from the cache.
 
 628         return self._cached_housenumbers.get(number)
 
 631     def add_postcode(self, conn, postcode):
 
 632         """ Make sure the given postcode is in the database.
 
 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)