Coverage for src / mafw / db / db_model.py: 97%

223 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-12 09:03 +0000

1# Copyright 2025–2026 European Union 

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

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5The module provides functionality to MAFw to interface to a DB. 

6""" 

7 

8import warnings 

9from collections.abc import Mapping, Sequence 

10from itertools import tee 

11from typing import TYPE_CHECKING, Any, Iterable, cast 

12 

13# mypy is complaining that peewee does not have make_snake_case. 

14from peewee import ( # type: ignore[attr-defined] 

15 EXCLUDED, 

16 SQL, 

17 Database, 

18 DatabaseProxy, 

19 Field, 

20 ForeignKeyField, 

21 ModelBase, 

22 ModelInsert, 

23 MySQLDatabase, 

24 Select, 

25 Value, 

26 fn, 

27 make_snake_case, 

28) 

29from playhouse.shortcuts import ThreadSafeDatabaseMetadata, dict_to_model, model_to_dict, update_model_from_dict 

30 

31# noinspection PyUnresolvedReferences 

32from playhouse.signals import Model 

33 

34from mafw.db import trigger 

35from mafw.db.db_types import PeeweeModelWithMeta 

36from mafw.db.fields import FileNameField 

37from mafw.db.model_register import ModelRegister 

38from mafw.db.trigger import Trigger 

39from mafw.mafw_errors import MAFwException, UnsupportedDatabaseError 

40 

41database_proxy = DatabaseProxy() 

42"""This is a placeholder for the real database object that will be known only at run time""" 

43 

44 

45mafw_model_register = ModelRegister() 

46""" 

47This is the instance of the ModelRegister 

48 

49 .. seealso:: 

50  

