Coverage for src/mafw/processor_library/db_init.py: 100%

172 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2025–2026 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5Database initialisation processor module. 

6 

7This module contains the following processors: 

8 

9:class:`TableCreator` 

10 processor which handles the creation of database tables based on registered models. It provides functionality to 

11 create tables automatically while respecting existing tables and offering options for forced recreation. 

12 

13:class:`TriggerRefresher` 

14 processor to safely update the trigger definitions. It removes all existing triggers and regenerates them 

15 according to the new definition. Particularly useful when debugging triggers, it can also be left at the 

16 beginning of all analysis pipelines since it does not cause any loss of data. 

17 

18:class:`SQLScriptRunner` 

19 processor to execute SQL scripts from files against the database. It reads SQL files, removes block comments, 

20 splits the content into individual statements, and executes them within a transaction. 

21 

22.. versionadded:: v2.0.0 

23""" 

24 

25import logging 

26import re 

27from collections.abc import Collection 

28from pathlib import Path 

29from typing import TYPE_CHECKING, Any, cast 

30 

31import peewee 

32from rich.prompt import Confirm, Prompt 

33 

34from mafw.db.db_model import mafw_model_register 

35from mafw.db.db_types import PeeweeModelWithMeta 

36from mafw.db.std_tables import StandardTable 

37from mafw.db.trigger import MySQLDialect, PostgreSQLDialect, SQLiteDialect, TriggerDialect 

38from mafw.decorators import database_required, single_loop 

39from mafw.enumerators import LoopingStatus, ProcessorExitStatus 

40from mafw.mafw_errors import InvalidConfigurationError, UnsupportedDatabaseError 

41from mafw.processor import ActiveParameter, Processor 

42 

43log = logging.getLogger(__name__) 

44 

45block_comment_re = re.compile(r'/\*.*?\*/', re.DOTALL) 

46 

47 

48def _standard_table_models() -> set[type[StandardTable]]: 

49 """Return the registered standard-table models when available.""" 

50 standard_tables = mafw_model_register.get_standard_tables() 

51 if isinstance(standard_tables, list): 

52 return set(standard_tables) 

53 return set() 

54 

55 

56@database_required 

57@single_loop 

58class TableCreator(Processor): 

59 """ 

60 Processor to create all tables in the database. 

61 

62 This processor can be included in all pipelines in order to create all tables in the database. Its functionality 

63 is based on the fact that all :class:`.MAFwBaseModel` subclasses are automatically included in a global register 

64 (:data:`.mafw_model_register`). 

65 

66 This processor will perform the following: 

67 

68 #. Get a list of all tables already existing in the database. 

69 #. Prune from the lists of models the ones for which already exist in the database. 

70 #. Create the remaining tables. 

71 

72 

73 This overall behaviour can be modified via the following parameters: 

74 

75 * *force_recreate* (bool, default = False): Use with extreme care. When set to True, all tables in the 

76 database and in the model register will be first dropped and then recreated. It is almost equivalent to a re-initialization of the 

77 whole DB with all the data being lost. 

78 

79 * *soft_recreate* (bool, default = True): When set to true, all tables whose model is in the mafw model 

80 register will be recreated with the safe flag. It means that there won't be any table drop. If a table is 

81 already existing, nothing will happen. If a new trigger is added to the table this will be created. When 

82 set to False, only tables whose model is in the register and that are not existing will be created. 

83 

84 * *apply_only_to_prefix* (list[str], default = []): This parameter allows to create only the tables that do 

85 not already exist and whose name start with one of the provided prefixes. 

86 

87 .. versionadded:: v2.0.0 

88 

89 """ 

90 

91 force_recreate = ActiveParameter( 

92 name='force_recreate', default=False, help_doc='First drop and then create the tables. LOSS OF ALL DATA!!!' 

93 ) 

94 """ 

95 Force recreate (bool, default = False). 

96  

97 Use with extreme care. When set to True, all tables in the database and in the model register will be first  

98 dropped and then recreated. It is almost equivalent to a re-initialization of the whole DB with all the data  

99 being lost.  

100 """ 

101 

102 soft_recreate = ActiveParameter( 

103 name='soft_recreate', default=True, help_doc='Safe recreate tables without dropping. No data loss' 

104 ) 

105 """ 

106 Soft recreate (bool default = True). 

