]> git.openstreetmap.org Git - nominatim.git/commitdiff
Merge remote-tracking branch 'upstream/master'
authorSarah Hoffmann <lonvia@denofr.de>
Wed, 28 Jul 2021 14:12:57 +0000 (16:12 +0200)
committerSarah Hoffmann <lonvia@denofr.de>
Wed, 28 Jul 2021 14:12:57 +0000 (16:12 +0200)
31 files changed:
CMakeLists.txt
docs/admin/Installation.md
docs/admin/Tokenizers.md
lib-php/admin/update.php [deleted file]
lib-php/init-website.php
lib-php/tokenizer/legacy_icu_tokenizer.php
lib-php/tokenizer/legacy_tokenizer.php
lib-php/website/details.php
lib-sql/indices.sql
lib-sql/tiger_import_finish.sql
lib-sql/tokenizer/icu_tokenizer_tables.sql [new file with mode: 0644]
lib-sql/tokenizer/legacy_icu_tokenizer.sql
lib-sql/tokenizer/legacy_tokenizer_indices.sql
nominatim/cli.py
nominatim/clicmd/__init__.py
nominatim/clicmd/add_data.py [new file with mode: 0644]
nominatim/db/sql_preprocessor.py
nominatim/db/utils.py
nominatim/tokenizer/legacy_icu_tokenizer.py
nominatim/tools/add_osm_data.py [new file with mode: 0644]
nominatim/tools/exec_utils.py
nominatim/version.py
test/bdd/db/import/postcodes.feature
test/bdd/db/update/postcode.feature
test/bdd/steps/steps_db_ops.py
test/python/mock_icu_word_table.py [new file with mode: 0644]
test/python/mock_legacy_word_table.py [new file with mode: 0644]
test/python/mocks.py
test/python/test_cli.py
test/python/test_db_utils.py
test/python/test_tokenizer_legacy_icu.py

index 48d640bca505a5058e9facb10107ae7b5337cb03..e189e479af7c5fb2525d6caf0aba1585d7005891 100644 (file)
@@ -62,7 +62,7 @@ endif()
 #-----------------------------------------------------------------------------
 
 if (BUILD_IMPORTER)
-    find_package(PythonInterp 3.5 REQUIRED)
+    find_package(PythonInterp 3.6 REQUIRED)
 endif()
 
 #-----------------------------------------------------------------------------
index 76af39c6e0a0b102547dedeb23343e57aa7c42ee..691487b87ef89c7c4394dc67bf17da7dcb8104f8 100644 (file)
@@ -37,7 +37,7 @@ For compiling:
 
 For running Nominatim:
 