51 :class:`.ModelRegister` for more information on how to retrieve models and :class:`.RegisteredMeta` and :class:`MAFwBaseModel` for the automatic registration of models` 

52 

53""" 

54 

55 

56class RegisteredMeta(ModelBase): 

57 """ 

58 Metaclass for registering models with the MAFw model registry. 

59 

60 This metaclass automatically registers model classes with the global model registry 

61 when they are defined, allowing for dynamic discovery and management of database models. 

62 It ensures that only concrete model classes (not the base classes themselves) are registered. 

63 

64 The registration process uses the table name from the model's metadata or generates 

65 a snake_case version of the class name if no explicit table name is set. 

66 """ 

67 

68 reserved_field_names = ['__logic__', '__conditional__'] 

69 

70 def __new__(cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any], **kwargs: dict[str, Any]) -> type: 

71 """ 

72 Create a new model class and register it if applicable. 

73 

74 This method is called during class definition to create the actual class 

75 and register it with the MAFw model registry if it's a concrete model class. 

76 

77 :param name: The name of the class being created. 

78 :type name: str 

79 :param bases: The base classes of the class being created. 

80 :type bases: tuple 

81 :param attrs: The attributes of the class being created. 

82 :type attrs: dict 

83 :param kwargs: Other keyword attributes passed to the class. 

84 :type kwargs: dict[str, Any] 

85 :return: The newly created class. 

86 :rtype: type 

87 """ 

88 # store here any extra argument that is passed to the class defition. 

89 extra_args = kwargs 

90 

91 meta = attrs.get('Meta', None) 

92 if meta: 

93 custom_table_name = meta.__dict__.get('table_name', None) 

94 else: 

95 custom_table_name = None 

96 model = cast(RegisteredMeta, super().__new__(cls, name, bases, attrs)) # type: ignore[no-untyped-call] 

97 

98 if TYPE_CHECKING: 

99 assert hasattr(model, '_meta') 

100 

101 # for consistency, add the extra_args to the meta instance 

102 model._meta.extra_args = extra_args 

103 

104 # check for the use of reserved field names. 

105 field_names = model._meta.sorted_field_names 

106 if any([reserved_name in field_names for reserved_name in RegisteredMeta.reserved_field_names]): 

107 warnings.warn( 

108 f'Model {model.__name__} is using a reserved field name. Filter configuration might not ' 

109 f'work properly. Reserved names are f{RegisteredMeta.reserved_field_names}.' 

110 ) 

111 

112 # this super call is actually creating the Model class according to peewee ModelBase (that is a metaclass) 

113 # the model class will have an attribute (_meta) created by the ModelBase using the inner class Meta as a template. 

114 # The additional attributes (like suffix, prefix, ...) that we have added in the MAFwBaseModel Meta class will be 

115 # available. There is one problem, that the _meta.table_name is generated with our make_prefixed_suffixed_table_name 

116 # but before that the additional attributes are actually transferred to the instance of the meta class. 

117 # for this reason we need to regenerate the table_name now, when the whole Meta class is available. 

118 # We do so only if the user didn't specify a custom table name 

119 if custom_table_name is None: 

120 if model._meta.table_function is None: 

121 table_name = make_snake_case(model.__name__) 

122 else: 

123 table_name = model._meta.table_function(model) 

124 model._meta.table_name = table_name 

125 

126 # Do we need to register the Model? 

127 # By default we want to register (so skip_registration = False) 

128 skip_registration = extra_args.get('do_not_register', False) 

129 if skip_registration: 

130 # no need to register, so just skip the registration. 

131 return model 

132 

133 # Register only concrete model classes that are not base classes 

134 # Check that we're not processing base classes themselves 

135 is_base_class = name in ['Model', 'MAFwBaseModel', 'StandardTable'] 

136 

137 # Check that we have valid bases and that at least one inherits from Model 

138 has_valid_bases = bases and any(issubclass(base, Model) for base in bases) 

139 

140 if not is_base_class and has_valid_bases: 

141 table_name = model._meta.table_name 

142 mafw_model_register.register_model(table_name, model) 

143 

144 if hasattr(model._meta, 'suffix'): 144 ↛ 147line 144 didn't jump to line 147 because the condition on line 144 was always true

145 mafw_model_register.register_suffix(model._meta.suffix) 

146 

147 if hasattr(model._meta, 'prefix'): 147 ↛ 150line 147 didn't jump to line 150 because the condition on line 147 was always true

148 mafw_model_register.register_prefix(model._meta.prefix) 

149 

150 return model 

151 

152 

153class MAFwBaseModelDoesNotExist(MAFwException): 

154 """Raised when the base model class is not existing.""" 

155 

156 

157def get_db(model_class: PeeweeModelWithMeta) -> Database: 

158 """ 

159 Extract the actual database instance from a model class. 

160 

161 This function retrieves the database object from a model class's metadata, 

162 handling the case where the database is a :class:`peewee.DatabaseProxy`. If the 

163 database is a proxy, it extracts the underlying database object. 

164 

165 :param model_class: The model class from which to extract the database. 

166 :type model_class: PeeweeModelWithMeta 

167 :return: The actual database instance. 

168 :rtype: Database 

169 """ 

170 db = model_class._meta.database 

171 if isinstance(db, DatabaseProxy): 

172 db = db.obj 

173 return cast(Database, db) 

174 

175 

176def get_primary_key_fields(model_class: PeeweeModelWithMeta) -> tuple[list[str], list[Field]]: 

177 """ 

178 Extract primary key field names and field objects from a model metadata class. 

179 

180 This function retrieves the primary key information from a Peewee model metadata 

181 class, handling both single-column primary keys and composite (multi-column) keys. 

182 It returns two parallel lists: the first containing the field names as strings, 

183 and the second containing the corresponding field objects. 

184 

185 :param model_class: The model class from which to extract primary key information. 

186 :type model_class: RegisteredMeta 

187 :return: A tuple containing two lists: the first with primary key field names (as strings) 

188 and the second with the corresponding field objects. 

189 :rtype: tuple[list[str], list[Field]] 

190 """ 

191 if model_class._meta.composite_key: 

192 pk_fields = [model_class._meta.fields[name] for name in model_class._meta.primary_key.field_names] 

193 else: 

194 pk_fields = [model_class._meta.primary_key] 

195 

196 return [f.name for f in pk_fields], pk_fields 

197 

198 

199def make_prefixed_suffixed_name(model_class: RegisteredMeta) -> str: 

200 """ 

201 Generate a table name with optional prefix and suffix for a given model class. 

202 

203 This function constructs a table name by combining the prefix, the snake_case 

204 version of the model class name, and the suffix. If either prefix or suffix 

205 are not defined in the model's metadata, empty strings are used instead. 

206 

207 The prefix, table name, and suffix are joined using underscores. For example: 

208 

209 - If a model class is named "UserAccount" with prefix="app", suffix="data", 

210 the resulting table name will be "app_user_account_data" 

211 

212 - If a model class is named "Product" with prefix="ecommerce", suffix="_latest", 

213 the resulting table name will be "ecommerce_product_latest" 

214 

215 .. note:: 

216 

217 Underscores (_) will be automatically added to prefix and suffix if not already present. 

218 

219 :param model_class: The model class for which to generate the table name. 

220 :type model_class: RegisteredMeta 

221 :return: The constructed table name including prefix and suffix if applicable. 

222 :rtype: str 

223 """ 

224 if TYPE_CHECKING: 

225 assert hasattr(model_class, '_meta') 

226 

227 if hasattr(model_class._meta, 'suffix') and model_class._meta.suffix is not None: 

228 suffix = model_class._meta.suffix 

229 else: 

230 suffix = '' 

231 

232 if hasattr(model_class._meta, 'prefix') and model_class._meta.prefix is not None: 

233 prefix = model_class._meta.prefix 

234 else: 

235 prefix = '' 

236 

237 if not suffix.startswith('_') and suffix != '': 

238 suffix = '_' + suffix 

239 

240 if not prefix.endswith('_') and prefix != '': 

241 prefix = prefix + '_' 

242 

243 return f'{prefix}{make_snake_case(model_class.__name__)}{suffix}' 

244 

245 

246class MAFwBaseModel(Model, metaclass=RegisteredMeta): 

247 """The base model for the MAFw library. 

248 

249 Every model class (table) that the user wants to interface must inherit from this base. 

250 

251 This class extends peewee's Model with several additional features: 

252 

253 1. Automatic model registration: Models are automatically registered with the MAFw model registry 

254 during class definition, enabling dynamic discovery and management of database models. 

255 

256 2. Trigger support: The class supports defining database triggers through the :meth:`.triggers` method, 

257 which are automatically created when the table is created. File removal triggers can also be automatically 

258 generated using the `file_trigger_auto_create` boolean flag in the :ref:`meta class <auto_triggers>`. See also 

259 :meth:`.file_removal_triggers`. 

260 

261 3. Standard upsert operations: Provides :meth:`.std_upsert` and :meth:`.std_upsert_many` methods for 

262 performing upsert operations that work with SQLite and PostgreSQL. 

263 

264 4. Dictionary conversion utilities: Includes :meth:`.to_dict`, :meth:`.from_dict`, and :meth:`.update_from_dict` 

265 methods for easy serialization and deserialization of model instances. 

266 

267 5. Customizable table naming: Supports table name prefixes and suffixes through the Meta class 

268 with `prefix` and `suffix` attributes. See :func:`.make_prefixed_suffixed_name`. 

269 

270 6. Automatic table creation control: The `automatic_creation` Meta attribute controls whether 

271 tables are automatically created when the application starts. 

272 

273 .. note:: 

274 

275 The automatic model registration can be disabled for one single model class using the keyword argument 

276 `do_not_register` passed to the :class:`.RegisteredMeta` meta-class. For example: 

277 

278 .. code-block:: python 

279 

280 class AutoRegisterModel(MAFwBaseModel): 

281 pass 

282 

283 

284 class NoRegisterModel(MAFwBaseModel, do_not_register=True): 

285 pass 

286 

287 the first class will be automatically registered, while the second one will not. This is particularly useful if 

288 the user wants to define a base model class for the whole project without having it in the register where 

289 only concrete Model implementations are stored. 

290 

291 """ 

292 

293 @classmethod 

294 def get_fields_by_type(cls, field_type: type[Field]) -> dict[str, Field]: 

295 """ 

296 Return a dict {field_name: field_object} for all fields of the given type. 

297 

298 .. versionadded:: v2.0.0 

299 

300 :param field_type: Field type 

301 :type field_type: peewee.Field 

302 :return: A dict {field_name: field_object} for all fields of the given type. 

303 :rtype: dict[str, peewee.Field] 

304 """ 

305 if TYPE_CHECKING: 

306 assert hasattr(cls, '_meta') 

307 

308 return {name: field for name, field in cls._meta.fields.items() if isinstance(field, field_type)} 

309 

310 @classmethod 

311 def file_removal_triggers(cls) -> list[Trigger]: 

312 """ 

313 Generate a list of triggers for automatic file removal when records are deleted. 

314 

315 This method creates database triggers that automatically handle file cleanup when 

316 records containing :class:`~mafw.db.fields.FileNameField` fields are removed from 

317 the database table. The triggers insert the filenames and checksums into the 

318 :class:`~mafw.db.std_tables.OrphanFile` table for later processing. 

319 

320 The triggers are only created if the model has at least one field of type 

321 :class:`~mafw.db.fields.FileNameField`. If no such fields exist, an empty list 

322 is returned. 

323 

324 :class:`.FileNameListField` is a subclass of :class:`.FileNameField` and is treated in the same 

325 way. 

326 

327 .. versionadded:: v2.0.0 

328 

329 .. note:: 

330 

331 This functionality requires the ``file_trigger_auto_create`` attribute in the 

332 model's Meta class to be set to ``True`` for automatic trigger creation. 

333 

334 :return: A list containing the trigger object for file removal, or an empty list 

335 if no :class:`~mafw.db.fields.FileNameField` fields are found. 

336 :rtype: list[:class:`~mafw.db.trigger.Trigger`] 

337 """ 

338 from mafw.db.std_tables import OrphanFile, TriggerStatus 

339 

340 # it includes also FileNameListField that is a subclass of FileNameField 

341 file_fields = cls.get_fields_by_type(FileNameField) 

342 

343 if len(file_fields) == 0: 

344 return [] 

345 

346 if TYPE_CHECKING: 

347 assert hasattr(cls, '_meta') 

348 

349 new_trigger = Trigger( 

350 trigger_name=cls._meta.table_name + '_delete_files', 

351 trigger_type=(trigger.TriggerWhen.Before, trigger.TriggerAction.Delete), 

352 source_table=cls, 

353 safe=True, 

354 for_each_row=True, 

355 ) 

356 sub_query = TriggerStatus.select(TriggerStatus.status).where(TriggerStatus.trigger_type == 'DELETE_FILES') 

357 trigger_condition = Value(1) == sub_query 

358 new_trigger.add_when(trigger_condition) 

359 data = [] 

360 for f in file_fields: 

361 c = cast(FileNameField, file_fields[f]).checksum_field or f 

362 # the checksum is not really used in the OrphanFile. 

363 # in versions before 2.0.0, the checksum field in OrphanFile was not nullable, 

364 # so it should contain something. In order to be backward compatible, in case of a missing checksum field 

365 # we will just use the filename 

366 # note that the automatic filename to checksum conversion will not work in this case because the trigger 

367 # lives in the database and not in the application. 

368 data.append({'filenames': SQL(f'OLD.{f}'), 'checksum': SQL(f'OLD.{c}')}) 

369 insert_query = OrphanFile.insert_many(data) 

370 new_trigger.add_sql(insert_query) 

371 

372 return [new_trigger] 

373 

374 @classmethod 

375 def triggers(cls) -> list[Trigger]: 

376 """ 

377 Returns an iterable of :class:`~mafw.db.trigger.Trigger` objects to create upon table creation. 

378 

379 The user must overload this returning all the triggers that must be created along with this class. 

380 """ 

381 return [] 

382 

383 # noinspection PyUnresolvedReferences 

384 @classmethod 

385 def create_table(cls, safe: bool = True, **options: Any) -> None: 

386 """ 

387 Create the table in the underlying DB and all the related trigger as well. 

388 

389 If the creation of a trigger fails, then the whole table dropped, and the original exception is re-raised. 

390 

391 .. warning:: 

392 

393 Trigger creation has been extensively tested with :link:`SQLite`, but not with the other database implementation. 

394 Please report any malfunction. 

395 

396 :param safe: Flag to add an IF NOT EXISTS to the creation statement. Defaults to True. 

397 :type safe: bool, Optional 

398 :param options: Additional options passed to the super method. 

399 """ 

400 super().create_table(safe, **options) 

401 

402 # this is just use to make mypy happy. 

403 meta_cls = cast(PeeweeModelWithMeta, cls) 

404 

405 # Get the database instance, it is used for trigger creation 

406 db = meta_cls._meta.database 

407 

408 triggers_list = cls.triggers() 

409 

410 if meta_cls._meta.file_trigger_auto_create: 

411 triggers_list.extend(cls.file_removal_triggers()) 

412 

413 if len(triggers_list): 

414 # Create tables with appropriate error handling 

415 try: 

416 for trigger in triggers_list: 

417 trigger.set_database(db) 

418 try: 

419 db.execute_sql(trigger.create()) 

420 except UnsupportedDatabaseError as e: 

421 warnings.warn(f'Skipping unsupported trigger {trigger.trigger_name}: {str(e)}') 

422 except Exception: 

423 raise 

424 except: 

425 # If an error occurs, drop the table and any created triggers 

426 meta_cls._meta.database.drop_tables([cls], safe=safe) 

427 for trigger in triggers_list: 

428 try: 

429 db.execute_sql(trigger.drop(True)) 

430 except Exception: 

431 pass # Ignore errors when dropping triggers during cleanup 

432 raise 

433 

434 @classmethod 

435 def std_upsert(cls, __data: dict[str, Any] | None = None, **mapping: Any) -> ModelInsert: 

436 """ 

437 Perform a so-called standard upsert. 

438 

439 An upsert statement is not part of the standard SQL and different databases have different ways to implement it. 

440 This method will work for the three DB backends generating a valid query for the three dialects. 

441 

442 An upsert is a statement in which we try to insert some data in a table where there are some constraints. 

443 If one constraint is failing, then instead of inserting a new row, we will try to update the existing row 

444 causing the constraint violation. 

445 

446 A standard upsert, in the naming convention of MAFw, is setting the conflict cause to the primary key with all 

447 other fields being updated. In other words, the database will try to insert the data provided in the table, but 

448 if the primary key already exists, then all other fields will be updated. 

449 

450 This method is equivalent to the following: 

451 

452 .. code-block:: python 

453 

454 class Sample(MAFwBaseModel): 

455 sample_id = AutoField( 

456 primary_key=True, 

457 help_text='The sample id primary key', 

458 ) 

459 sample_name = TextField(help_text='The sample name') 

460 

461 

462 ( 

463 Sample.insert(sample_id=1, sample_name='my_sample') 

464 .on_conflict( 

465 preserve=[Sample.sample_name] 

466 ) # use the value we would have inserted 

467 .execute() 

468 ) 

469 

470 :param __data: A dictionary containing the key/value pair for the insert. The key is the column name. 

471 Defaults to None 

472 :type __data: dict, Optional 

473 :param mapping: Keyword arguments representing the value to be inserted. 

474 

475 """ 

476 meta_cls = cast('PeeweeModelWithMeta', cls) 

477 db = get_db(meta_cls) 

478 

479 # Normalize input 

480 if __data is not None: 

481 data = dict(__data) 

482 data.update(mapping) 

483 else: 

484 data = dict(mapping) 

485 

486 # --- PK detection --- 

487 pk_names, pk_fields = get_primary_key_fields(meta_cls) 

488 

489 # --- Field resolution: fix #138 --- 

490 # combined includes both field and column names, we need to be sure 

491 # we do not count them twice 

492 combined = meta_cls._meta.combined 

493 seen_fields: set[Field] = set() 

494 

495 for name in data: 

496 field = combined.get(name) 

497 if field is None: 497 ↛ 498line 497 didn't jump to line 498 because the condition on line 497 was never true

498 raise ValueError(f'Unknown field/column: {name}') 

499 

500 # Deduplicate same Field object (raw_image vs raw_image_id) 

501 if field in seen_fields: 501 ↛ 502line 501 didn't jump to line 502 because the condition on line 501 was never true

502 continue 

503 

504 seen_fields.add(field) 

505 

506 # --- Build insert --- 

507 insert_query = cls.insert(data) 

508 

509 # --- Backend-specific update mapping --- 

510 if isinstance(db, MySQLDatabase): 

511 update_mapping = {field: fn.VALUES(field) for field in seen_fields if field.name not in pk_names} 

512 return insert_query.on_conflict(update=update_mapping) 

513 

514 else: 

515 update_mapping = { 

516 field: getattr(EXCLUDED, field.name) for field in seen_fields if field.name not in pk_names 

517 } 

518 

519 return insert_query.on_conflict( 

520 conflict_target=pk_fields, 

521 update=update_mapping, 

522 ) 

523 

524 @classmethod 

525 def std_upsert_many(cls, rows: Iterable[Any], fields: list[str] | None = None) -> ModelInsert: 

526 """ 

527 Perform a standard upsert with many rows. 

528 

529 .. seealso:: 

530 

531 Read the :meth:`std_upsert` documentation for an explanation of this method. 

532 

533 :param rows: A list with the rows to be inserted. Each item can be a dictionary or a tuple of values. If a 

534 tuple is provided, then the `fields` must be provided. 

535 :type rows: Iterable 

536 :param fields: A list of field names. Defaults to None. 

537 :type fields: list[str], Optional 

538 """ 

539 meta_cls = cast(PeeweeModelWithMeta, cls) 

540 db = get_db(meta_cls) 

541 

542 # Initialise the output iterator and field names list. 

543 # rows_to_insert will hold the actual data to insert (possibly split from rows for inspection) 

544 # insert_field_names will hold the column names (either provided or inferred from first row) 

545 rows_to_insert: Iterable[Any] = rows 

546 insert_field_names: list[str] = list(fields) if fields is not None else [] 

547 

548 # If fields are not explicitly provided, we need to infer them from the input data. 

549 # This branch handles the case where rows is a Sequence (list, tuple) that supports indexing. 

550 if fields is None: 

551 if isinstance(rows, Sequence) and not isinstance(rows, (str, bytes)): 

552 # Sequence types (list, tuple) allow direct indexing to inspect the first row. 

553 if len(rows) > 0: 553 ↛ 570line 553 didn't jump to line 570 because the condition on line 553 was always true

554 first_row = rows[0] 

555 # If the first row is a dictionary (Mapping), extract its keys as field names. 

556 if isinstance(first_row, Mapping): 556 ↛ 570line 556 didn't jump to line 570 because the condition on line 556 was always true

557 insert_field_names = list(first_row.keys()) 

558 else: 

559 # For non-Sequence iterables (generators, etc.), we need to use tee() to inspect 

560 # the first element without consuming the iterator. This creates two iterators: 

561 # rows_inspect for looking at the first row, rows_to_insert for actual insertion. 

562 rows_inspect, rows_to_insert = tee(rows) 

563 first_row = next(rows_inspect, None) 

564 if isinstance(first_row, Mapping): 564 ↛ 570line 564 didn't jump to line 570 because the condition on line 564 was always true

565 insert_field_names = list(first_row.keys()) 

566 

567 # Retrieve primary key field information. pk_names is used to exclude PK fields from 

568 # the update operation (PK should not be updated during upsert), while pk_fields is 

569 # used as the conflict target for the database. 

570 pk_names, pk_fields = get_primary_key_fields(meta_cls) 

571 

572 # Build the list of fields to update during the upsert. 

573 # We need to: 

574 # 1. Remove duplicates (seen_names set) 

575 # 2. Exclude primary key fields (they're used for conflict detection, not update) 

576 # 3. Verify the field actually exists in the model (field_obj may be None) 

577 seen_fields: set[Field] = set() 

578 update_fields: list[tuple[Field, str]] = [] 

579 for field_name in insert_field_names: 

580 # Get the actual field object from the model metadata 

581 field_obj = meta_cls._meta.combined.get(field_name) 

582 

583 if field_obj is None: 

584 raise ValueError(f'Unknown field/column: {field_name}') 

585 

586 # Skip duplicate field 

587 if field_obj in seen_fields: 

588 continue 

589 

590 seen_fields.add(field_obj) 

591 

592 # Skip primary key fields - they identify the row, not data to update 

593 if field_obj.name in pk_names: 

594 continue 

595 

596 update_fields.append((field_obj, field_name)) 

597 

598 # Create the bulk insert query using insert_many 

599 insert_query = cls.insert_many(rows_to_insert, fields=insert_field_names or None) 

600 

601 # Define a helper for MySQL's VALUES() syntax. 

602 # NOTE: VALUES() is deprecated in MySQL 8.0.20+, but still required by Peewee 

603 def _mysql_value_expression(field: Field) -> Any: 

604 return fn.VALUES(field) 

605 

606 # Handle database-specific on_conflict syntax: 

607 # - MySQL uses VALUES(field_name) to reference the values being inserted 

608 # - PostgreSQL and SQLite use EXCLUDED.field_name to reference rejected values 

609 if isinstance(db, MySQLDatabase): 

610 update_mapping = {field: _mysql_value_expression(field) for field, _ in update_fields} 

611 return insert_query.on_conflict(update=update_mapping) 

612 

613 # For PostgreSQL/SQLite, use EXCLUDED table to reference the rejected insert values 

614 update_mapping = {field: getattr(EXCLUDED, field.name) for field, _ in update_fields} 

615 return insert_query.on_conflict( 

616 conflict_target=pk_fields, 

617 update=update_mapping, 

618 ) 

619 

620 def to_dict( 

621 self, 

622 recurse: bool = True, 

623 backrefs: bool = False, 

624 only: list[str] | None = None, 

625 exclude: list[str] | None = None, 

626 **kwargs: Any, 

627 ) -> dict[str, Any]: 

628 """ 

629 Convert model instance to dictionary with optional parameters 

630 

631 See full documentation directly on the `peewee documentation 