107  

108 When set to true, all tables whose model is in the mafw model register will be recreated with the safe flag. It  

109 means that there won't be any table drop. If a table is already existing, nothing will happen. If a new trigger  

110 is added to the table, this will be created. When set to False, only tables whose model is in the register and  

111 that are not existing will be created.  

112 """ 

113 

114 apply_only_to_prefix = ActiveParameter[list[str]]( 

115 name='apply_only_to_prefix', 

116 default=[], 

117 help_doc='Create only tables whose name start with the provided prefixes.', 

118 ) 

119 """ 

120 Apply only to tables starting with prefix (list[str], default = []). 

121  

122 This parameter allows to create only the tables that do not already exist and whose name start with one of the  

123 provided prefixes.  

124 """ 

125 

126 def __init__(self, *args: Any, **kwargs: Any) -> None: 

127 super().__init__(*args, **kwargs) 

128 self.existing_table_names: list[str] = [] 

129 """The list of all existing tables in the database.""" 

130 

131 def validate_configuration(self) -> None: 

132 """ 

133 Configuration validation 

134 

135 :attr:`force_recreate` and :attr:`soft_recreate` cannot be both valid. 

136 

137 :raises InvalidConfigurationError: if both recreate types are True. 

138 """ 

139 if self.force_recreate and self.soft_recreate: 

140 raise InvalidConfigurationError( 

141 'Both force_recreate and soft_recreate set to True. Incompatible configuration' 

142 ) 

143 

144 def process(self) -> None: 

145 """ 

146 Execute the table creation process. 

147 

148 This method performs the following steps: 

149 

150 #. Identify all models that have automatic creation enabled. 

151 #. Filter models based on the apply_only_to_prefix parameter if specified. 

152 #. Handle forced recreation if requested, including user confirmation. 

153 #. Handle soft recreation if requested, letting all tables with a known model be recreated. 

154 #. Create the required tables. 

155 #. Initialise standard tables after recreation if needed. 

156 

157 If user cancel the creation, the processor exit status is set to :attr:`.ProcessorExitStatus.Aborted` so that 

158 the whole processor list is blocked. 

159 """ 

160 # get all tables with autocreation flag 

161 autocreation_models = [ 

162 model 

163 for _, model in mafw_model_register.items() 

164 if model._meta.automatic_creation # type: ignore[attr-defined] 

165 ] 

166 

167 # filter out standard tables if the user decided not to create them 

168 standard_table_models = _standard_table_models() 

169 if not self.create_standard_tables: 

170 autocreation_models = [model for model in autocreation_models if model not in standard_table_models] 

171 

172 # get the table name from the model class 

173 autocreation_table_names = [cast(PeeweeModelWithMeta, model)._meta.table_name for model in autocreation_models] 

174 

175 if self.apply_only_to_prefix: 

176 # remove tables with all given prefixes 

177 autocreation_table_names = [ 

178 name 

179 for name in autocreation_table_names 

180 if any([name.startswith(prefix) for prefix in self.apply_only_to_prefix]) # type: ignore[union-attr] 

181 ] 

182 

183 # in the case of force_recreation, we need to have user confirmation 

184 if self.force_recreate: 

185 log.warning(f'Forcing recreation of {len(autocreation_table_names)} tables in the database.') 

186 log.warning('All data in these tables will be lost.') 

187 

188 with self._user_interface.enter_interactive_mode(): 

189 question = 'Are you really sure?' 

190 if self._user_interface.name == 'rich': 

191 question = '[red][bold]' + question + '[/red][/bold]' 

192 confirmation = self._user_interface.prompt_question( 

193 question=question, prompt_type=Confirm, default=False, show_default=True, case_sensitive=True 

194 ) 

195 if not confirmation: 

196 self.processor_exit_status = ProcessorExitStatus.Aborted 

197 return 

198 else: 

199 log.info(f'Removing {len(autocreation_table_names)} tables from the database.') 

200 models = [ 

201 model 

202 for model in autocreation_models 

203 if cast(PeeweeModelWithMeta, model)._meta.table_name in autocreation_table_names 

204 ] 

205 self.database.drop_tables(models) # type: ignore[arg-type] 

206 

207 if self.soft_recreate: 

208 # recreate all tables in the mafw register 

209 models = [ 

210 model 

211 for model in autocreation_models 

212 if cast(PeeweeModelWithMeta, model)._meta.table_name in autocreation_table_names 

213 ] 

214 else: 

215 # recreate all tables in the mafw register and that are not yet existing 

216 self.existing_table_names = self.database.get_tables() 

217 models = [ 

218 model 

219 for model in autocreation_models 

220 if cast(PeeweeModelWithMeta, model)._meta.table_name in autocreation_table_names 

221 and cast(PeeweeModelWithMeta, model)._meta.table_name not in self.existing_table_names 

222 ] 

223 self.database.create_tables(models) # type: ignore[arg-type] 

224 

225 if self.force_recreate and self.create_standard_tables: 

226 # in the case of a recreation, do the initialisation of all dropped standard tables. 

227 for model in models: 

228 if model in standard_table_models: 

229 model.init() 

230 

231 n = len(models) 

232 if n > 0: 

233 if n == 1: 

234 plu = '' 

235 else: 

236 plu = 's' 

237 if self.soft_recreate: 

238 soft = '(soft) ' 

239 else: 

240 soft = '' 

241 

242 log.info(f'Successfully {soft}created {len(models)} table{plu}.') 

243 

244 

245@database_required 

246class TriggerRefresher(Processor): 

247 """ 

