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