632 <https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#dict_to_model>`__. 

633 

634 :param recurse: If to recurse through foreign keys. Default to True. 

635 :type recurse: bool, Optional 

636 :param backrefs: If to include backrefs. Default to False. 

637 :type backrefs: bool, Optional 

638 :param only: A list of fields to be included. Defaults to None. 

639 :type only: list[str], Optional 

640 :param exclude: A list of fields to be excluded. Defaults to None. 

641 :type exclude: list[str], Optional 

642 :param kwargs: Other keyword arguments to be passed to peewee `playhouse shortcut <https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#dict_to_model>`__. 

643 :return: A dictionary containing the key/value of the model. 

644 :rtype: dict[str, Any] 

645 """ 

646 # the playhouse module of peewee is not typed. 

647 return model_to_dict( # type: ignore[no-any-return] 

648 self, 

649 recurse=recurse, 

650 backrefs=backrefs, # type: ignore[no-untyped-call] 

651 only=only, 

652 exclude=exclude, 

653 **kwargs, 

654 ) 

655 

656 @classmethod 

657 def from_dict(cls, data: dict[str, Any], ignore_unknown: bool = False) -> 'MAFwBaseModel': 

658 """ 

659 Create a new model instance from dictionary 

660 

661 :param data: The dictionary containing the key/value pairs of the model. 