248 Processor to recreate all triggers. 

249 

250 Triggers are database objects, and even though they could be created, dropped and modified at any moment, 

251 within the MAFw execution cycle they are normally created along with the table they are targeting. 

252 

253 When the table is created, also all its triggers are created, 

254 but unless differently specified, with the safe flag on, that means that they are created if they do not exist. 

255 

256 This might be particularly annoying when modifying an existing trigger, because you need to manually drop the 

257 trigger to let the table creation mechanism to create the newer version. 

258 

259 The goal of this processor is to drop all existing triggers and then recreate the corresponding tables so to have 

260 an updated version of the triggers. 

261 

262 The processor is relying on the fact that all subclasses of :class:`.MAFwBaseModel` 

263 are automatically inserted in the :data:`.mafw_model_register` so that the model class can be retrieved from the 

264 table name. 

265 

266 Before removing any trigger, the processor will build a list with all the affected tables and check if all of 

267 them are in the :data:`.mafw_model_register`, if so, it will proceed without asking any further confirmation. 

268 Otherwise, if some affected tables are not in the register, then it will ask the user to decide what to do: 

269 

270 - Remove only the triggers whose tables are in the register and thus recreated afterward. 

271 - Remove all triggers, in this case, some of them will not be recreated. 

272 - Abort the processor. 

273 

274 Trigger manipulations (drop and creation) are not directly implemented in :link:`peewee` and are an extension 

275 provided by MAFw. In order to be compatible with the three main databases (:link:`sqlite`, :link:`mysql` and 

276 :link:`postgresql`), the SQL generation is obtained via the :class:`.TriggerDialect` interface. 

277 

278 .. seealso:: 

279 

280 The :class:`.Trigger` class and also the :ref:`trigger chapter <triggers>` for a deeper explanation on triggers. 

281 

282 The :class:`.ModelRegister` class, the :data:`.mafw_model_register` and the :ref:`related chapter 

283 <auto_registration>` on the automatic registration mechanism. 

284 

285 The :class:`.TriggerDialect` and its subclasses, for a database independent way to generate SQL statement 

286 related to triggers. 

287 

288 .. versionadded:: v2.0.0 

289 """ 

290 

291 def __init__(self, *args: Any, **kwargs: Any) -> None: 

292 super().__init__(*args, **kwargs) 

293 self.dialect: TriggerDialect | None = None 

294 self.tables_to_be_rebuilt: set[str] = set() 

295 

296 def get_dialect(self) -> TriggerDialect: 

297 """ 

298 Get the valid SQL dialect based on the type of Database 

299 

300 :return: The SQL trigger dialect 

301 :type: :class:`.TriggerDialect` 

302 :raises: :class:`.UnsupportedDatabaseError` if there is no dialect for the current DB. 

303 """ 

304 if self.dialect is not None: 

305 return self.dialect 

306 

307 if self._database is None: 

308 # Default to SQLite dialect 

309 return SQLiteDialect() 

310 

311 db = self._database 

312 if isinstance(db, peewee.DatabaseProxy): 

313 db = db.obj # Get the actual database from the proxy 

314 

315 dialect: TriggerDialect 

316 if isinstance(db, peewee.SqliteDatabase): 

317 dialect = SQLiteDialect() 

318 elif isinstance(db, peewee.MySQLDatabase): 

319 dialect = MySQLDialect() 

320 elif isinstance(db, peewee.PostgresqlDatabase): 

321 dialect = PostgreSQLDialect() 

322 else: 

323 raise UnsupportedDatabaseError(f'Unsupported database type: {type(db)}') 

324 

325 return dialect 

326 

327 def start(self) -> None: 

328 super().start() 

329 self.dialect = self.get_dialect() 

330 

331 def get_items(self) -> Collection[Any]: 

332 """ 

333 Retrieves a list of database triggers and interacts with the user to determine which ones to process. 

334 

335 This method fetches all currently defined database triggers. If any tables 

336 associated with these triggers are not known (i.e., not registered in 

337 :data:`.mafw_model_register`), it enters an interactive mode to prompt the user for 

338 a course of action: 

339 

340 1. **Remove All Triggers (A):** Processes all triggers for subsequent removal, 

341 but only marks 'rebuildable' tables for rebuilding. 

342 2. **Remove Only Rebuildable Triggers (O):** Processes only triggers associated 

343 with 'rebuildable' tables. 

344 3. **Quit (Q):** Aborts the entire process. 

345 

346 If no unknown tables are found, or the user chooses to process rebuildable tables, 

347 the list of triggers and the set of tables to be rebuilt are prepared for the next stage. 

348 

349 :return: A collection of database triggers to be processed, in the for tuple trigger_name, table_name 

350 :rtype: list[tuple[str, str]] 

351 """ 

352 if TYPE_CHECKING: 

353 assert self.dialect is not None 

354 

355 s: list[tuple[str, str]] = self.database.execute_sql(self.dialect.select_all_trigger_sql()).fetchall() # type: ignore[no-untyped-call] 

356 tables = [r[1] for r in s] 

357 

358 affected_tables = set(tables) 

359 known_tables = mafw_model_register.get_table_names() 

360 rebuildable_tables = {t for t in affected_tables if t in known_tables} 

361 not_rebuildable_tables = affected_tables - rebuildable_tables 

362 

363 if len(not_rebuildable_tables) > 0: 

364 log.warning(f'There are some tables ({len(not_rebuildable_tables)}) that cannot be rebuild') 

365 with self._user_interface.enter_interactive_mode(): 

366 question = 'Remove all triggers (A), remove only rebuildable triggers (O), quit (Q)' 

367 if self._user_interface.name == 'rich': 

368 question = '[red][bold]' + question + '[/red][/bold]' 

369 

370 class TriggerPrompt(Prompt): 

371 response_type = str 

372 validate_error_message = '[prompt.invalid]Please enter A, O or Q' 

373 choices: list[str] = ['A', 'O', 'Q'] 

374 

375 answer = self._user_interface.prompt_question( 

376 question=question, 

377 prompt_type=TriggerPrompt, 

378 default='O', 

379 show_default=True, 

380 case_sensitive=False, 

381 show_answer=True, 

382 ) 

383 

384 if answer == 'Q': 

385 s = [] 

386 affected_tables = set() 

387 self.processor_exit_status = ProcessorExitStatus.Aborted 

388 self.looping_status = LoopingStatus.Abort 

389 

390 elif answer == 'O': 

391 s = [r for r in s if r[1] in rebuildable_tables] 

392 affected_tables = rebuildable_tables 

393 

394 else: # equivalent to 'A' 

395 # remove all triggers 

396 # but rebuilds only rebuildable_tables 

397 affected_tables = rebuildable_tables 

398 

399 self.tables_to_be_rebuilt = affected_tables 

400 return s 

401 

402 def process(self) -> None: 

403 """Delete the current trigger from its table""" 

404 if TYPE_CHECKING: 

405 assert self.dialect is not None 

406 

407 self.database.execute_sql(self.dialect.drop_trigger_sql(self.item[0], safe=True, table_name=self.item[1])) # type: ignore[no-untyped-call] 

408 

409 def finish(self) -> None: 

410 """ 

411 Recreate the tables from which triggers were dropped. 

412 

