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 but using
 
   9 libICU instead of the PostgreSQL module.
 
  15 from textwrap import dedent
 
  17 from nominatim.db.connection import connect
 
  18 from nominatim.db.utils import CopyBuffer
 
  19 from nominatim.db.sql_preprocessor import SQLPreprocessor
 
  20 from nominatim.indexer.place_info import PlaceInfo
 
  21 from nominatim.tokenizer.icu_rule_loader import ICURuleLoader
 
  22 from nominatim.tokenizer.base import AbstractAnalyzer, AbstractTokenizer
 
  24 DBCFG_TERM_NORMALIZATION = "tokenizer_term_normalization"
 
  26 LOG = logging.getLogger()
 
  28 def create(dsn, data_dir):
 
  29     """ Create a new instance of the tokenizer provided by this module.
 
  31     return LegacyICUTokenizer(dsn, data_dir)
 
  34 class LegacyICUTokenizer(AbstractTokenizer):
 
  35     """ This tokenizer uses libICU to covert names and queries to ASCII.
 
  36         Otherwise it uses the same algorithms and data structures as the
 
  37         normalization routines in Nominatim 3.
 
  40     def __init__(self, dsn, data_dir):
 
  42         self.data_dir = data_dir
 
  46     def init_new_db(self, config, init_db=True):
 
  47         """ Set up a new tokenizer for the database.
 
  49             This copies all necessary data in the project directory to make
 
  50             sure the tokenizer remains stable even over updates.
 
  52         self.loader = ICURuleLoader(config)
 
  54         self._install_php(config.lib_dir.php)
 
  58             self.update_sql_functions(config)
 
  59             self._init_db_tables(config)
 
  62     def init_from_project(self, config):
 
  63         """ Initialise the tokenizer from the project directory.
 
  65         self.loader = ICURuleLoader(config)
 
  67         with connect(self.dsn) as conn:
 
  68             self.loader.load_config_from_db(conn)
 
  71     def finalize_import(self, config):
 
  72         """ Do any required postprocessing to make the tokenizer data ready
 
  75         with connect(self.dsn) as conn:
 
  76             sqlp = SQLPreprocessor(conn, config)
 
  77             sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
 
  80     def update_sql_functions(self, config):
 
  81         """ Reimport the SQL functions for this tokenizer.
 
  83         with connect(self.dsn) as conn:
 
  84             sqlp = SQLPreprocessor(conn, config)
 
  85             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer.sql')
 
  88     def check_database(self, config):
 
  89         """ Check that the tokenizer is set up correctly.
 
  91         # Will throw an error if there is an issue.
 
  92         self.init_from_project(config)
 
  95     def update_statistics(self):
 
  96         """ Recompute frequencies for all name words.
 
  98         with connect(self.dsn) as conn:
 
  99             if conn.table_exists('search_name'):
 
 100                 with conn.cursor() as cur:
 
 101                     cur.drop_table("word_frequencies")
 
 102                     LOG.info("Computing word frequencies")
 
 103                     cur.execute("""CREATE TEMP TABLE word_frequencies AS
 
 104                                      SELECT unnest(name_vector) as id, count(*)
 
 105                                      FROM search_name GROUP BY id""")
 
 106                     cur.execute("CREATE INDEX ON word_frequencies(id)")
 
 107                     LOG.info("Update word table with recomputed frequencies")
 
 108                     cur.execute("""UPDATE word
 
 109                                    SET info = info || jsonb_build_object('count', count)
 
 110                                    FROM word_frequencies WHERE word_id = id""")
 
 111                     cur.drop_table("word_frequencies")
 
 115     def _cleanup_housenumbers(self):
 
 116         """ Remove unused house numbers.
 
 118         with connect(self.dsn) as conn:
 
 119             if not conn.table_exists('search_name'):
 
 121             with conn.cursor(name="hnr_counter") as cur:
 
 122                 cur.execute("""SELECT word_id, word_token FROM word
 
 124                                  AND NOT EXISTS(SELECT * FROM search_name
 
 125                                                 WHERE ARRAY[word.word_id] && name_vector)
 
 126                                  AND (char_length(word_token) > 6
 
 127                                       OR word_token not similar to '\\d+')
 
 129                 candidates = {token: wid for wid, token in cur}
 
 130             with conn.cursor(name="hnr_counter") as cur:
 
 131                 cur.execute("""SELECT housenumber FROM placex
 
 132                                WHERE housenumber is not null
 
 133                                      AND (char_length(housenumber) > 6
 
 134                                           OR housenumber not similar to '\\d+')
 
 137                     for hnr in row[0].split(';'):
 
 138                         candidates.pop(hnr, None)
 
 139             LOG.info("There are %s outdated housenumbers.", len(candidates))
 
 141                 with conn.cursor() as cur:
 
 142                     cur.execute("""DELETE FROM word WHERE word_id = any(%s)""",
 
 143                                 (list(candidates.values()), ))
 
 148     def update_word_tokens(self):
 
 149         """ Remove unused tokens.
 
 151         LOG.warning("Cleaning up housenumber tokens.")
 
 152         self._cleanup_housenumbers()
 
 153         LOG.warning("Tokenizer house-keeping done.")
 
 156     def name_analyzer(self):
 
 157         """ Create a new analyzer for tokenizing names and queries
 
 158             using this tokinzer. Analyzers are context managers and should
 
 162             with tokenizer.name_analyzer() as analyzer:
 
 166             When used outside the with construct, the caller must ensure to
 
 167             call the close() function before destructing the analyzer.
 
 169             Analyzers are not thread-safe. You need to instantiate one per thread.
 
 171         return LegacyICUNameAnalyzer(self.dsn, self.loader.make_sanitizer(),
 
 172                                      self.loader.make_token_analysis())
 
 175     def _install_php(self, phpdir):
 
 176         """ Install the php script for the tokenizer.
 
 178         php_file = self.data_dir / "tokenizer.php"
 
 179         php_file.write_text(dedent(f"""\
 
 181             @define('CONST_Max_Word_Frequency', 10000000);
 
 182             @define('CONST_Term_Normalization_Rules', "{self.loader.normalization_rules}");
 
 183             @define('CONST_Transliteration', "{self.loader.get_search_rules()}");
 
 184             require_once('{phpdir}/tokenizer/icu_tokenizer.php');"""))
 
 187     def _save_config(self):
 
 188         """ Save the configuration that needs to remain stable for the given
 
 189             database as database properties.
 
 191         with connect(self.dsn) as conn:
 
 192             self.loader.save_config_to_db(conn)
 
 195     def _init_db_tables(self, config):
 
 196         """ Set up the word table and fill it with pre-computed word
 
 199         with connect(self.dsn) as conn:
 
 200             sqlp = SQLPreprocessor(conn, config)
 
 201             sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer_tables.sql')
 
 205 class LegacyICUNameAnalyzer(AbstractAnalyzer):
 
 206     """ The legacy analyzer uses the ICU library for splitting names.
 
 208         Each instance opens a connection to the database to request the
 
 212     def __init__(self, dsn, sanitizer, token_analysis):
 
 213         self.conn = connect(dsn).connection
 
 214         self.conn.autocommit = True
 
 215         self.sanitizer = sanitizer
 
 216         self.token_analysis = token_analysis
 
 218         self._cache = _TokenCache()
 
 222         """ Free all resources used by the analyzer.
 
 229     def _search_normalized(self, name):
 
 230         """ Return the search token transliteration of the given name.
 
 232         return self.token_analysis.search.transliterate(name).strip()
 
 235     def _normalized(self, name):
 
 236         """ Return the normalized version of the given name with all
 
 237             non-relevant information removed.
 
 239         return self.token_analysis.normalizer.transliterate(name).strip()
 
 242     def get_word_token_info(self, words):
 
 243         """ Return token information for the given list of words.
 
 244             If a word starts with # it is assumed to be a full name
 
 245             otherwise is a partial name.
 
 247             The function returns a list of tuples with
 
 248             (original word, word token, word id).
 
 250             The function is used for testing and debugging only
 
 251             and not necessarily efficient.
 
 256             if word.startswith('#'):
 
 257                 full_tokens[word] = self._search_normalized(word[1:])
 
 259                 partial_tokens[word] = self._search_normalized(word)
 
 261         with self.conn.cursor() as cur:
 
 262             cur.execute("""SELECT word_token, word_id
 
 263                             FROM word WHERE word_token = ANY(%s) and type = 'W'
 
 264                         """, (list(full_tokens.values()),))
 
 265             full_ids = {r[0]: r[1] for r in cur}
 
 266             cur.execute("""SELECT word_token, word_id
 
 267                             FROM word WHERE word_token = ANY(%s) and type = 'w'""",
 
 268                         (list(partial_tokens.values()),))
 
 269             part_ids = {r[0]: r[1] for r in cur}
 
 271         return [(k, v, full_ids.get(v, None)) for k, v in full_tokens.items()] \
 
 272                + [(k, v, part_ids.get(v, None)) for k, v in partial_tokens.items()]
 
 276     def normalize_postcode(postcode):
 
 277         """ Convert the postcode to a standardized form.
 
 279             This function must yield exactly the same result as the SQL function
 
 280             'token_normalized_postcode()'.
 
 282         return postcode.strip().upper()
 
 285     def _make_standard_hnr(self, hnr):
 
 286         """ Create a normalised version of a housenumber.
 
 288             This function takes minor shortcuts on transliteration.
 
 290         return self._search_normalized(hnr)
 
 292     def update_postcodes_from_db(self):
 
 293         """ Update postcode tokens in the word table from the location_postcode
 
 297         with self.conn.cursor() as cur:
 
 298             # This finds us the rows in location_postcode and word that are
 
 299             # missing in the other table.
 
 300             cur.execute("""SELECT * FROM
 
 301                             (SELECT pc, word FROM
 
 302                               (SELECT distinct(postcode) as pc FROM location_postcode) p
 
 304                               (SELECT word FROM word WHERE type = 'P') w
 
 306                            WHERE pc is null or word is null""")
 
 308             with CopyBuffer() as copystr:
 
 309                 for postcode, word in cur:
 
 311                         to_delete.append(word)
 
 313                         copystr.add(self._search_normalized(postcode),
 
 317                     cur.execute("""DELETE FROM WORD
 
 318                                    WHERE type ='P' and word = any(%s)
 
 321                 copystr.copy_out(cur, 'word',
 
 322                                  columns=['word_token', 'type', 'word'])
 
 325     def update_special_phrases(self, phrases, should_replace):
 
 326         """ Replace the search index for special phrases with the new phrases.
 
 327             If `should_replace` is True, then the previous set of will be
 
 328             completely replaced. Otherwise the phrases are added to the
 
 329             already existing ones.
 
 331         norm_phrases = set(((self._normalized(p[0]), p[1], p[2], p[3])
 
 334         with self.conn.cursor() as cur:
 
 335             # Get the old phrases.
 
 336             existing_phrases = set()
 
 337             cur.execute("SELECT word, info FROM word WHERE type = 'S'")
 
 338             for word, info in cur:
 
 339                 existing_phrases.add((word, info['class'], info['type'],
 
 340                                       info.get('op') or '-'))
 
 342             added = self._add_special_phrases(cur, norm_phrases, existing_phrases)
 
 344                 deleted = self._remove_special_phrases(cur, norm_phrases,
 
 349         LOG.info("Total phrases: %s. Added: %s. Deleted: %s",
 
 350                  len(norm_phrases), added, deleted)
 
 353     def _add_special_phrases(self, cursor, new_phrases, existing_phrases):
 
 354         """ Add all phrases to the database that are not yet there.
 
 356         to_add = new_phrases - existing_phrases
 
 359         with CopyBuffer() as copystr:
 
 360             for word, cls, typ, oper in to_add:
 
 361                 term = self._search_normalized(word)
 
 363                     copystr.add(term, 'S', word,
 
 364                                 json.dumps({'class': cls, 'type': typ,
 
 365                                             'op': oper if oper in ('in', 'near') else None}))
 
 368             copystr.copy_out(cursor, 'word',
 
 369                              columns=['word_token', 'type', 'word', 'info'])
 
 375     def _remove_special_phrases(cursor, new_phrases, existing_phrases):
 
 376         """ Remove all phrases from the databse that are no longer in the
 
 379         to_delete = existing_phrases - new_phrases
 
 382             cursor.execute_values(
 
 383                 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
 
 384                     WHERE type = 'S' and word = name
 
 385                           and info->>'class' = in_class and info->>'type' = in_type
 
 386                           and ((op = '-' and info->>'op' is null) or op = info->>'op')
 
 389         return len(to_delete)
 
 392     def add_country_names(self, country_code, names):
 
 393         """ Add names for the given country to the search index.
 
 395         # Make sure any name preprocessing for country names applies.
 
 396         info = PlaceInfo({'name': names, 'country_code': country_code,
 
 397                           'rank_address': 4, 'class': 'boundary',
 
 398                           'type': 'administrative'})
 
 399         self._add_country_full_names(country_code,
 
 400                                      self.sanitizer.process_names(info)[0])
 
 403     def _add_country_full_names(self, country_code, names):
 
 404         """ Add names for the given country from an already sanitized
 
 409             norm_name = self._search_normalized(name.name)
 
 411                 word_tokens.add(norm_name)
 
 413         with self.conn.cursor() as cur:
 
 415             cur.execute("""SELECT word_token FROM word
 
 416                             WHERE type = 'C' and word = %s""",
 
 418             word_tokens.difference_update((t[0] for t in cur))
 
 420             # Only add those names that are not yet in the list.
 
 422                 cur.execute("""INSERT INTO word (word_token, type, word)
 
 423                                (SELECT token, 'C', %s
 
 424                                 FROM unnest(%s) as token)
 
 425                             """, (country_code, list(word_tokens)))
 
 427             # No names are deleted at the moment.
 
 428             # If deletion is made possible, then the static names from the
 
 429             # initial 'country_name' table should be kept.
 
 432     def process_place(self, place):
 
 433         """ Determine tokenizer information about the given place.
 
 435             Returns a JSON-serializable structure that will be handed into
 
 436             the database via the token_info field.
 
 438         token_info = _TokenInfo(self._cache)
 
 440         names, address = self.sanitizer.process_names(place)
 
 443             fulls, partials = self._compute_name_tokens(names)
 
 445             token_info.add_names(fulls, partials)
 
 447             if place.is_country():
 
 448                 self._add_country_full_names(place.country_code, names)
 
 451             self._process_place_address(token_info, address)
 
 453         return token_info.data
 
 456     def _process_place_address(self, token_info, address):
 
 461             if item.kind == 'postcode':
 
 462                 self._add_postcode(item.name)
 
 463             elif item.kind == 'housenumber':
 
 464                 norm_name = self._make_standard_hnr(item.name)
 
 467             elif item.kind == 'street':
 
 468                 streets.extend(self._retrieve_full_tokens(item.name))
 
 469             elif item.kind == 'place':
 
 471                     token_info.add_place(self._compute_partial_tokens(item.name))
 
 472             elif not item.kind.startswith('_') and not item.suffix and \
 
 473                  item.kind not in ('country', 'full'):
 
 474                 addr_terms.append((item.kind, self._compute_partial_tokens(item.name)))
 
 477             token_info.add_housenumbers(self.conn, hnrs)
 
 480             token_info.add_address_terms(addr_terms)
 
 483             token_info.add_street(streets)
 
 486     def _compute_partial_tokens(self, name):
 
 487         """ Normalize the given term, split it into partial words and return
 
 488             then token list for them.
 
 490         norm_name = self._search_normalized(name)
 
 494         for partial in norm_name.split():
 
 495             token = self._cache.partials.get(partial)
 
 499                 need_lookup.append(partial)
 
 502             with self.conn.cursor() as cur:
 
 503                 cur.execute("""SELECT word, getorcreate_partial_word(word)
 
 504                                FROM unnest(%s) word""",
 
 507                 for partial, token in cur:
 
 509                     self._cache.partials[partial] = token
 
 514     def _retrieve_full_tokens(self, name):
 
 515         """ Get the full name token for the given name, if it exists.
 
 516             The name is only retrived for the standard analyser.
 
 518         norm_name = self._search_normalized(name)
 
 520         # return cached if possible
 
 521         if norm_name in self._cache.fulls:
 
 522             return self._cache.fulls[norm_name]
 
 524         with self.conn.cursor() as cur:
 
 525             cur.execute("SELECT word_id FROM word WHERE word_token = %s and type = 'W'",
 
 527             full = [row[0] for row in cur]
 
 529         self._cache.fulls[norm_name] = full
 
 534     def _compute_name_tokens(self, names):
 
 535         """ Computes the full name and partial name tokens for the given
 
 539         partial_tokens = set()
 
 542             analyzer_id = name.get_attr('analyzer')
 
 543             norm_name = self._normalized(name.name)
 
 544             if analyzer_id is None:
 
 547                 token_id = f'{norm_name}@{analyzer_id}'
 
 549             full, part = self._cache.names.get(token_id, (None, None))
 
 551                 variants = self.token_analysis.analysis[analyzer_id].get_variants_ascii(norm_name)
 
 555                 with self.conn.cursor() as cur:
 
 556                     cur.execute("SELECT (getorcreate_full_word(%s, %s)).*",
 
 557                                 (token_id, variants))
 
 558                     full, part = cur.fetchone()
 
 560                 self._cache.names[token_id] = (full, part)
 
 562             full_tokens.add(full)
 
 563             partial_tokens.update(part)
 
 565         return full_tokens, partial_tokens
 
 568     def _add_postcode(self, postcode):
 
 569         """ Make sure the normalized postcode is present in the word table.
 
 571         if re.search(r'[:,;]', postcode) is None:
 
 572             postcode = self.normalize_postcode(postcode)
 
 574             if postcode not in self._cache.postcodes:
 
 575                 term = self._search_normalized(postcode)
 
 579                 with self.conn.cursor() as cur:
 
 580                     # no word_id needed for postcodes
 
 581                     cur.execute("""INSERT INTO word (word_token, type, word)
 
 582                                    (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
 
 585                                       WHERE type = 'P' and word = pc))
 
 586                                 """, (term, postcode))
 
 587                 self._cache.postcodes.add(postcode)
 
 591     """ Collect token information to be sent back to the database.
 
 593     def __init__(self, cache):
 
 598     def _mk_array(tokens):
 
 599         return '{%s}' % ','.join((str(s) for s in tokens))
 
 602     def add_names(self, fulls, partials):
 
 603         """ Adds token information for the normalised names.
 
 605         self.data['names'] = self._mk_array(itertools.chain(fulls, partials))
 
 608     def add_housenumbers(self, conn, hnrs):
 
 609         """ Extract housenumber information from a list of normalised
 
 612         self.data['hnr_tokens'] = self._mk_array(self._cache.get_hnr_tokens(conn, hnrs))
 
 613         self.data['hnr'] = ';'.join(hnrs)
 
 616     def add_street(self, tokens):
 
 617         """ Add addr:street match terms.
 
 619         self.data['street'] = self._mk_array(tokens)
 
 622     def add_place(self, tokens):
 
 623         """ Add addr:place search and match terms.
 
 626             self.data['place'] = self._mk_array(tokens)
 
 629     def add_address_terms(self, terms):
 
 630         """ Add additional address terms.
 
 632         tokens = {key: self._mk_array(partials)
 
 633                   for key, partials in terms if partials}
 
 636             self.data['addr'] = tokens
 
 640     """ Cache for token information to avoid repeated database queries.
 
 642         This cache is not thread-safe and needs to be instantiated per
 
 649         self.postcodes = set()
 
 650         self.housenumbers = {}
 
 653     def get_hnr_tokens(self, conn, terms):
 
 654         """ Get token ids for a list of housenumbers, looking them up in the
 
 655             database if necessary. `terms` is an iterable of normalized
 
 662             token = self.housenumbers.get(term)
 
 669             with conn.cursor() as cur:
 
 670                 cur.execute("SELECT nr, getorcreate_hnr_id(nr) FROM unnest(%s) as nr",
 
 672                 for term, tid in cur:
 
 673                     self.housenumbers[term] = tid