662 :type data: dict[str, Any] 

663 :param ignore_unknown: If unknown dictionary keys should be ignored. 

664 :type ignore_unknown: bool 

665 :return: A new model instance. 

666 :rtype: MAFwBaseModel 

667 """ 

668 # the playhouse module of peewee is not typed. 

669 return dict_to_model(cls, data, ignore_unknown=ignore_unknown) # type: ignore[no-untyped-call,no-any-return] 

670 

671 def update_from_dict(self, data: dict[str, Any], ignore_unknown: bool = False) -> 'MAFwBaseModel': 

672 """ 

673 Update current model instance from dictionary 

674 

675 The model instance is returned for daisy-chaining. 

676 

677 :param data: The dictionary containing the key/value pairs of the model. 

678 :type data: dict[str, Any] 

679 :param ignore_unknown: If unknown dictionary keys should be ignored. 

680 :type ignore_unknown: bool 

681 """ 

682 update_model_from_dict(self, data, ignore_unknown=ignore_unknown) # type: ignore[no-untyped-call] 

683 return self 

684 

685 @classmethod 

686 def join_dependent_models(cls, base_query: Select | None = None) -> Select: 

687 """Return a query that joins every model referenced through foreign keys. 

688 

689 Each foreign-key relation defined on ``cls`` is joined and attached to the 