-  * [PostgreSQL](https://www.postgresql.org) (9.3+ will work, 11+ strongly recommended)
+  * [PostgreSQL](https://www.postgresql.org) (9.5+ will work, 11+ strongly recommended)
   * [PostGIS](https://postgis.net) (2.2+)
   * [Python 3](https://www.python.org/) (3.6+)
   * [Psycopg2](https://www.psycopg.org) (2.7+)
index 782d50b873fe4ce166e48678b41ac9522403dc03..f3454f67df0714be7a75f7afb5303a96f3109743 100644 (file)
@@ -171,7 +171,7 @@ It is also possible to restrict replacements to the beginning and end of a
 name:
 
 ``` yaml
-- ^south => n  # matches only at the beginning of the name
+- ^south => s  # matches only at the beginning of the name
 - road$ => rd  # matches only at the end of the name
 ```
 
@@ -192,8 +192,8 @@ a shortcut notation for it:
 The simple arrow causes an additional variant to be added. Note that
 decomposition has an effect here on the source as well. So a rule
 
-```yaml
-- ~strasse => str
+``` yaml
+- "~strasse -> str"
 ```
 
 means that for a word like `hauptstrasse` four variants are created:
diff --git a/lib-php/admin/update.php b/lib-php/admin/update.php
deleted file mode 100644 (file)
index 3075070..0000000
+++ /dev/null
@@ -1,150 +0,0 @@
-<?php
-@define('CONST_LibDir', dirname(dirname(__FILE__)));
-
-require_once(CONST_LibDir.'/init-cmd.php');
-require_once(CONST_LibDir.'/setup_functions.php');
-
-ini_set('memory_limit', '800M');
-
-// (long-opt, short-opt, min-occurs, max-occurs, num-arguments, num-arguments, type, help)
-$aCMDOptions
-= array(
-   'Import / update / index osm data',
-   array('help', 'h', 0, 1, 0, 0, false, 'Show Help'),
-   array('quiet', 'q', 0, 1, 0, 0, 'bool', 'Quiet output'),
-   array('verbose', 'v', 0, 1, 0, 0, 'bool', 'Verbose output'),
-
-   array('calculate-postcodes', '', 0, 1, 0, 0, 'bool', 'Update postcode centroid table'),
-
-   array('import-file', '', 0, 1, 1, 1, 'realpath', 'Re-import data from an OSM file'),
-   array('import-diff', '', 0, 1, 1, 1, 'realpath', 'Import a diff (osc) file from local file system'),
-   array('osm2pgsql-cache', '', 0, 1, 1, 1, 'int', 'Cache size used by osm2pgsql'),
-
-   array('import-node', '', 0, 1, 1, 1, 'int', 'Re-import node'),
-   array('import-way', '', 0, 1, 1, 1, 'int', 'Re-import way'),
-   array('import-relation', '', 0, 1, 1, 1, 'int', 'Re-import relation'),
-   array('import-from-main-api', '', 0, 1, 0, 0, 'bool', 'Use OSM API instead of Overpass to download objects'),
-
-   array('project-dir', '', 0, 1, 1, 1, 'realpath', 'Base directory of the Nominatim installation (default: .)'),
-  );
-
-getCmdOpt($_SERVER['argv'], $aCMDOptions, $aResult, true, true);
-
-loadSettings($aCMDResult['project-dir'] ?? getcwd());
-setupHTTPProxy();
-
-date_default_timezone_set('Etc/UTC');
-
-$oDB = new Nominatim\DB();
-$oDB->connect();
-$fPostgresVersion = $oDB->getPostgresVersion();
-
-$aDSNInfo = Nominatim\DB::parseDSN(getSetting('DATABASE_DSN'));
-if (!isset($aDSNInfo['port']) || !$aDSNInfo['port']) {
-    $aDSNInfo['port'] = 5432;
-}
-
-// cache memory to be used by osm2pgsql, should not be more than the available memory
-$iCacheMemory = (isset($aResult['osm2pgsql-cache'])?$aResult['osm2pgsql-cache']:2000);
-if ($iCacheMemory + 500 > getTotalMemoryMB()) {
-    $iCacheMemory = getCacheMemoryMB();
-    echo "WARNING: resetting cache memory to $iCacheMemory\n";
-}
-
-$oOsm2pgsqlCmd = (new \Nominatim\Shell(getOsm2pgsqlBinary()))
-                 ->addParams('--hstore')
-                 ->addParams('--latlong')
-                 ->addParams('--append')
-                 ->addParams('--slim')
-                 ->addParams('--with-forward-dependencies', 'false')
-                 ->addParams('--log-progress', 'true')
-                 ->addParams('--number-processes', 1)
-                 ->addParams('--cache', $iCacheMemory)
-                 ->addParams('--output', 'gazetteer')
-                 ->addParams('--style', getImportStyle())
-                 ->addParams('--database', $aDSNInfo['database'])
-                 ->addParams('--port', $aDSNInfo['port']);
-
-if (isset($aDSNInfo['hostspec']) && $aDSNInfo['hostspec']) {
-    $oOsm2pgsqlCmd->addParams('--host', $aDSNInfo['hostspec']);
-}
-if (isset($aDSNInfo['username']) && $aDSNInfo['username']) {
-    $oOsm2pgsqlCmd->addParams('--user', $aDSNInfo['username']);
-}
-if (isset($aDSNInfo['password']) && $aDSNInfo['password']) {
-    $oOsm2pgsqlCmd->addEnvPair('PGPASSWORD', $aDSNInfo['password']);
-}
-if (getSetting('FLATNODE_FILE')) {
-    $oOsm2pgsqlCmd->addParams('--flat-nodes', getSetting('FLATNODE_FILE'));
-}
-if ($fPostgresVersion >= 11.0) {
-    $oOsm2pgsqlCmd->addEnvPair(
-        'PGOPTIONS',
-        '-c jit=off -c max_parallel_workers_per_gather=0'
-    );
-}
-
-if (isset($aResult['import-diff']) || isset($aResult['import-file'])) {
-    // import diffs and files directly (e.g. from osmosis --rri)
-    $sNextFile = isset($aResult['import-diff']) ? $aResult['import-diff'] : $aResult['import-file'];
-
-    if (!file_exists($sNextFile)) {
-        fail("Cannot open $sNextFile\n");
-    }
-
-    // Import the file
-    $oCMD = (clone $oOsm2pgsqlCmd)->addParams($sNextFile);
-    echo $oCMD->escapedCmd()."\n";
-    $iRet = $oCMD->run();
-
-    if ($iRet) {
-        fail("Error from osm2pgsql, $iRet\n");
-    }
-
-    // Don't update the import status - we don't know what this file contains
-}
-
-$sTemporaryFile = CONST_InstallDir.'/osmosischange.osc';
-$bHaveDiff = false;
-$bUseOSMApi = isset($aResult['import-from-main-api']) && $aResult['import-from-main-api'];
-$sContentURL = '';
-if (isset($aResult['import-node']) && $aResult['import-node']) {
-    if ($bUseOSMApi) {
-        $sContentURL = 'https://www.openstreetmap.org/api/0.6/node/'.$aResult['import-node'];
-    } else {
-        $sContentURL = 'https://overpass-api.de/api/interpreter?data=node('.$aResult['import-node'].');out%20meta;';
-    }
-}
-
-if (isset($aResult['import-way']) && $aResult['import-way']) {
-    if ($bUseOSMApi) {
-        $sContentURL = 'https://www.openstreetmap.org/api/0.6/way/'.$aResult['import-way'].'/full';
-    } else {
-        $sContentURL = 'https://overpass-api.de/api/interpreter?data=(way('.$aResult['import-way'].');%3E;);out%20meta;';
-    }
-}
-
-if (isset($aResult['import-relation']) && $aResult['import-relation']) {
-    if ($bUseOSMApi) {
-        $sContentURL = 'https://www.openstreetmap.org/api/0.6/relation/'.$aResult['import-relation'].'/full';
-    } else {
-        $sContentURL = 'https://overpass-api.de/api/interpreter?data=(rel(id:'.$aResult['import-relation'].');%3E;);out%20meta;';
-    }
-}
-
-if ($sContentURL) {
-    file_put_contents($sTemporaryFile, file_get_contents($sContentURL));
-    $bHaveDiff = true;
-}
-
-if ($bHaveDiff) {
-    // import generated change file
-
-    $oCMD = (clone $oOsm2pgsqlCmd)->addParams($sTemporaryFile);
-    echo $oCMD->escapedCmd()."\n";
-
-    $iRet = $oCMD->run();
-    if ($iRet) {
-        fail("osm2pgsql exited with error level $iRet\n");
-    }
-}
index d6cc8a245c3b377a9954d7509c5e409c114affe7..6f3b55453f29939ed7e3c9144a4f1e090a6b086b 100644 (file)
@@ -12,7 +12,7 @@ require_once(CONST_Debug ? 'DebugHtml.php' : 'DebugNone.php');
 
 function userError($sMsg)
 {
-    throw new Exception($sMsg, 400);
+    throw new \Exception($sMsg, 400);
 }
 
 
@@ -37,7 +37,7 @@ function shutdown_exception_handler_xml()
 {
     $error = error_get_last();
     if ($error !== null && $error['type'] === E_ERROR) {
-        exception_handler_xml(new Exception($error['message'], 500));
+        exception_handler_xml(new \Exception($error['message'], 500));
     }
 }
 
@@ -45,7 +45,7 @@ function shutdown_exception_handler_json()
 {
     $error = error_get_last();
     if ($error !== null && $error['type'] === E_ERROR) {
-        exception_handler_json(new Exception($error['message'], 500));
+        exception_handler_json(new \Exception($error['message'], 500));
     }
 }
 
index 2c0884c8170b46df51f64d90e67def88ac2d3b55..3751e821837d4cfb9acb52f7fb3f0e9ebf000465 100644 (file)
@@ -19,13 +19,13 @@ class Tokenizer
 
     public function checkStatus()
     {
-        $sSQL = "SELECT word_id FROM word WHERE word_token IN (' a')";
+        $sSQL = 'SELECT word_id FROM word limit 1';
         $iWordID = $this->oDB->getOne($sSQL);
         if ($iWordID === false) {
-            throw new Exception('Query failed', 703);
+            throw new \Exception('Query failed', 703);
         }
         if (!$iWordID) {
-            throw new Exception('No value', 704);
+            throw new \Exception('No value', 704);
         }
     }
 
@@ -55,9 +55,8 @@ class Tokenizer
     {
         $aResults = array();
 
-        $sSQL = 'SELECT word_id, class, type FROM word ';
-        $sSQL .= '   WHERE word_token = \' \' || :term';
-        $sSQL .= '   AND class is not null AND class not in (\'place\')';
+        $sSQL = "SELECT word_id, info->>'class' as class, info->>'type' as type ";
+        $sSQL .= '   FROM word WHERE word_token = :term and type = \'S\'';
 
         Debug::printVar('Term', $sTerm);
         Debug::printSQL($sSQL);
@@ -146,8 +145,10 @@ class Tokenizer
     private function addTokensFromDB(&$oValidTokens, $aTokens, $sNormQuery)
     {
         // Check which tokens we have, get the ID numbers
-        $sSQL = 'SELECT word_id, word_token, word, class, type, country_code,';
-        $sSQL .= ' operator, coalesce(search_name_count, 0) as count';
+        $sSQL = 'SELECT word_id, word_token, type, word,';
+        $sSQL .= "      info->>'op' as operator,";
+        $sSQL .= "      info->>'class' as class, info->>'type' as ctype,";
+        $sSQL .= "      info->>'count' as count";
         $sSQL .= ' FROM word WHERE word_token in (';
         $sSQL .= join(',', $this->oDB->getDBQuotedList($aTokens)).')';
 
@@ -156,67 +157,66 @@ class Tokenizer
         $aDBWords = $this->oDB->getAll($sSQL, null, 'Could not get word tokens.');
 
         foreach ($aDBWords as $aWord) {
-            $oToken = null;
             $iId = (int) $aWord['word_id'];
+            $sTok = $aWord['word_token'];
 
-            if ($aWord['class']) {
-                // Special terms need to appear in their normalized form.
-                // (postcodes are not normalized in the word table)
-                $sNormWord = $this->normalizeString($aWord['word']);
-                if ($aWord['word'] && strpos($sNormQuery, $sNormWord) === false) {
-                    continue;
-                }
-
-                if ($aWord['class'] == 'place' && $aWord['type'] == 'house') {
-                    $oToken = new Token\HouseNumber($iId, trim($aWord['word_token']));
-                } elseif ($aWord['class'] == 'place' && $aWord['type'] == 'postcode') {
-                    if ($aWord['word']
+            switch ($aWord['type']) {
+                case 'C':  // country name tokens
+                    if ($aWord['word'] !== null
+                        && (!$this->aCountryRestriction
+                            || in_array($aWord['word'], $this->aCountryRestriction))
+                    ) {
+                        $oValidTokens->addToken(
+                            $sTok,
+                            new Token\Country($iId, $aWord['word'])
+                        );
+                    }
+                    break;
+                case 'H':  // house number tokens
+                    $oValidTokens->addToken($sTok, new Token\HouseNumber($iId, $aWord['word_token']));
+                    break;
+                case 'P':  // postcode tokens
+                    // Postcodes are not normalized, so they may have content
+                    // that makes SQL injection possible. Reject postcodes
+                    // that would need special escaping.
+                    if ($aWord['word'] !== null
                         && pg_escape_string($aWord['word']) == $aWord['word']
                     ) {
-                        $oToken = new Token\Postcode(
+                        $sNormPostcode = $this->normalizeString($aWord['word']);
+                        if (strpos($sNormQuery, $sNormPostcode) !== false) {
+                            $oValidTokens->addToken(
+                                $sTok,
+                                new Token\Postcode($iId, $aWord['word'], null)
+                            );
+                        }
+                    }
+                    break;
+                case 'S':  // tokens for classification terms (special phrases)
+                    if ($aWord['class'] !== null && $aWord['ctype'] !== null) {
+                        $oValidTokens->addToken($sTok, new Token\SpecialTerm(
                             $iId,
-                            $aWord['word'],
-                            $aWord['country_code']
-                        );
+                            $aWord['class'],
+                            $aWord['ctype'],
+                            (isset($aWord['operator'])) ? Operator::NEAR : Operator::NONE
+                        ));
                     }
-                } else {
-                    // near and in operator the same at the moment
-                    $oToken = new Token\SpecialTerm(
+                    break;
+                case 'W': // full-word tokens
+                    $oValidTokens->addToken($sTok, new Token\Word(
                         $iId,
-                        $aWord['class'],
-                        $aWord['type'],
-                        $aWord['operator'] ? Operator::NEAR : Operator::NONE
-                    );
-                }
-            } elseif ($aWord['country_code']) {
-                // Filter country tokens that do not match restricted countries.
-                if (!$this->aCountryRestriction
-                    || in_array($aWord['country_code'], $this->aCountryRestriction)
-                ) {
-                    $oToken = new Token\Country($iId, $aWord['country_code']);
-                }
-            } elseif ($aWord['word_token'][0] == ' ') {
-                 $oToken = new Token\Word(
-                     $iId,
-                     $aWord['word_token'][0] != ' ',
-                     (int) $aWord['count'],
-                     substr_count($aWord['word_token'], ' ')
-                 );
-            } else {
-                $oToken = new Token\Partial(
-                    $iId,
-                    $aWord['word_token'],
-                    (int) $aWord['count']
-                );
-            }
-
-            if ($oToken) {
-                // remove any leading spaces
-                if ($aWord['word_token'][0] == ' ') {
-                    $oValidTokens->addToken(substr($aWord['word_token'], 1), $oToken);
-                } else {
-                    $oValidTokens->addToken($aWord['word_token'], $oToken);
-                }
+                        (int) $aWord['count'],
+                        substr_count($aWord['word_token'], ' ')
+                    ));
+                    break;
+                case 'w':  // partial word terms
+                    $oValidTokens->addToken($sTok, new Token\Partial(
+                        $iId,
+                        $aWord['word_token'],
+                        (int) $aWord['count']
+                    ));
+                    break;
+                default:
+                    break;
             }
         }
     }
@@ -235,12 +235,10 @@ class Tokenizer
 
         for ($i = 0; $i < $iNumWords; $i++) {
             $sPhrase = $aWords[$i];
-            $aTokens[' '.$sPhrase] = ' '.$sPhrase;
             $aTokens[$sPhrase] = $sPhrase;
 
             for ($j = $i + 1; $j < $iNumWords; $j++) {
                 $sPhrase .= ' '.$aWords[$j];
-                $aTokens[' '.$sPhrase] = ' '.$sPhrase;
                 $aTokens[$sPhrase] = $sPhrase;
             }
         }
index 064b41667a9322bb6cb164dd6f7bb041490d1257..570b88289e7cd13a68dd13d8c5af64c519f33fe6 100644 (file)
@@ -19,20 +19,20 @@ class Tokenizer
     {
         $sStandardWord = $this->oDB->getOne("SELECT make_standard_name('a')");
         if ($sStandardWord === false) {
-            throw new Exception('Module failed', 701);
+            throw new \Exception('Module failed', 701);
         }
 
         if ($sStandardWord != 'a') {
-            throw new Exception('Module call failed', 702);
+            throw new \Exception('Module call failed', 702);
         }
 
         $sSQL = "SELECT word_id FROM word WHERE word_token IN (' a')";
         $iWordID = $this->oDB->getOne($sSQL);
         if ($iWordID === false) {
-            throw new Exception('Query failed', 703);
+            throw new \Exception('Query failed', 703);
         }
         if (!$iWordID) {
-            throw new Exception('No value', 704);
+            throw new \Exception('No value', 704);
         }
     }
 
index c16725e2ca2dae23b4b6e7d68195dafd4297bb10..0d67ec83d7a18bb9cb3906f7981b8cd26f614612 100644 (file)
@@ -83,7 +83,7 @@ if ($sOsmType && $iOsmId > 0) {
     }
 
     if ($sPlaceId === false) {
-        throw new Exception('No place with that OSM ID found.', 404);
+        throw new \Exception('No place with that OSM ID found.', 404);
     }
 } else {
     if ($sPlaceId === false) {
@@ -146,7 +146,7 @@ $sSQL .= " WHERE place_id = $iPlaceID";
 $aPointDetails = $oDB->getRow($sSQL, null, 'Could not get details of place object.');
 
 if (!$aPointDetails) {
-    throw new Exception('No place with that place ID found.', 404);
+    throw new \Exception('No place with that place ID found.', 404);
 }
 
 $aPointDetails['localname'] = $aPointDetails['localname']?$aPointDetails['localname']:$aPointDetails['housenumber'];
index 81299544573c0c4c1ffea2850b8be54477f6a2bb..62bae94c7120917b90e495e8f4915d2f314ed6e6 100644 (file)
@@ -1,62 +1,62 @@
 -- Indices used only during search and update.
 -- These indices are created only after the indexing process is done.
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_place_addressline_address_place_id
+CREATE INDEX IF NOT EXISTS idx_place_addressline_address_place_id
   ON place_addressline USING BTREE (address_place_id) {{db.tablespace.search_index}};
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_placex_rank_search
+CREATE INDEX IF NOT EXISTS idx_placex_rank_search
   ON placex USING BTREE (rank_search) {{db.tablespace.search_index}};
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_placex_rank_address
+CREATE INDEX IF NOT EXISTS idx_placex_rank_address
   ON placex USING BTREE (rank_address) {{db.tablespace.search_index}};
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_placex_parent_place_id
+CREATE INDEX IF NOT EXISTS idx_placex_parent_place_id
   ON placex USING BTREE (parent_place_id) {{db.tablespace.search_index}}
   WHERE parent_place_id IS NOT NULL;
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_placex_geometry_reverse_lookupPolygon
+CREATE INDEX IF NOT EXISTS idx_placex_geometry_reverse_lookupPolygon
   ON placex USING gist (geometry) {{db.tablespace.search_index}}
   WHERE St_GeometryType(geometry) in ('ST_Polygon', 'ST_MultiPolygon')
     AND rank_address between 4 and 25 AND type != 'postcode'
     AND name is not null AND indexed_status = 0 AND linked_place_id is null;
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_osmline_parent_place_id
+CREATE INDEX IF NOT EXISTS idx_osmline_parent_place_id
   ON location_property_osmline USING BTREE (parent_place_id) {{db.tablespace.search_index}};
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_osmline_parent_osm_id
+CREATE INDEX IF NOT EXISTS idx_osmline_parent_osm_id
   ON location_property_osmline USING BTREE (osm_id) {{db.tablespace.search_index}};
 
-CREATE INDEX {{sql.if_index_not_exists}} idx_postcode_postcode
+CREATE INDEX IF NOT EXISTS idx_postcode_postcode
   ON location_postcode USING BTREE (postcode) {{db.tablespace.search_index}};
 
 -- Indices only needed for updating.
 
 {% if not drop %}
-  CREATE INDEX {{sql.if_index_not_exists}} idx_placex_pendingsector
+  CREATE INDEX IF NOT EXISTS idx_placex_pendingsector
     ON placex USING BTREE (rank_address,geometry_sector) {{db.tablespace.address_index}}
     WHERE indexed_status > 0;
 
-  CREATE INDEX {{sql.if_index_not_exists}} idx_location_area_country_place_id
+  CREATE INDEX IF NOT EXISTS idx_location_area_country_place_id
     ON location_area_country USING BTREE (place_id) {{db.tablespace.address_index}};
 
-  CREATE UNIQUE INDEX {{sql.if_index_not_exists}} idx_place_osm_unique
+  CREATE UNIQUE INDEX IF NOT EXISTS idx_place_osm_unique
     ON place USING btree(osm_id, osm_type, class, type) {{db.tablespace.address_index}};
 {% endif %}
 
 -- Indices only needed for search.
 
 {% if 'search_name' in db.tables %}
-  CREATE INDEX {{sql.if_index_not_exists}} idx_search_name_nameaddress_vector
+  CREATE INDEX IF NOT EXISTS idx_search_name_nameaddress_vector
     ON search_name USING GIN (nameaddress_vector) WITH (fastupdate = off) {{db.tablespace.search_index}};
-  CREATE INDEX {{sql.if_index_not_exists}} idx_search_name_name_vector
+  CREATE INDEX IF NOT EXISTS idx_search_name_name_vector
     ON search_name USING GIN (name_vector) WITH (fastupdate = off) {{db.tablespace.search_index}};
-  CREATE INDEX {{sql.if_index_not_exists}} idx_search_name_centroid
+  CREATE INDEX IF NOT EXISTS idx_search_name_centroid
     ON search_name USING GIST (centroid) {{db.tablespace.search_index}};
 
   {% if postgres.has_index_non_key_column %}
-    CREATE INDEX {{sql.if_index_not_exists}} idx_placex_housenumber
+    CREATE INDEX IF NOT EXISTS idx_placex_housenumber
       ON placex USING btree (parent_place_id) INCLUDE (housenumber) WHERE housenumber is not null;
-    CREATE INDEX {{sql.if_index_not_exists}} idx_osmline_parent_osm_id_with_hnr
+    CREATE INDEX IF NOT EXISTS idx_osmline_parent_osm_id_with_hnr
       ON location_property_osmline USING btree(parent_place_id) INCLUDE (startnumber, endnumber);
   {% endif %}
 {% endif %}
index a084a2e2c52c2dd818ec2381be2400dcfc180fcd..1a9dc2ddf1da980af4ebfa76856e62c47e9c5f87 100644 (file)
@@ -1,7 +1,7 @@
 --index only on parent_place_id
-CREATE INDEX {{sql.if_index_not_exists}} idx_location_property_tiger_parent_place_id_imp
+CREATE INDEX IF NOT EXISTS idx_location_property_tiger_parent_place_id_imp
   ON location_property_tiger_import (parent_place_id) {{db.tablespace.aux_index}};
-CREATE UNIQUE INDEX {{sql.if_index_not_exists}} idx_location_property_tiger_place_id_imp
+CREATE UNIQUE INDEX IF NOT EXISTS idx_location_property_tiger_place_id_imp
   ON location_property_tiger_import (place_id) {{db.tablespace.aux_index}};
 
 GRANT SELECT ON location_property_tiger_import TO "{{config.DATABASE_WEBUSER}}";
diff --git a/lib-sql/tokenizer/icu_tokenizer_tables.sql b/lib-sql/tokenizer/icu_tokenizer_tables.sql
new file mode 100644 (file)
index 0000000..7ec3c6f
--- /dev/null
@@ -0,0 +1,29 @@
+DROP TABLE IF EXISTS word;
+CREATE TABLE word (
+  word_id INTEGER,
+  word_token text NOT NULL,
+  type text NOT NULL,
+  word text,
+  info jsonb
+) {{db.tablespace.search_data}};
+
+CREATE INDEX idx_word_word_token ON word
+    USING BTREE (word_token) {{db.tablespace.search_index}};
+-- Used when updating country names from the boundary relation.
+CREATE INDEX idx_word_country_names ON word
+    USING btree(word) {{db.tablespace.address_index}}
+    WHERE type = 'C';
+-- Used when inserting new postcodes on updates.
+CREATE INDEX idx_word_postcodes ON word
+    USING btree(word) {{db.tablespace.address_index}}
+    WHERE type = 'P';
+-- Used when inserting full words.
+CREATE INDEX idx_word_full_word ON word
+    USING btree(word) {{db.tablespace.address_index}}
+    WHERE type = 'W';
+
+GRANT SELECT ON word TO "{{config.DATABASE_WEBUSER}}";
+
+DROP SEQUENCE IF EXISTS seq_word;
+CREATE SEQUENCE seq_word start 1;
+GRANT SELECT ON seq_word to "{{config.DATABASE_WEBUSER}}";
index 686137de5f11a5bbdeb350350340c91a508f93da..ffe6648c38e959c6279efb2d1898d835514f32a7 100644 (file)
@@ -98,12 +98,14 @@ DECLARE
   term_count INTEGER;
 BEGIN
   SELECT min(word_id) INTO full_token
-    FROM word WHERE word = norm_term and class is null and country_code is null;
+    FROM word WHERE word = norm_term and type = 'W';
 
   IF full_token IS NULL THEN
     full_token := nextval('seq_word');
-    INSERT INTO word (word_id, word_token, word, search_name_count)
-      SELECT full_token, ' ' || lookup_term, norm_term, 0 FROM unnest(lookup_terms) as lookup_term;
+    INSERT INTO word (word_id, word_token, type, word, info)
+      SELECT full_token, lookup_term, 'W', norm_term,
+             json_build_object('count', 0)
+        FROM unnest(lookup_terms) as lookup_term;
   END IF;
 
   FOR term IN SELECT unnest(string_to_array(unnest(lookup_terms), ' ')) LOOP
@@ -115,14 +117,14 @@ BEGIN
 
   partial_tokens := '{}'::INT[];
   FOR term IN SELECT unnest(partial_terms) LOOP
-    SELECT min(word_id), max(search_name_count) INTO term_id, term_count
-      FROM word WHERE word_token = term and class is null and country_code is null;
+    SELECT min(word_id), max(info->>'count') INTO term_id, term_count
+      FROM word WHERE word_token = term and type = 'w';
 
     IF term_id IS NULL THEN
       term_id := nextval('seq_word');
       term_count := 0;
-      INSERT INTO word (word_id, word_token, search_name_count)
-        VALUES (term_id, term, 0);
+      INSERT INTO word (word_id, word_token, type, info)
+        VALUES (term_id, term, 'w', json_build_object('count', term_count));
     END IF;
 
     IF term_count < {{ max_word_freq }} THEN
@@ -140,15 +142,13 @@ CREATE OR REPLACE FUNCTION getorcreate_hnr_id(lookup_term TEXT)
 DECLARE
   return_id INTEGER;
 BEGIN
-  SELECT min(word_id) INTO return_id
-    FROM word
-    WHERE word_token = '  '  || lookup_term
-          and class = 'place' and type = 'house';
+  SELECT min(word_id) INTO return_id FROM word
+    WHERE word_token = lookup_term and type = 'H';
 
   IF return_id IS NULL THEN
     return_id := nextval('seq_word');
-    INSERT INTO word (word_id, word_token, class, type, search_name_count)
-      VALUES (return_id, ' ' || lookup_term, 'place', 'house', 0);
+    INSERT INTO word (word_id, word_token, type)
+      VALUES (return_id, lookup_term, 'H');
   END IF;
 
   RETURN return_id;
index 44a2909c108a76d321a47bbc9e46626f1a5b657e..b21f29d7fc3b72bc0c7f00ed9e87da161c2822fc 100644 (file)
@@ -1,2 +1,2 @@
-CREATE INDEX {{sql.if_index_not_exists}} idx_word_word_id
+CREATE INDEX IF NOT EXISTS idx_word_word_id
   ON word USING BTREE (word_id) {{db.tablespace.search_index}};
index 5626deb4b5aa6d503e9efd345086f85617ffd487..7fae205bd36dde2a9df3fca68e12444c82d403f3 100644 (file)
@@ -114,63 +114,6 @@ class CommandlineParser:
 #
 # No need to document the functions each time.
 # pylint: disable=C0111
-# Using non-top-level imports to make pyosmium optional for replication only.
-# pylint: disable=E0012,C0415
-class UpdateAddData:
-    """\
-    Add additional data from a file or an online source.
-
-    Data is only imported, not indexed. You need to call `nominatim index`
-    to complete the process.
-    """
-
-    @staticmethod
-    def add_args(parser):
-        group_name = parser.add_argument_group('Source')
-        group = group_name.add_mutually_exclusive_group(required=True)
-        group.add_argument('--file', metavar='FILE',
-                           help='Import data from an OSM file')
-        group.add_argument('--diff', metavar='FILE',
-                           help='Import data from an OSM diff file')
-        group.add_argument('--node', metavar='ID', type=int,
-                           help='Import a single node from the API')
-        group.add_argument('--way', metavar='ID', type=int,
-                           help='Import a single way from the API')
-        group.add_argument('--relation', metavar='ID', type=int,
-                           help='Import a single relation from the API')
-        group.add_argument('--tiger-data', metavar='DIR',
-                           help='Add housenumbers from the US TIGER census database.')
-        group = parser.add_argument_group('Extra arguments')
-        group.add_argument('--use-main-api', action='store_true',
-                           help='Use OSM API instead of Overpass to download objects')
-
-    @staticmethod
-    def run(args):
-        from nominatim.tokenizer import factory as tokenizer_factory
-        from nominatim.tools import tiger_data
-
-        if args.tiger_data:
-            tokenizer = tokenizer_factory.get_tokenizer_for_db(args.config)
-            return tiger_data.add_tiger_data(args.tiger_data,
-                                             args.config, args.threads or 1,
-                                             tokenizer)
-
-        params = ['update.php']
-        if args.file:
-            params.extend(('--import-file', args.file))
-        elif args.diff:
-            params.extend(('--import-diff', args.diff))
-        elif args.node:
-            params.extend(('--import-node', args.node))
-        elif args.way:
-            params.extend(('--import-way', args.way))
-        elif args.relation:
-            params.extend(('--import-relation', args.relation))
-        if args.use_main_api:
-            params.append('--use-main-api')
-        return run_legacy_script(*params, nominatim_env=args)
-
-
 class QueryExport:
     """\
     Export addresses as CSV file from the database.
@@ -261,7 +204,7 @@ def get_set_parser(**kwargs):
 
     parser.add_subcommand('special-phrases', clicmd.ImportSpecialPhrases)
 
-    parser.add_subcommand('add-data', UpdateAddData)
+    parser.add_subcommand('add-data', clicmd.UpdateAddData)
     parser.add_subcommand('index', clicmd.UpdateIndex)
     parser.add_subcommand('refresh', clicmd.UpdateRefresh())
 
index f905fed1b97207fd5a039fa1fa09166e073366b2..ac2cae5b782cac0ecc4dee9eb641f3b0129b8199 100644 (file)
@@ -7,6 +7,7 @@ from nominatim.clicmd.replication import UpdateReplication
 from nominatim.clicmd.api import APISearch, APIReverse, APILookup, APIDetails, APIStatus
 from nominatim.clicmd.index import UpdateIndex
 from nominatim.clicmd.refresh import UpdateRefresh
+from nominatim.clicmd.add_data import UpdateAddData
 from nominatim.clicmd.admin import AdminFuncs
 from nominatim.clicmd.freeze import SetupFreeze
 from nominatim.clicmd.special_phrases import ImportSpecialPhrases
diff --git a/nominatim/clicmd/add_data.py b/nominatim/clicmd/add_data.py
new file mode 100644 (file)
index 0000000..d13f46d
--- /dev/null
@@ -0,0 +1,76 @@
+"""
+Implementation of the 'add-data' subcommand.
+"""
+import logging
+
+# Do not repeat documentation of subcommand classes.
+# pylint: disable=C0111
+# Using non-top-level imports to avoid eventually unused imports.
+# pylint: disable=E0012,C0415
+
+LOG = logging.getLogger()
+
+class UpdateAddData:
+    """\
+    Add additional data from a file or an online source.
+
+    Data is only imported, not indexed. You need to call `nominatim index`
+    to complete the process.
+    """
+
+    @staticmethod
+    def add_args(parser):
+        group_name = parser.add_argument_group('Source')
+        group = group_name.add_mutually_exclusive_group(required=True)
+        group.add_argument('--file', metavar='FILE',
+                           help='Import data from an OSM file or diff file')
+        group.add_argument('--diff', metavar='FILE',
+                           help='Import data from an OSM diff file (deprecated: use --file)')
+        group.add_argument('--node', metavar='ID', type=int,
+                           help='Import a single node from the API')
+        group.add_argument('--way', metavar='ID', type=int,
+                           help='Import a single way from the API')
+        group.add_argument('--relation', metavar='ID', type=int,
+                           help='Import a single relation from the API')
+        group.add_argument('--tiger-data', metavar='DIR',
+                           help='Add housenumbers from the US TIGER census database.')
+        group = parser.add_argument_group('Extra arguments')
+        group.add_argument('--use-main-api', action='store_true',
+                           help='Use OSM API instead of Overpass to download objects')
+        group.add_argument('--osm2pgsql-cache', metavar='SIZE', type=int,
+                           help='Size of cache to be used by osm2pgsql (in MB)')
+        group.add_argument('--socket-timeout', dest='socket_timeout', type=int, default=60,
+                           help='Set timeout for file downloads.')
+
+    @staticmethod
+    def run(args):
+        from nominatim.tokenizer import factory as tokenizer_factory
+        from nominatim.tools import tiger_data, add_osm_data
+
+        if args.tiger_data:
+            tokenizer = tokenizer_factory.get_tokenizer_for_db(args.config)
+            return tiger_data.add_tiger_data(args.tiger_data,
+                                             args.config, args.threads or 1,
+                                             tokenizer)
+
+        osm2pgsql_params = args.osm2pgsql_options(default_cache=1000, default_threads=1)
+        if args.file or args.diff:
+            return add_osm_data.add_data_from_file(args.file or args.diff,
+                                                   osm2pgsql_params)
+
+        if args.node:
+            return add_osm_data.add_osm_object('node', args.node,
+                                               args.use_main_api,
+                                               osm2pgsql_params)
+
+        if args.way:
+            return add_osm_data.add_osm_object('way', args.way,
+                                               args.use_main_api,
+                                               osm2pgsql_params)
+
+        if args.relation:
+            return add_osm_data.add_osm_object('relation', args.relation,
+                                               args.use_main_api,
+                                               osm2pgsql_params)
+
+        return 0
index d756a215618d316499af1261c4f48a35c801b20c..80b89c57b1dbfb969aeb10f6b05d8b5166507f23 100644 (file)
@@ -41,20 +41,6 @@ def _setup_tablespace_sql(config):
     return out
 
 
-def _setup_postgres_sql(conn):
-    """ Set up a dictionary with various Postgresql/Postgis SQL terms which
-        are dependent on the database version in use.
-    """
-    out = {}
-    pg_version = conn.server_version_tuple()
-    # CREATE INDEX IF NOT EXISTS was introduced in PG9.5.
-    # Note that you need to ignore failures on older versions when
-    # using this construct.
-    out['if_index_not_exists'] = ' IF NOT EXISTS ' if pg_version >= (9, 5, 0) else ''
-
-    return out
-
-
 def _setup_postgresql_features(conn):
     """ Set up a dictionary with various optional Postgresql/Postgis features that
         depend on the database version.
@@ -87,7 +73,6 @@ class SQLPreprocessor:
 
         self.env.globals['config'] = config
         self.env.globals['db'] = db_info
-        self.env.globals['sql'] = _setup_postgres_sql(conn)
         self.env.globals['postgres'] = _setup_postgresql_features(conn)
 
 
index 9a4a41a581661ced3048797b7ae1ff98d613dad0..bb7faa25767f2a55066494a448a3fc00aa5b6025 100644 (file)
@@ -65,6 +65,7 @@ _SQL_TRANSLATION = {ord(u'\\'): u'\\\\',
                     ord(u'\t'): u'\\t',
                     ord(u'\n'): u'\\n'}
 
+
 class CopyBuffer:
     """ Data collector for the copy_from command.
     """
index 6d3d11c163eed81995b8c2c7c71f7870de5395ec..a887ae286834e6005a97ddd53da467b4af54f1ff 100644 (file)
@@ -4,6 +4,7 @@ libICU instead of the PostgreSQL module.
 """
 from collections import Counter
 import itertools
+import json
 import logging
 import re
 from textwrap import dedent
@@ -74,13 +75,10 @@ class LegacyICUTokenizer:
             self.max_word_frequency = get_property(conn, DBCFG_MAXWORDFREQ)
 
 
-    def finalize_import(self, config):
+    def finalize_import(self, _):
         """ Do any required postprocessing to make the tokenizer data ready
             for use.
         """
-        with connect(self.dsn) as conn:
-            sqlp = SQLPreprocessor(conn, config)
-            sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_indices.sql')
 
 
     def update_sql_functions(self, config):
@@ -121,18 +119,17 @@ class LegacyICUTokenizer:
         """
         return LegacyICUNameAnalyzer(self.dsn, ICUNameProcessor(self.naming_rules))
 
-    # pylint: disable=missing-format-attribute
+
     def _install_php(self, phpdir):
         """ Install the php script for the tokenizer.
         """
         php_file = self.data_dir / "tokenizer.php"
-        php_file.write_text(dedent("""\
+        php_file.write_text(dedent(f"""\
             <?php
-            @define('CONST_Max_Word_Frequency', {0.max_word_frequency});
-            @define('CONST_Term_Normalization_Rules', "{0.term_normalization}");
-            @define('CONST_Transliteration', "{0.naming_rules.search_rules}");
-            require_once('{1}/tokenizer/legacy_icu_tokenizer.php');
-            """.format(self, phpdir)))
+            @define('CONST_Max_Word_Frequency', {self.max_word_frequency});
+            @define('CONST_Term_Normalization_Rules', "{self.term_normalization}");
+            @define('CONST_Transliteration', "{self.naming_rules.search_rules}");
+            require_once('{phpdir}/tokenizer/legacy_icu_tokenizer.php');"""))
 
 
     def _save_config(self, config):
@@ -152,40 +149,48 @@ class LegacyICUTokenizer:
         """
         with connect(self.dsn) as conn:
             sqlp = SQLPreprocessor(conn, config)
-            sqlp.run_sql_file(conn, 'tokenizer/legacy_tokenizer_tables.sql')
+            sqlp.run_sql_file(conn, 'tokenizer/icu_tokenizer_tables.sql')
             conn.commit()
 
             LOG.warning("Precomputing word tokens")
 
             # get partial words and their frequencies
-            words = Counter()
-            name_proc = ICUNameProcessor(self.naming_rules)
-            with conn.cursor(name="words") as cur:
-                cur.execute(""" SELECT v, count(*) FROM
-                                  (SELECT svals(name) as v FROM place)x
-                                WHERE length(v) < 75 GROUP BY v""")
-
-                for name, cnt in cur:
-                    terms = set()
-                    for word in name_proc.get_variants_ascii(name_proc.get_normalized(name)):
-                        if ' ' in word:
-                            terms.update(word.split())
-                    for term in terms:
-                        words[term] += cnt
+            words = self._count_partial_terms(conn)
 
             # copy them back into the word table
             with CopyBuffer() as copystr:
-                for args in words.items():
-                    copystr.add(*args)
+                for term, cnt in words.items():
+                    copystr.add('w', term, json.dumps({'count': cnt}))
 
                 with conn.cursor() as cur:
                     copystr.copy_out(cur, 'word',
-                                     columns=['word_token', 'search_name_count'])
+                                     columns=['type', 'word_token', 'info'])
                     cur.execute("""UPDATE word SET word_id = nextval('seq_word')
-                                   WHERE word_id is null""")
+                                   WHERE word_id is null and type = 'w'""")
 
             conn.commit()
 
+    def _count_partial_terms(self, conn):
+        """ Count the partial terms from the names in the place table.
+        """
+        words = Counter()
+        name_proc = ICUNameProcessor(self.naming_rules)
+
+        with conn.cursor(name="words") as cur:
+            cur.execute(""" SELECT v, count(*) FROM
+                              (SELECT svals(name) as v FROM place)x
+                            WHERE length(v) < 75 GROUP BY v""")
+
+            for name, cnt in cur:
+                terms = set()
+                for word in name_proc.get_variants_ascii(name_proc.get_normalized(name)):
+                    if ' ' in word:
+                        terms.update(word.split())
+                for term in terms:
+                    words[term] += cnt
+
+        return words
+
 
 class LegacyICUNameAnalyzer:
     """ The legacy analyzer uses the ICU library for splitting names.
@@ -229,22 +234,26 @@ class LegacyICUNameAnalyzer:
             The function is used for testing and debugging only
             and not necessarily efficient.
         """
-        tokens = {}
+        full_tokens = {}
+        partial_tokens = {}
         for word in words:
             if word.startswith('#'):
-                tokens[word] = ' ' + self.name_processor.get_search_normalized(word[1:])
+                full_tokens[word] = self.name_processor.get_search_normalized(word[1:])
             else:
-                tokens[word] = self.name_processor.get_search_normalized(word)
+                partial_tokens[word] = self.name_processor.get_search_normalized(word)
 
         with self.conn.cursor() as cur:
             cur.execute("""SELECT word_token, word_id
-                           FROM word, (SELECT unnest(%s::TEXT[]) as term) t
-                           WHERE word_token = t.term
-                                 and class is null and country_code is null""",
-                        (list(tokens.values()), ))
-            ids = {r[0]: r[1] for r in cur}
+                            FROM word WHERE word_token = ANY(%s) and type = 'W'
+                        """, (list(full_tokens.values()),))
+            full_ids = {r[0]: r[1] for r in cur}
+            cur.execute("""SELECT word_token, word_id
+                            FROM word WHERE word_token = ANY(%s) and type = 'w'""",
+                        (list(partial_tokens.values()),))
+            part_ids = {r[0]: r[1] for r in cur}
 
-        return [(k, v, ids.get(v, None)) for k, v in tokens.items()]
+        return [(k, v, full_ids.get(v, None)) for k, v in full_tokens.items()] \
+               + [(k, v, part_ids.get(v, None)) for k, v in partial_tokens.items()]
 
 
     @staticmethod
@@ -276,8 +285,7 @@ class LegacyICUNameAnalyzer:
                             (SELECT pc, word FROM
                               (SELECT distinct(postcode) as pc FROM location_postcode) p
                               FULL JOIN
-                              (SELECT word FROM word
-                                WHERE class ='place' and type = 'postcode') w
+                              (SELECT word FROM word WHERE type = 'P') w
                               ON pc = word) x
                            WHERE pc is null or word is null""")
 
@@ -286,24 +294,23 @@ class LegacyICUNameAnalyzer:
                     if postcode is None:
                         to_delete.append(word)
                     else:
-                        copystr.add(
-                            postcode,
-                            ' ' + self.name_processor.get_search_normalized(postcode),
-                            'place', 'postcode', 0)
+                        copystr.add(self.name_processor.get_search_normalized(postcode),
+                                    'P', postcode)
 
                 if to_delete:
                     cur.execute("""DELETE FROM WORD
-                                   WHERE class ='place' and type = 'postcode'
-                                         and word = any(%s)
+                                   WHERE type ='P' and word = any(%s)
                                 """, (to_delete, ))
 
                 copystr.copy_out(cur, 'word',
-                                 columns=['word', 'word_token', 'class', 'type',
-                                          'search_name_count'])
+                                 columns=['word_token', 'type', 'word'])
 
 
     def update_special_phrases(self, phrases, should_replace):
         """ Replace the search index for special phrases with the new phrases.
+            If `should_replace` is True, then the previous set of will be
+            completely replaced. Otherwise the phrases are added to the
+            already existing ones.
         """
         norm_phrases = set(((self.name_processor.get_normalized(p[0]), p[1], p[2], p[3])
                             for p in phrases))
@@ -311,11 +318,10 @@ class LegacyICUNameAnalyzer:
         with self.conn.cursor() as cur:
             # Get the old phrases.
             existing_phrases = set()
-            cur.execute("""SELECT word, class, type, operator FROM word
-                           WHERE class != 'place'
-                                 OR (type != 'house' AND type != 'postcode')""")
-            for label, cls, typ, oper in cur:
-                existing_phrases.add((label, cls, typ, oper or '-'))
+            cur.execute("SELECT word, info FROM word WHERE type = 'S'")
+            for word, info in cur:
+                existing_phrases.add((word, info['class'], info['type'],
+                                      info.get('op') or '-'))
 
             added = self._add_special_phrases(cur, norm_phrases, existing_phrases)
             if should_replace:
@@ -338,13 +344,13 @@ class LegacyICUNameAnalyzer:
             for word, cls, typ, oper in to_add:
                 term = self.name_processor.get_search_normalized(word)
                 if term:
-                    copystr.add(word, ' ' + term, cls, typ,
-                                oper if oper in ('in', 'near') else None, 0)
+                    copystr.add(term, 'S', word,
+                                json.dumps({'class': cls, 'type': typ,
+                                            'op': oper if oper in ('in', 'near') else None}))
                     added += 1
 
             copystr.copy_out(cursor, 'word',
-                             columns=['word', 'word_token', 'class', 'type',
-                                      'operator', 'search_name_count'])
+                             columns=['word_token', 'type', 'word', 'info'])
 
         return added
 
@@ -359,9 +365,10 @@ class LegacyICUNameAnalyzer:
         if to_delete:
             cursor.execute_values(
                 """ DELETE FROM word USING (VALUES %s) as v(name, in_class, in_type, op)
-                    WHERE word = name and class = in_class and type = in_type
-                          and ((op = '-' and operator is null) or op = operator)""",
-                to_delete)
+                    WHERE type = 'S' and word = name
+                          and info->>'class' = in_class and info->>'type' = in_type
+                          and ((op = '-' and info->>'op' is null) or op = info->>'op')
+                """, to_delete)
 
         return len(to_delete)
 
@@ -371,22 +378,28 @@ class LegacyICUNameAnalyzer:
         """
         word_tokens = set()
         for name in self._compute_full_names(names):
-            if name:
-                word_tokens.add(' ' + self.name_processor.get_search_normalized(name))
+            norm_name = self.name_processor.get_search_normalized(name)
+            if norm_name:
+                word_tokens.add(norm_name)
 
         with self.conn.cursor() as cur:
             # Get existing names
-            cur.execute("SELECT word_token FROM word WHERE country_code = %s",
+            cur.execute("""SELECT word_token FROM word
+                            WHERE type = 'C' and word = %s""",
                         (country_code, ))
             word_tokens.difference_update((t[0] for t in cur))
 
+            # Only add those names that are not yet in the list.
             if word_tokens:
-                cur.execute("""INSERT INTO word (word_id, word_token, country_code,
-                                                 search_name_count)
-                               (SELECT nextval('seq_word'), token, %s, 0
+                cur.execute("""INSERT INTO word (word_token, type, word)
+                               (SELECT token, 'C', %s
                                 FROM unnest(%s) as token)
                             """, (country_code, list(word_tokens)))
 
+            # No names are deleted at the moment.
+            # If deletion is made possible, then the static names from the
+            # initial 'country_name' table should be kept.
+
 
     def process_place(self, place):
         """ Determine tokenizer information about the given place.
@@ -497,14 +510,12 @@ class LegacyICUNameAnalyzer:
 
                 with self.conn.cursor() as cur:
                     # no word_id needed for postcodes
-                    cur.execute("""INSERT INTO word (word, word_token, class, type,
-                                                     search_name_count)
-                                   (SELECT pc, %s, 'place', 'postcode', 0
-                                    FROM (VALUES (%s)) as v(pc)
+                    cur.execute("""INSERT INTO word (word_token, type, word)
+                                   (SELECT %s, 'P', pc FROM (VALUES (%s)) as v(pc)
                                     WHERE NOT EXISTS
                                      (SELECT * FROM word
-                                      WHERE word = pc and class='place' and type='postcode'))
-                                """, (' ' + term, postcode))
+                                      WHERE type = 'P' and word = pc))
+                                """, (term, postcode))
                 self._cache.postcodes.add(postcode)
 
 
@@ -595,7 +606,8 @@ class _TokenCache:
 
     def get_hnr_tokens(self, conn, terms):
         """ Get token ids for a list of housenumbers, looking them up in the
-            database if necessary.
+            database if necessary. `terms` is an iterable of normalized
+            housenumbers.
         """
         tokens = []
         askdb = []
diff --git a/nominatim/tools/add_osm_data.py b/nominatim/tools/add_osm_data.py
new file mode 100644 (file)
index 0000000..fa35667
--- /dev/null
@@ -0,0 +1,46 @@
+"""
+Function to add additional OSM data from a file or the API into the database.
+"""
+from pathlib import Path
+import logging
+import urllib
+
+from nominatim.tools.exec_utils import run_osm2pgsql, get_url
+
+LOG = logging.getLogger()
+
+def add_data_from_file(fname, options):
+    """ Adds data from a OSM file to the database. The file may be a normal
+        OSM file or a diff file in all formats supported by libosmium.
+    """
+    options['import_file'] = Path(fname)
+    options['append'] = True
+    run_osm2pgsql(options)
+
+    # No status update. We don't know where the file came from.
+    return 0
+
+
+def add_osm_object(osm_type, osm_id, use_main_api, options):
+    """ Add or update a single OSM object from the latest version of the
+        API.
+    """
+    if use_main_api:
+        base_url = f'https://www.openstreetmap.org/api/0.6/{osm_type}/{osm_id}'
+        if osm_type in ('way', 'relation'):
+            base_url += '/full'
+    else:
+        # use Overpass API
+        if osm_type == 'node':
+            data = f'node({osm_id});out meta;'
+        elif osm_type == 'way':
+            data = f'(way({osm_id});>;);out meta;'
+        else:
+            data = f'(rel(id:{osm_id});>;);out meta;'
+        base_url = 'https://overpass-api.de/api/interpreter?' \
+                   + urllib.parse.urlencode({'data': data})
+
+    options['append'] = True
+    options['import_data'] = get_url(base_url).encode('utf-8')
+
+    run_osm2pgsql(options)
index 72d252b7a83abdeb434912729bd595cfb10b3f7a..6177b15f000e4e429c9d8e4f27384d546abd49c1 100644 (file)
@@ -128,9 +128,14 @@ def run_osm2pgsql(options):
     if options.get('disable_jit', False):
         env['PGOPTIONS'] = '-c jit=off -c max_parallel_workers_per_gather=0'
 
-    cmd.append(str(options['import_file']))
+    if 'import_data' in options:
+        cmd.extend(('-r', 'xml', '-'))
+    else:
+        cmd.append(str(options['import_file']))
 
-    subprocess.run(cmd, cwd=options.get('cwd', '.'), env=env, check=True)
+    subprocess.run(cmd, cwd=options.get('cwd', '.'),
+                   input=options.get('import_data'),
+                   env=env, check=True)
 
 
 def get_url(url):
index 6f9005eaf868d8dabce8f26ade77ec28612d63e4..025600f7956069e58da3a2e08d0d654d8e8a301e 100644 (file)
@@ -12,5 +12,5 @@ Version information for Nominatim.
 # Released versions always have a database patch level of 0.
 NOMINATIM_VERSION = (3, 7, 0, 2)
 
-POSTGRESQL_REQUIRED_VERSION = (9, 3)
+POSTGRESQL_REQUIRED_VERSION = (9, 5)
 POSTGIS_REQUIRED_VERSION = (2, 2)
index 6102e99ba1b00925bbd754b700c9e80344736d9f..4c839db00143e004b28f5d98dd6890102475e5e0 100644 (file)
@@ -134,9 +134,7 @@ Feature: Import of postcodes
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | de      | 01982    | country:de |
-        And word contains
-           | word  | class | type |
-           | 01982 | place | postcode |
+        And there are word tokens for postcodes 01982
 
     Scenario: Different postcodes with the same normalization can both be found
         Given the places
index 94550ffd6b3f764a0687a8ae628eb154feacdb75..c2fb30ceb1b1ff479235a4041e4f4187f6e65d1f 100644 (file)
@@ -18,10 +18,7 @@ Feature: Update of postcode
            | country | postcode | geometry |
            | de      | 01982    | country:de |
            | ch      | 4567     | country:ch |
-        And word contains
-           | word  | class | type |
-           | 01982 | place | postcode |
-           | 4567  | place | postcode |
+        And there are word tokens for postcodes 01982,4567
 
      Scenario: When the last postcode is deleted, it is deleted from postcode and word
         Given the places
@@ -34,12 +31,8 @@ Feature: Update of postcode
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | ch      | 4567     | country:ch |
-        And word contains not
-           | word  | class | type |
-           | 01982 | place | postcode |
-        And word contains
-           | word  | class | type |
-           | 4567  | place | postcode |
+        And there are word tokens for postcodes 4567
+        And there are no word tokens for postcodes 01982
 
      Scenario: A postcode is not deleted from postcode and word when it exist in another country
         Given the places
@@ -52,9 +45,7 @@ Feature: Update of postcode
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | ch      | 01982    | country:ch |
-        And word contains
-           | word  | class | type |
-           | 01982 | place | postcode |
+        And there are word tokens for postcodes 01982
 
      Scenario: Updating a postcode is reflected in postcode table
         Given the places
@@ -68,9 +59,7 @@ Feature: Update of postcode
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | de      | 20453    | country:de |
-        And word contains
-           | word  | class | type |
-           | 20453 | place | postcode |
+        And there are word tokens for postcodes 20453
 
      Scenario: When changing from a postcode type, the entry appears in placex
         When importing
@@ -91,9 +80,7 @@ Feature: Update of postcode
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | de      | 20453    | country:de |
-        And word contains
-           | word  | class | type |
-           | 20453 | place | postcode |
+        And there are word tokens for postcodes 20453
 
      Scenario: When changing to a postcode type, the entry disappears from placex
         When importing
@@ -114,6 +101,4 @@ Feature: Update of postcode
         Then location_postcode contains exactly
            | country | postcode | geometry |
            | de      | 01982    | country:de |
-        And word contains
-           | word  | class | type |
-           | 01982 | place | postcode |
+        And there are word tokens for postcodes 01982
index b4f0d8532ac39984746aae2911e66ab8564232fd..ac61fc67356aa8ab04274fe69bf9b28f0eddeffd 100644 (file)
@@ -266,20 +266,36 @@ def check_location_postcode(context):
 
             db_row.assert_row(row, ('country', 'postcode'))
 
-@then("word contains(?P<exclude> not)?")
-def check_word_table(context, exclude):
-    """ Check the contents of the word table. Each row represents a table row
-        and all data must match. Data not present in the expected table, may
-        be arbitry. The rows are identified via all given columns.
+@then("there are(?P<exclude> no)? word tokens for postcodes (?P<postcodes>.*)")
+def check_word_table_for_postcodes(context, exclude, postcodes):
+    """ Check that the tokenizer produces postcode tokens for the given
+        postcodes. The postcodes are a comma-separated list of postcodes.
+        Whitespace matters.
     """
+    nctx = context.nominatim
+    tokenizer = tokenizer_factory.get_tokenizer_for_db(nctx.get_test_config())
+    with tokenizer.name_analyzer() as ana:
+        plist = [ana.normalize_postcode(p) for p in postcodes.split(',')]
+
+    plist.sort()
+
     with context.db.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
-        for row in context.table:
-            wheres = ' AND '.join(["{} = %s".format(h) for h in row.headings])
-            cur.execute("SELECT * from word WHERE " + wheres, list(row.cells))
-            if exclude:
-                assert cur.rowcount == 0, "Row still in word table: %s" % '/'.join(values)
-            else:
-                assert cur.rowcount > 0, "Row not in word table: %s" % '/'.join(values)
+        if nctx.tokenizer == 'legacy_icu':
+            cur.execute("SELECT word FROM word WHERE type = 'P' and word = any(%s)",
+                        (plist,))
+        else:
+            cur.execute("""SELECT word FROM word WHERE word = any(%s)
+                             and class = 'place' and type = 'postcode'""",
+                        (plist,))
+
+        found = [row[0] for row in cur]
+        assert len(found) == len(set(found)), f"Duplicate rows for postcodes: {found}"
+
+    if exclude:
+        assert len(found) == 0, f"Unexpected postcodes: {found}"
+    else:
+        assert set(found) == set(plist), \
+        f"Missing postcodes {set(plist) - set(found)}. Found: {found}"
 
 @then("place_addressline contains")
 def check_place_addressline(context):
diff --git a/test/python/mock_icu_word_table.py b/test/python/mock_icu_word_table.py
new file mode 100644 (file)
index 0000000..cde5e77
--- /dev/null
@@ -0,0 +1,84 @@
+"""
+Legacy word table for testing with functions to prefil and test contents
+of the table.
+"""
+
+class MockIcuWordTable:
+    """ A word table for testing using legacy word table structure.
+    """
+    def __init__(self, conn):
+        self.conn = conn
+        with conn.cursor() as cur:
+            cur.execute("""CREATE TABLE word (word_id INTEGER,
+                                              word_token text NOT NULL,
+                                              type text NOT NULL,
+                                              word text,
+                                              info jsonb)""")
+
+        conn.commit()
+
+    def add_special(self, word_token, word, cls, typ, oper):
+        with self.conn.cursor() as cur:
+            cur.execute("""INSERT INTO word (word_token, type, word, info)
+                              VALUES (%s, 'S', %s,
+                                      json_build_object('class', %s,
+                                                        'type', %s,
+                                                        'op', %s))
+                        """, (word_token, word, cls, typ, oper))
+        self.conn.commit()
+
+
+    def add_country(self, country_code, word_token):
+        with self.conn.cursor() as cur:
+            cur.execute("""INSERT INTO word (word_token, type, word)
+                           VALUES(%s, 'C', %s)""",
+                        (word_token, country_code))
+        self.conn.commit()
+
+
+    def add_postcode(self, word_token, postcode):
+        with self.conn.cursor() as cur:
+            cur.execute("""INSERT INTO word (word_token, type, word)
+                              VALUES (%s, 'P', %s)
+                        """, (word_token, postcode))
+        self.conn.commit()
+
+
+    def count(self):
+        with self.conn.cursor() as cur:
+            return cur.scalar("SELECT count(*) FROM word")
+
+
+    def count_special(self):
+        with self.conn.cursor() as cur:
+            return cur.scalar("SELECT count(*) FROM word WHERE type = 'S'")
+
+
+    def get_special(self):
+        with self.conn.cursor() as cur:
+            cur.execute("SELECT word_token, info, word FROM word WHERE type = 'S'")
+            result = set(((row[0], row[2], row[1]['class'],
+                           row[1]['type'], row[1]['op']) for row in cur))
+            assert len(result) == cur.rowcount, "Word table has duplicates."
+            return result
+
+
+    def get_country(self):
+        with self.conn.cursor() as cur:
+            cur.execute("SELECT word, word_token FROM word WHERE type = 'C'")
+            result = set((tuple(row) for row in cur))
+            assert len(result) == cur.rowcount, "Word table has duplicates."
+            return result
+
+
+    def get_postcodes(self):
+        with self.conn.cursor() as cur:
+            cur.execute("SELECT word FROM word WHERE type = 'P'")
+            return set((row[0] for row in cur))
+
+
+    def get_partial_words(self):
+        with self.conn.cursor() as cur:
+            cur.execute("SELECT word_token, info FROM word WHERE type ='w'")
+            return set(((row[0], row[1]['count']) for row in cur))
+
diff --git a/test/python/mock_legacy_word_table.py b/test/python/mock_legacy_word_table.py
new file mode 100644 (file)
index 0000000..8baf3ad
--- /dev/null
@@ -0,0 +1,86 @@
+"""
+Legacy word table for testing with functions to prefil and test contents
+of the table.
+"""
+
+class MockLegacyWordTable:
+    """ A word table for testing using legacy word table structure.
+    """
+    def __init__(self, conn):
+        self.conn = conn
+        with conn.cursor() as cur:
+            cur.execute("""CREATE TABLE word (word_id INTEGER,
+                                              word_token text,
+                                              word text,
+                                              class text,
+                                              type text,
+                                              country_code varchar(2),
+                                              search_name_count INTEGER,
+                                              operator TEXT)""")
+
+        conn.commit()
+
+    def add_special(self, word_token, word, cls, typ, oper):
+        with self.conn.cursor() as cur:
+            cur.execute("""INSERT INTO word (word_token, word, class, type, operator)
+                              VALUES (%s, %s, %s, %s, %s)
+                        """, (word_token, word, cls, typ, oper))
+        self.conn.commit()
+
+
+    def add_country(self, country_code, word_token):
+        with self.conn.cursor() as cur:
+            cur.execute("INSERT INTO word (word_token, country_code) VALUES(%s, %s)",
+                        (word_token, country_code))
+        self.conn.commit()
+
+
+    def add_postcode(self, word_token, postcode):
+        with self.conn.cursor() as cur:
+            cur.execute("""INSERT INTO word (word_token, word, class, type)
+                              VALUES (%s, %s, 'place', 'postcode')
+                        """, (word_token, postcode))
+        self.conn.commit()
+
+
+    def count(self):
+        with self.conn.cursor() as cur:
+            return cur.scalar("SELECT count(*) FROM word")
+
+
+    def count_special(self):
+        with self.conn.cursor() as cur:
+            return cur.scalar("SELECT count(*) FROM word WHERE class != 'place'")
+
+
+    def get_special(self):
+        with self.conn.cursor() as cur:
+            cur.execute("""SELECT word_token, word, class, type, operator
+                           FROM word WHERE class != 'place'""")
+            result = set((tuple(row) for row in cur))
+            assert len(result) == cur.rowcount, "Word table has duplicates."
+            return result
+
+
+    def get_country(self):
+        with self.conn.cursor() as cur:
+            cur.execute("""SELECT country_code, word_token
+                           FROM word WHERE country_code is not null""")
+            result = set((tuple(row) for row in cur))
+            assert len(result) == cur.rowcount, "Word table has duplicates."
+            return result
+
+
+    def get_postcodes(self):
+        with self.conn.cursor() as cur:
+            cur.execute("""SELECT word FROM word
+                           WHERE class = 'place' and type = 'postcode'""")
+            return set((row[0] for row in cur))
+
+    def get_partial_words(self):
+        with self.conn.cursor() as cur:
+            cur.execute("""SELECT word_token, search_name_count FROM word
+                           WHERE class is null and country_code is null
+                                 and not word_token like ' %'""")
+            return set((tuple(row) for row in cur))
+
index f9faaa93c42e06fac7fdb11fe3fb6abeb132bd5e..7f7aaafc45d49364fba3917da897793c7f607428 100644 (file)
@@ -7,6 +7,9 @@ import psycopg2.extras
 
 from nominatim.db import properties
 
+# This must always point to the mock word table for the default tokenizer.
+from mock_legacy_word_table import MockLegacyWordTable as MockWordTable
+
 class MockParamCapture:
     """ Mock that records the parameters with which a function was called
         as well as the number of calls.
@@ -24,88 +27,6 @@ class MockParamCapture:
         return self.return_value
 
 
-class MockWordTable:
-    """ A word table for testing.
-    """
-    def __init__(self, conn):
-        self.conn = conn
-        with conn.cursor() as cur:
-            cur.execute("""CREATE TABLE word (word_id INTEGER,
-                                              word_token text,
-                                              word text,
-                                              class text,
-                                              type text,
-                                              country_code varchar(2),
-                                              search_name_count INTEGER,
-                                              operator TEXT)""")
-
-        conn.commit()
-
-    def add_special(self, word_token, word, cls, typ, oper):
-        with self.conn.cursor() as cur:
-            cur.execute("""INSERT INTO word (word_token, word, class, type, operator)
-                              VALUES (%s, %s, %s, %s, %s)
-                        """, (word_token, word, cls, typ, oper))
-        self.conn.commit()
-
-
-    def add_country(self, country_code, word_token):
-        with self.conn.cursor() as cur:
-            cur.execute("INSERT INTO word (word_token, country_code) VALUES(%s, %s)",
-                        (word_token, country_code))
-        self.conn.commit()
-
-
-    def add_postcode(self, word_token, postcode):
-        with self.conn.cursor() as cur:
-            cur.execute("""INSERT INTO word (word_token, word, class, type)
-                              VALUES (%s, %s, 'place', 'postcode')
-                        """, (word_token, postcode))
-        self.conn.commit()
-
-
-    def count(self):
-        with self.conn.cursor() as cur:
-            return cur.scalar("SELECT count(*) FROM word")
-
-
-    def count_special(self):
-        with self.conn.cursor() as cur:
-            return cur.scalar("SELECT count(*) FROM word WHERE class != 'place'")
-
-
-    def get_special(self):
-        with self.conn.cursor() as cur:
-            cur.execute("""SELECT word_token, word, class, type, operator
-                           FROM word WHERE class != 'place'""")
-            result = set((tuple(row) for row in cur))
-            assert len(result) == cur.rowcount, "Word table has duplicates."
-            return result
-
-
-    def get_country(self):
-        with self.conn.cursor() as cur:
-            cur.execute("""SELECT country_code, word_token
-                           FROM word WHERE country_code is not null""")
-            result = set((tuple(row) for row in cur))
-            assert len(result) == cur.rowcount, "Word table has duplicates."
-            return result
-
-
-    def get_postcodes(self):
-        with self.conn.cursor() as cur:
-            cur.execute("""SELECT word FROM word
-                           WHERE class = 'place' and type = 'postcode'""")
-            return set((row[0] for row in cur))
-
-    def get_partial_words(self):
-        with self.conn.cursor() as cur:
-            cur.execute("""SELECT word_token, search_name_count FROM word
-                           WHERE class is null and country_code is null
-                                 and not word_token like ' %'""")
-            return set((tuple(row) for row in cur))
-
-
 class MockPlacexTable:
     """ A placex table for testing.
     """
index d9e0104014eb74c529cfa6d198847ce415460fa6..bd5182e300c13868feafc404d2ec676293de6d05 100644 (file)
@@ -15,6 +15,7 @@ import nominatim.clicmd.admin
 import nominatim.clicmd.setup
 import nominatim.indexer.indexer
 import nominatim.tools.admin
+import nominatim.tools.add_osm_data
 import nominatim.tools.check_database
 import nominatim.tools.database_import
 import nominatim.tools.freeze
@@ -60,7 +61,6 @@ class TestCli:
 
 
     @pytest.mark.parametrize("command,script", [
-                             (('add-data', '--file', 'foo.osm'), 'update'),
                              (('export',), 'export')
                              ])
     def test_legacy_commands_simple(self, mock_run_legacy, command, script):
@@ -88,13 +88,20 @@ class TestCli:
         assert mock.called == 1
 
 
-    @pytest.mark.parametrize("name,oid", [('file', 'foo.osm'), ('diff', 'foo.osc'),
-                                          ('node', 12), ('way', 8), ('relation', 32)])
-    def test_add_data_command(self, mock_run_legacy, name, oid):
+    @pytest.mark.parametrize("name,oid", [('file', 'foo.osm'), ('diff', 'foo.osc')])
+    def test_add_data_file_command(self, mock_func_factory, name, oid):
+        mock_run_legacy = mock_func_factory(nominatim.tools.add_osm_data, 'add_data_from_file')
+        assert self.call_nominatim('add-data', '--' + name, str(oid)) == 0
+
+        assert mock_run_legacy.called == 1
+
+
+    @pytest.mark.parametrize("name,oid", [('node', 12), ('way', 8), ('relation', 32)])
+    def test_add_data_object_command(self, mock_func_factory, name, oid):
+        mock_run_legacy = mock_func_factory(nominatim.tools.add_osm_data, 'add_osm_object')
         assert self.call_nominatim('add-data', '--' + name, str(oid)) == 0
 
         assert mock_run_legacy.called == 1
-        assert mock_run_legacy.last_args == ('update.php', '--import-' + name, oid)
 
 
     def test_serve_command(self, mock_func_factory):
index 545cc58f633448096fbd2f212a19e69160ae01ff..9eea7ed101eb1421ad67e69124634d87076f1ab2 100644 (file)
@@ -1,6 +1,8 @@
 """
 Tests for DB utility functions in db.utils
 """
+import json
+
 import pytest
 
 import nominatim.db.utils as db_utils
@@ -115,3 +117,38 @@ class TestCopyBuffer:
 
 
 
+class TestCopyBufferJson:
+    TABLE_NAME = 'copytable'
+
+    @pytest.fixture(autouse=True)
+    def setup_test_table(self, table_factory):
+        table_factory(self.TABLE_NAME, 'colA INT, colB JSONB')
+
+
+    def table_rows(self, cursor):
+        cursor.execute('SELECT * FROM ' + self.TABLE_NAME)
+        results = {k: v for k,v in cursor}
+
+        assert len(results) == cursor.rowcount
+
+        return results
+
+
+    def test_json_object(self, temp_db_cursor):
+        with db_utils.CopyBuffer() as buf:
+            buf.add(1, json.dumps({'test': 'value', 'number': 1}))
+
+            buf.copy_out(temp_db_cursor, self.TABLE_NAME)
+
+        assert self.table_rows(temp_db_cursor) == \
+                   {1: {'test': 'value', 'number': 1}}
+
+
+    def test_json_object_special_chras(self, temp_db_cursor):
+        with db_utils.CopyBuffer() as buf:
+            buf.add(1, json.dumps({'te\tst': 'va\nlue', 'nu"mber': None}))
+
+            buf.copy_out(temp_db_cursor, self.TABLE_NAME)
+
+        assert self.table_rows(temp_db_cursor) == \
+                   {1: {'te\tst': 'va\nlue', 'nu"mber': None}}
index 39fc9fb4c5a7f348c29ffe8c3b490caf458063f4..ed489662550b8f100308612311f1d6ea51a02d40 100644 (file)
@@ -11,6 +11,12 @@ from nominatim.tokenizer.icu_name_processor import ICUNameProcessorRules
 from nominatim.tokenizer.icu_rule_loader import ICURuleLoader
 from nominatim.db import properties
 
+from mock_icu_word_table import MockIcuWordTable
+
+@pytest.fixture
+def word_table(temp_db_conn):
+    return MockIcuWordTable(temp_db_conn)
+
 
 @pytest.fixture
 def test_config(def_config, tmp_path):
@@ -21,8 +27,8 @@ def test_config(def_config, tmp_path):
     sqldir.mkdir()
     (sqldir / 'tokenizer').mkdir()
     (sqldir / 'tokenizer' / 'legacy_icu_tokenizer.sql').write_text("SELECT 'a'")
-    shutil.copy(str(def_config.lib_dir.sql / 'tokenizer' / 'legacy_tokenizer_tables.sql'),
-                str(sqldir / 'tokenizer' / 'legacy_tokenizer_tables.sql'))
+    shutil.copy(str(def_config.lib_dir.sql / 'tokenizer' / 'icu_tokenizer_tables.sql'),
+                str(sqldir / 'tokenizer' / 'icu_tokenizer_tables.sql'))
 
     def_config.lib_dir.sql = sqldir
 
@@ -88,12 +94,14 @@ DECLARE
   term_count INTEGER;
 BEGIN
   SELECT min(word_id) INTO full_token
-    FROM word WHERE word = norm_term and class is null and country_code is null;
+    FROM word WHERE info->>'word' = norm_term and type = 'W';
 
   IF full_token IS NULL THEN
     full_token := nextval('seq_word');
-    INSERT INTO word (word_id, word_token, word, search_name_count)
-      SELECT full_token, ' ' || lookup_term, norm_term, 0 FROM unnest(lookup_terms) as lookup_term;
+    INSERT INTO word (word_id, word_token, type, info)
+      SELECT full_token, lookup_term, 'W',
+             json_build_object('word', norm_term, 'count', 0)
+        FROM unnest(lookup_terms) as lookup_term;
   END IF;
 
   FOR term IN SELECT unnest(string_to_array(unnest(lookup_terms), ' ')) LOOP
@@ -105,18 +113,18 @@ BEGIN
 
   partial_tokens := '{}'::INT[];
   FOR term IN SELECT unnest(partial_terms) LOOP
-    SELECT min(word_id), max(search_name_count) INTO term_id, term_count
-      FROM word WHERE word_token = term and class is null and country_code is null;
+    SELECT min(word_id), max(info->>'count') INTO term_id, term_count
+      FROM word WHERE word_token = term and type = 'w';
 
     IF term_id IS NULL THEN
       term_id := nextval('seq_word');
       term_count := 0;
-      INSERT INTO word (word_id, word_token, search_name_count)
-        VALUES (term_id, term, 0);
+      INSERT INTO word (word_id, word_token, type, info)
+        VALUES (term_id, term, 'w', json_build_object('count', term_count));
     END IF;
 
     IF NOT (ARRAY[term_id] <@ partial_tokens) THEN
-        partial_tokens := partial_tokens || term_id;
+      partial_tokens := partial_tokens || term_id;
     END IF;
   END LOOP;
 END;
@@ -232,14 +240,14 @@ def test_update_special_phrase_empty_table(analyzer, word_table):
         ], True)
 
     assert word_table.get_special() \
-               == {(' KÖNIG BEI', 'König bei', 'amenity', 'royal', 'near'),
-                   (' KÖNIGE', 'Könige', 'amenity', 'royal', None),
-                   (' STREET', 'street', 'highway', 'primary', 'in')}
+               == {('KÖNIG BEI', 'König bei', 'amenity', 'royal', 'near'),
+                   ('KÖNIGE', 'Könige', 'amenity', 'royal', None),
+                   ('STREET', 'street', 'highway', 'primary', 'in')}
 
 
 def test_update_special_phrase_delete_all(analyzer, word_table):
-    word_table.add_special(' FOO', 'foo', 'amenity', 'prison', 'in')
-    word_table.add_special(' BAR', 'bar', 'highway', 'road', None)
+    word_table.add_special('FOO', 'foo', 'amenity', 'prison', 'in')
+    word_table.add_special('BAR', 'bar', 'highway', 'road', None)
 
     assert word_table.count_special() == 2
 
@@ -250,8 +258,8 @@ def test_update_special_phrase_delete_all(analyzer, word_table):
 
 
 def test_update_special_phrases_no_replace(analyzer, word_table):
-    word_table.add_special(' FOO', 'foo', 'amenity', 'prison', 'in')
-    word_table.add_special(' BAR', 'bar', 'highway', 'road', None)
+    word_table.add_special('FOO', 'foo', 'amenity', 'prison', 'in')
+    word_table.add_special('BAR', 'bar', 'highway', 'road', None)
 
     assert word_table.count_special() == 2
 
@@ -262,8 +270,8 @@ def test_update_special_phrases_no_replace(analyzer, word_table):
 
 
 def test_update_special_phrase_modify(analyzer, word_table):
-    word_table.add_special(' FOO', 'foo', 'amenity', 'prison', 'in')
-    word_table.add_special(' BAR', 'bar', 'highway', 'road', None)
+    word_table.add_special('FOO', 'foo', 'amenity', 'prison', 'in')
+    word_table.add_special('BAR', 'bar', 'highway', 'road', None)
 
     assert word_table.count_special() == 2
 
@@ -275,25 +283,25 @@ def test_update_special_phrase_modify(analyzer, word_table):
         ], True)
 
     assert word_table.get_special() \
-               == {(' PRISON', 'prison', 'amenity', 'prison', 'in'),
-                   (' BAR', 'bar', 'highway', 'road', None),
-                   (' GARDEN', 'garden', 'leisure', 'garden', 'near')}
+               == {('PRISON', 'prison', 'amenity', 'prison', 'in'),
+                   ('BAR', 'bar', 'highway', 'road', None),
+                   ('GARDEN', 'garden', 'leisure', 'garden', 'near')}
 
 
 def test_add_country_names_new(analyzer, word_table):
     with analyzer() as anl:
         anl.add_country_names('es', {'name': 'Espagña', 'name:en': 'Spain'})
 
-    assert word_table.get_country() == {('es', ' ESPAGÑA'), ('es', ' SPAIN')}
+    assert word_table.get_country() == {('es', 'ESPAGÑA'), ('es', 'SPAIN')}
 
 
 def test_add_country_names_extend(analyzer, word_table):
-    word_table.add_country('ch', ' SCHWEIZ')
+    word_table.add_country('ch', 'SCHWEIZ')
 
     with analyzer() as anl:
         anl.add_country_names('ch', {'name': 'Schweiz', 'name:fr': 'Suisse'})
 
-    assert word_table.get_country() == {('ch', ' SCHWEIZ'), ('ch', ' SUISSE')}
+    assert word_table.get_country() == {('ch', 'SCHWEIZ'), ('ch', 'SUISSE')}
 
 
 class TestPlaceNames:
@@ -307,6 +315,7 @@ class TestPlaceNames:
 
     def expect_name_terms(self, info, *expected_terms):
         tokens = self.analyzer.get_word_token_info(expected_terms)
+        print (tokens)
         for token in tokens:
             assert token[2] is not None, "No token for {0}".format(token)
 
@@ -316,7 +325,7 @@ class TestPlaceNames:
     def test_simple_names(self):
         info = self.analyzer.process_place({'name': {'name': 'Soft bAr', 'ref': '34'}})
 
-        self.expect_name_terms(info, '#Soft bAr', '#34','Soft', 'bAr', '34')
+        self.expect_name_terms(info, '#Soft bAr', '#34', 'Soft', 'bAr', '34')
 
 
     @pytest.mark.parametrize('sep', [',' , ';'])
@@ -339,7 +348,7 @@ class TestPlaceNames:
                                            'country_feature': 'no'})
 
         self.expect_name_terms(info, '#norge', 'norge')
-        assert word_table.get_country() == {('no', ' NORGE')}
+        assert word_table.get_country() == {('no', 'NORGE')}
 
 
 class TestPlaceAddress: