]> git.openstreetmap.org Git - nominatim.git/blob - nominatim/tools/special_phrases/sp_importer.py
Merge pull request #2424 from lonvia/multi-country-import
[nominatim.git] / nominatim / tools / special_phrases / sp_importer.py
1 """
2     Module containing the class handling the import
3     of the special phrases.
4
5     Phrases are analyzed and imported into the database.
6
7     The phrases already present in the database which are not
8     valids anymore are removed.
9 """
10 import logging
11 import os
12 from os.path import isfile
13 from pathlib import Path
14 import re
15 import subprocess
16 import json
17
18 from psycopg2.sql import Identifier, Literal, SQL
19 from nominatim.errors import UsageError
20 from nominatim.tools.special_phrases.importer_statistics import SpecialPhrasesImporterStatistics
21
22 LOG = logging.getLogger()
23
24 def _classtype_table(phrase_class, phrase_type):
25     """ Return the name of the table for the given class and type.
26     """
27     return f'place_classtype_{phrase_class}_{phrase_type}'
28
29 class SPImporter():
30     # pylint: disable-msg=too-many-instance-attributes
31     """
32         Class handling the process of special phrases importation into the database.
33
34         Take a sp loader which load the phrases from an external source.
35     """
36     def __init__(self, config, phplib_dir, db_connection, sp_loader) -> None:
37         self.config = config
38         self.phplib_dir = phplib_dir
39         self.db_connection = db_connection
40         self.sp_loader = sp_loader
41         self.statistics_handler = SpecialPhrasesImporterStatistics()
42         self.black_list, self.white_list = self._load_white_and_black_lists()
43         self.sanity_check_pattern = re.compile(r'^\w+$')
44         # This set will contain all existing phrases to be added.
45         # It contains tuples with the following format: (lable, class, type, operator)
46         self.word_phrases = set()
47         # This set will contain all existing place_classtype tables which doesn't match any
48         # special phrases class/type on the wiki.
49         self.table_phrases_to_delete = set()
50
51     def import_phrases(self, tokenizer, should_replace):
52         """
53             Iterate through all SpecialPhrases extracted from the
54             loader and import them into the database.
55
56             If should_replace is set to True only the loaded phrases
57             will be kept into the database. All other phrases already
58             in the database will be removed.
59         """
60         LOG.warning('Special phrases importation starting')
61         self._fetch_existing_place_classtype_tables()
62
63         # Store pairs of class/type for further processing
64         class_type_pairs = set()
65
66         for loaded_phrases in self.sp_loader:
67             for phrase in loaded_phrases:
68                 result = self._process_phrase(phrase)
69                 if result:
70                     class_type_pairs.add(result)
71
72         self._create_place_classtype_table_and_indexes(class_type_pairs)
73         if should_replace:
74             self._remove_non_existent_tables_from_db()
75         self.db_connection.commit()
76
77         with tokenizer.name_analyzer() as analyzer:
78             analyzer.update_special_phrases(self.word_phrases, should_replace)
79
80         LOG.warning('Import done.')
81         self.statistics_handler.notify_import_done()
82
83
84     def _fetch_existing_place_classtype_tables(self):
85         """
86             Fetch existing place_classtype tables.
87             Fill the table_phrases_to_delete set of the class.
88         """
89         query = """
90             SELECT table_name
91             FROM information_schema.tables
92             WHERE table_schema='public'
93             AND table_name like 'place_classtype_%';
94         """
95         with self.db_connection.cursor() as db_cursor:
96             db_cursor.execute(SQL(query))
97             for row in db_cursor:
98                 self.table_phrases_to_delete.add(row[0])
99
100     def _load_white_and_black_lists(self):
101         """
102             Load white and black lists from phrases-settings.json.
103         """
104         settings_path = (self.config.config_dir / 'phrase-settings.json').resolve()
105
106         if self.config.PHRASE_CONFIG:
107             settings_path = self._convert_php_settings_if_needed(self.config.PHRASE_CONFIG)
108
109         with settings_path.open("r") as json_settings:
110             settings = json.load(json_settings)
111         return settings['blackList'], settings['whiteList']
112
113     def _check_sanity(self, phrase):
114         """
115             Check sanity of given inputs in case somebody added garbage in the wiki.
116             If a bad class/type is detected the system will exit with an error.
117         """
118         class_matchs = self.sanity_check_pattern.findall(phrase.p_class)
119         type_matchs = self.sanity_check_pattern.findall(phrase.p_type)
120
121         if not class_matchs or not type_matchs:
122             LOG.warning("Bad class/type: %s=%s. It will not be imported",
123                         phrase.p_class, phrase.p_type)
124             return False
125         return True
126
127     def _process_phrase(self, phrase):
128         """
129             Processes the given phrase by checking black and white list
130             and sanity.
131             Return the class/type pair corresponding to the phrase.
132         """
133
134         # blacklisting: disallow certain class/type combinations
135         if phrase.p_class in self.black_list.keys() \
136            and phrase.p_type in self.black_list[phrase.p_class]:
137             return None
138
139         # whitelisting: if class is in whitelist, allow only tags in the list
140         if phrase.p_class in self.white_list.keys() \
141            and phrase.p_type not in self.white_list[phrase.p_class]:
142             return None
143
144         # sanity check, in case somebody added garbage in the wiki
145         if not self._check_sanity(phrase):
146             self.statistics_handler.notify_one_phrase_invalid()
147             return None
148
149         self.word_phrases.add((phrase.p_label, phrase.p_class,
150                                phrase.p_type, phrase.p_operator))
151
152         return (phrase.p_class, phrase.p_type)
153
154
155     def _create_place_classtype_table_and_indexes(self, class_type_pairs):
156         """
157             Create table place_classtype for each given pair.
158             Also create indexes on place_id and centroid.
159         """
160         LOG.warning('Create tables and indexes...')
161
162         sql_tablespace = self.config.TABLESPACE_AUX_DATA
163         if sql_tablespace:
164             sql_tablespace = ' TABLESPACE ' + sql_tablespace
165
166         with self.db_connection.cursor() as db_cursor:
167             db_cursor.execute("CREATE INDEX idx_placex_classtype ON placex (class, type)")
168
169         for pair in class_type_pairs:
170             phrase_class = pair[0]
171             phrase_type = pair[1]
172
173             table_name = _classtype_table(phrase_class, phrase_type)
174
175             if table_name in self.table_phrases_to_delete:
176                 self.statistics_handler.notify_one_table_ignored()
177                 # Remove this table from the ones to delete as it match a
178                 # class/type still existing on the special phrases of the wiki.
179                 self.table_phrases_to_delete.remove(table_name)
180                 # So don't need to create the table and indexes.
181                 continue
182
183             # Table creation
184             self._create_place_classtype_table(sql_tablespace, phrase_class, phrase_type)
185
186             # Indexes creation
187             self._create_place_classtype_indexes(sql_tablespace, phrase_class, phrase_type)
188
189             # Grant access on read to the web user.
190             self._grant_access_to_webuser(phrase_class, phrase_type)
191
192             self.statistics_handler.notify_one_table_created()
193
194         with self.db_connection.cursor() as db_cursor:
195             db_cursor.execute("DROP INDEX idx_placex_classtype")
196
197
198     def _create_place_classtype_table(self, sql_tablespace, phrase_class, phrase_type):
199         """
200             Create table place_classtype of the given phrase_class/phrase_type if doesn't exit.
201         """
202         table_name = _classtype_table(phrase_class, phrase_type)
203         with self.db_connection.cursor() as db_cursor:
204             db_cursor.execute(SQL("""
205                     CREATE TABLE IF NOT EXISTS {{}} {}
206                     AS SELECT place_id AS place_id,st_centroid(geometry) AS centroid FROM placex
207                     WHERE class = {{}} AND type = {{}}""".format(sql_tablespace))
208                               .format(Identifier(table_name), Literal(phrase_class),
209                                       Literal(phrase_type)))
210
211
212     def _create_place_classtype_indexes(self, sql_tablespace, phrase_class, phrase_type):
213         """
214             Create indexes on centroid and place_id for the place_classtype table.
215         """
216         index_prefix = 'idx_place_classtype_{}_{}_'.format(phrase_class, phrase_type)
217         base_table = _classtype_table(phrase_class, phrase_type)
218         # Index on centroid
219         if not self.db_connection.index_exists(index_prefix + 'centroid'):
220             with self.db_connection.cursor() as db_cursor:
221                 db_cursor.execute(SQL("""
222                     CREATE INDEX {{}} ON {{}} USING GIST (centroid) {}""".format(sql_tablespace))
223                                   .format(Identifier(index_prefix + 'centroid'),
224                                           Identifier(base_table)), sql_tablespace)
225
226         # Index on place_id
227         if not self.db_connection.index_exists(index_prefix + 'place_id'):
228             with self.db_connection.cursor() as db_cursor:
229                 db_cursor.execute(SQL(
230                     """CREATE INDEX {{}} ON {{}} USING btree(place_id) {}""".format(sql_tablespace))
231                                   .format(Identifier(index_prefix + 'place_id'),
232                                           Identifier(base_table)))
233
234
235     def _grant_access_to_webuser(self, phrase_class, phrase_type):
236         """
237             Grant access on read to the table place_classtype for the webuser.
238         """
239         table_name = _classtype_table(phrase_class, phrase_type)
240         with self.db_connection.cursor() as db_cursor:
241             db_cursor.execute(SQL("""GRANT SELECT ON {} TO {}""")
242                               .format(Identifier(table_name),
243                                       Identifier(self.config.DATABASE_WEBUSER)))
244
245     def _remove_non_existent_tables_from_db(self):
246         """
247             Remove special phrases which doesn't exist on the wiki anymore.
248             Delete the place_classtype tables.
249         """
250         LOG.warning('Cleaning database...')
251
252         # Delete place_classtype tables corresponding to class/type which
253         # are not on the wiki anymore.
254         with self.db_connection.cursor() as db_cursor:
255             for table in self.table_phrases_to_delete:
256                 self.statistics_handler.notify_one_table_deleted()
257                 db_cursor.drop_table(table)
258
259
260     def _convert_php_settings_if_needed(self, file_path):
261         """
262             Convert php settings file of special phrases to json file if it is still in php format.
263         """
264         if not isfile(file_path):
265             raise UsageError(str(file_path) + ' is not a valid file.')
266
267         file, extension = os.path.splitext(file_path)
268         json_file_path = Path(file + '.json').resolve()
269
270         if extension not in ('.php', '.json'):
271             raise UsageError('The custom NOMINATIM_PHRASE_CONFIG file has not a valid extension.')
272
273         if extension == '.php' and not isfile(json_file_path):
274             try:
275                 subprocess.run(['/usr/bin/env', 'php', '-Cq',
276                                 (self.phplib_dir / 'migration/PhraseSettingsToJson.php').resolve(),
277                                 file_path], check=True)
278                 LOG.warning('special_phrase configuration file has been converted to json.')
279             except subprocess.CalledProcessError:
280                 LOG.error('Error while converting %s to json.', file_path)
281                 raise
282
283         return json_file_path