690 resulting rows via an attribute named ``_<field name>`` so that callers have 

691 the same convenient access that manual ``.join_from`` calls would have 

692 provided. 

693 

694 :param base_query: Optional query to extend. If not provided a new ``Select`` 

695 is created that includes ``cls`` and the dependent models. 

696 :type base_query: :class:`peewee.Select`, optional 

697 :return: The query joining every dependent model. 

698 :rtype: :class:`peewee.Select` 

699 """ 

700 if TYPE_CHECKING: 

701 assert hasattr(cls, '_meta') 

702 

703 foreign_keys = [field for field in cls._meta.fields.values() if isinstance(field, ForeignKeyField)] 

704 

705 if len(foreign_keys) == 0: 

706 return base_query or cls.select(cls) 

707 

708 dependent_models: list[type[ModelBase]] = [] 

709 for fk in foreign_keys: 

710 rel_model = fk.rel_model 

711 if rel_model not in dependent_models: 

712 dependent_models.append(rel_model) 

713 

714 query: Select = base_query or cls.select(cls, *dependent_models) 

715 

716 for idx, fk in enumerate(foreign_keys): 

717 query = query.join(fk.rel_model, attr=f'_{fk.name}') # type: ignore[call-arg] 

718 if idx != len(foreign_keys) - 1: 

719 query = query.switch(cls) # type: ignore[attr-defined] 

720 return query 

721 

722 class Meta: 

723 """The metadata container for the Model class""" 

724 

725 database = database_proxy 

726 """The reference database. A proxy is used as a placeholder that will be automatically replaced by the real  

727 instance of the database at runtime.""" 

728 

729 model_metadata_class = ThreadSafeDatabaseMetadata 

730 """Thread-safe metadata storage to allow runtime database binding across threads.""" 

731 

732 legacy_table_names = False 

733 """ 

734 Set the default table name as the snake case of the Model camel case name. 

735  

736 So for example, a model named ThisIsMyTable will corresponds to a database table named this_is_my_table. 

737 """ 

738 

739 suffix = '' 

740 """ 

741 Set the value to append to the table name.  

742 """ 

743 

744 prefix = '' 

745 """ 

746 Set the value to prepend to the table name.  

747 """ 

748 

749 table_function = make_prefixed_suffixed_name 

750 """ 

751 Set the table naming function. 

752 """ 

753 

754 automatic_creation = True 

755 """ 

756 Whether the table linked to the model should be created automatically 

757 """ 

758 

759 file_trigger_auto_create = False 

760 """ 

761 Whether to automatically create triggers to delete files once a row with a FilenameField is removed 

762 """