413 This is only done if the user did not abort the process. 

414 """ 

415 if self.looping_status != LoopingStatus.Abort: 

416 log.info(f'Recreating {self.n_item} triggers on {len(self.tables_to_be_rebuilt)} tables...') 

417 models = [mafw_model_register.get_model(table_name) for table_name in self.tables_to_be_rebuilt] 

418 

419 self.database.create_tables(models) # type: ignore[arg-type] 

420 super().finish() 

421 

422 def format_progress_message(self) -> None: 

423 self.progress_message = f'Dropping trigger {self.item[0]} from table {self.item[1]}' 

424 

425 

426@database_required 

427class SQLScriptRunner(Processor): 

428 """ 

429 Processor to execute SQL scripts from files against the database. 

430 

431 This processor reads SQL files, removes multi-line block comments, splits the content into individual 

432 statements, and executes them within a transaction. It is designed to handle SQL script execution 

433 in a safe manner by wrapping all statements in a single atomic transaction. 

434 

435 The processor accepts a list of SQL files through the :attr:`sql_files` parameter. Each file is validated 

436 to ensure it exists and is a regular file before processing. Block comments (`/* ... */`) are removed 

437 from the SQL content before statement parsing. 

438 

439 .. versionadded:: v2.0.0 

440 """ 

441 

442 sql_files = ActiveParameter[list[Path]]('sql_files', default=[], help_doc='A list of SQL files to be processed') 

443 """List of SQL files to be processed""" 

444 

445 def validate_configuration(self) -> None: 

446 """ 

447 Validate the configuration of SQL script runner. 

448 

449 Ensures that all specified SQL files exist and are regular files. 

450 

451 :raises InvalidConfigurationError: if any of the specified files does not exist or is not a regular file. 

452 """ 

453 if TYPE_CHECKING: 

454 # we need to convince mypy that the sql_files is not and ActiveParameter but 

455 # the content of the ActiveParameter 

456 assert isinstance(self.sql_files, list) 

457 

458 self.sql_files = [Path(file) for file in self.sql_files] 

459 for file in self.sql_files: 

460 if not file.exists() or not file.is_file(): 

461 raise InvalidConfigurationError(f'There are issues with SQL file "{file.resolve()}". Please verify.') 

462 

463 def get_items(self) -> Collection[Any]: 

464 """ 

465 Get the collection of SQL files to be processed. 

466 

467 :return: A collection of SQL file paths to be processed 

468 :rtype: Collection[Any] 

469 """ 

470 if TYPE_CHECKING: 

471 # we need to convince mypy that the sql_files is not and ActiveParameter but 

472 # the content of the ActiveParameter 

473 assert isinstance(self.sql_files, list) 

474 

475 return self.sql_files 

476 

477 def process(self) -> None: 

478 """ 

479 Process a single SQL file by reading, parsing, and executing its statements. 

480 

481 Reads the SQL file content, removes multi-line block comments, splits the content 

482 into individual SQL statements, and executes them within a transaction. 

483 

484 If no statements are found in the file, a warning is logged. If an error occurs 

485 during execution, the transaction is rolled back and the exception is re-raised. 

486 

487 :raises Exception: If an error occurs during SQL statement execution. 

488 """ 

489 with open(self.item) as sql_file: 

490 sql_content = sql_file.read() 

491 

492 # remove the multi-line block comments (/* ... */) 

493 sql_content = block_comment_re.sub('', sql_content) 

494 

495 statements = [s.strip() + ';' for s in sql_content.split(';') if s.strip()] 

496 

497 if not statements: 

498 log.warning(f'No SQL statements found to execute in {self.item.name}.') 

499 

500 log.debug(f'Found {len(statements)} statements to execute.') 

501 

502 try: 

503 # use an atomic transaction to wrap the execution of all statements. 

504 with self.database.atomic(): 

505 for statement in statements: 

506 self.database.execute_sql(statement) # type: ignore[no-untyped-call] 

507 

508 except Exception as e: 

509 log.critical(f'An error occurred while executing the SQL script {self.item.name}.') 

510 log.critical('Rolling back the database to preserve integrity.') 

511 log.critical(f'Error details: {e}') 

512 raise 

513 

514 def format_progress_message(self) -> None: 

515 self.progress_message = f'Processing SQL file {self.item.name}'