plexus-python-pulse-common 1.1.75__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1638 @@
1
+ import datetime
2
+ from collections.abc import Iterable, Sequence
3
+ from typing import Protocol, Self
4
+
5
+ import pydantic as pdt
6
+ import sqlalchemy as sa
7
+ import sqlalchemy.dialects.postgresql as sa_pg
8
+ import sqlalchemy.dialects.sqlite as sa_sqlite
9
+ import sqlalchemy.exc as sa_exc
10
+ import sqlalchemy.orm as sa_orm
11
+ from plexus.common.utils.dbutils import dialects, orm_to_dict
12
+ from plexus.common.utils.iterutils import head
13
+ from sqlmodel import Field, SQLModel
14
+
15
+ from plexus.pulse.common.utils.datautils import validate_dt_timezone
16
+ from plexus.pulse.common.utils.jsonutils import json_datetime_encoder
17
+
18
+ __all__ = [
19
+ "compare_postgresql_types",
20
+ "compare_sqlite_types",
21
+ "model_name_of",
22
+ "print_model_results",
23
+ "validate_model_extended",
24
+ "collect_model_tables",
25
+ "model_copy_from",
26
+ "make_base_model",
27
+ "SequenceModelMixinProtocol",
28
+ "ChangingModelMixinProtocol",
29
+ "SnapshotModelMixinProtocol",
30
+ "RevisionModelMixinProtocol",
31
+ "SQLiteDateTime",
32
+ "model_sqn_type",
33
+ "model_datetime_tz_type",
34
+ "model_revision_type",
35
+ "SequenceModelMixin",
36
+ "ChangingModelMixin",
37
+ "SnapshotModelMixin",
38
+ "RevisionModelMixin",
39
+ "make_sequence_model_mixin",
40
+ "make_changing_model_mixin",
41
+ "make_snapshot_model_mixin",
42
+ "make_revision_model_mixin",
43
+ "sequence_model_mixin",
44
+ "changing_model_mixin",
45
+ "snapshot_model_mixin",
46
+ "revision_model_mixin",
47
+ "SequenceModel",
48
+ "ChangingModel",
49
+ "SnapshotModel",
50
+ "RevisionModel",
51
+ "clone_sequence_model_instance",
52
+ "clone_changing_model_instance",
53
+ "clone_snapshot_model_instance",
54
+ "clone_revision_model_instance",
55
+ "make_snapshot_model_trigger",
56
+ "make_revision_model_trigger",
57
+ "db_make_order_by_clause",
58
+ "db_create_sequence_model",
59
+ "db_create_sequence_models",
60
+ "db_read_sequence_model",
61
+ "db_read_sequence_models",
62
+ "db_update_sequence_model",
63
+ "db_delete_sequence_model",
64
+ "db_create_changing_model",
65
+ "db_create_changing_models",
66
+ "db_update_changing_model",
67
+ "db_create_snapshot_model",
68
+ "db_create_snapshot_models",
69
+ "db_read_snapshot_models_of_record",
70
+ "db_read_latest_snapshot_model_of_record",
71
+ "db_read_active_snapshot_model_of_record",
72
+ "db_read_expired_snapshot_models_of_record",
73
+ "db_read_latest_snapshot_models",
74
+ "db_read_active_snapshot_models",
75
+ "db_update_snapshot_model",
76
+ "db_expire_snapshot_model",
77
+ "db_activate_snapshot_model",
78
+ "db_create_revision_model",
79
+ "db_create_revision_models",
80
+ "db_read_revision_models_of_record",
81
+ "db_read_latest_revision_model_of_record",
82
+ "db_read_active_revision_model_of_record",
83
+ "db_read_expired_revision_models_of_record",
84
+ "db_read_latest_revision_models",
85
+ "db_read_active_revision_models",
86
+ "db_update_revision_model",
87
+ "db_expire_revision_model",
88
+ "db_activate_revision_model",
89
+ ]
90
+
91
+
92
+ def compare_postgresql_types(type_a, type_b) -> bool:
93
+ """
94
+ Compares two Postgresql-specific column types to determine if they are equivalent.
95
+ This includes types from sqlalchemy.dialects.postgresql like ARRAY, JSON, UUID, etc.
96
+ """
97
+ if not isinstance(type_a, type(type_b)):
98
+ return False
99
+ if isinstance(type_a, sa_pg.ARRAY):
100
+ return compare_postgresql_types(type_a.item_type, type_b.item_type)
101
+ if isinstance(type_a, (sa_pg.VARCHAR, sa_pg.CHAR, sa_pg.TEXT)):
102
+ return type_a.length == type_b.length
103
+ if isinstance(type_a, (sa_pg.TIMESTAMP, sa_pg.TIME)):
104
+ return type_a.timezone == type_b.timezone
105
+ if isinstance(type_a, sa_pg.NUMERIC):
106
+ return type_a.precision == type_b.precision and type_a.scale == type_b.scale
107
+ return type(type_a) in {
108
+ sa_pg.BOOLEAN,
109
+ sa_pg.INTEGER,
110
+ sa_pg.BIGINT,
111
+ sa_pg.SMALLINT,
112
+ sa_pg.FLOAT,
113
+ sa_pg.DOUBLE_PRECISION,
114
+ sa_pg.REAL,
115
+ sa_pg.DATE,
116
+ sa_pg.UUID,
117
+ sa_pg.JSON,
118
+ sa_pg.JSONB,
119
+ sa_pg.HSTORE,
120
+ }
121
+
122
+
123
+ def compare_sqlite_types(type_a, type_b) -> bool:
124
+ """
125
+ Compares two SQLite-specific column types to determine if they are equivalent.
126
+ This includes types from sqlalchemy.dialects.sqlite like JSON, etc.
127
+ """
128
+ if not isinstance(type_a, type(type_b)):
129
+ return False
130
+ if isinstance(type_a, (sa_sqlite.VARCHAR, sa_sqlite.CHAR, sa_sqlite.TEXT)):
131
+ return type_a.length == type_b.length
132
+ if isinstance(type_a, (sa_sqlite.TIMESTAMP, sa_sqlite.TIME)):
133
+ return type_a.timezone == type_b.timezone
134
+ if isinstance(type_a, (sa_sqlite.NUMERIC, sa_sqlite.DECIMAL)):
135
+ return type_a.precision == type_b.precision and type_a.scale == type_b.scale
136
+ return type(type_a) in {
137
+ sa_sqlite.BOOLEAN,
138
+ sa_sqlite.INTEGER,
139
+ sa_sqlite.SMALLINT,
140
+ sa_sqlite.FLOAT,
141
+ sa_sqlite.REAL,
142
+ sa_sqlite.DATE,
143
+ sa_sqlite.JSON,
144
+ }
145
+
146
+
147
+ def model_name_of(model: type[SQLModel] | SQLModel, fallback_classname: bool = True) -> str | None:
148
+ table_name = getattr(model, "__tablename__")
149
+ if not table_name and fallback_classname:
150
+ return getattr(model, "__name__") if isinstance(model, type) else getattr(model.__class__, "__name__")
151
+ return table_name
152
+
153
+
154
+ def print_model_results(results: Iterable[SQLModel | Sequence[SQLModel]]):
155
+ """
156
+ Pretty-print an iterable of ``SQLModel`` instances or sequences of ``SQLModel`` instances in a tabular format
157
+ using the ``rich`` library.
158
+
159
+ :param results: An iterable of ``SQLModel`` instances or sequences of ``SQLModel`` instances to print.
160
+ """
161
+ results = list(results)
162
+ if not results:
163
+ print("Query results: (empty)")
164
+ return
165
+
166
+ first_result = head(results)
167
+
168
+ cols = [f"{model_name_of(db_model)}.{key}"
169
+ for db_model in (first_result if isinstance(first_result, Sequence) else [first_result])
170
+ for key in orm_to_dict(db_model).keys()]
171
+
172
+ rows = [
173
+ {f"{model_name_of(db_model)}.{key}": str(value)
174
+ for db_model in (result if isinstance(result, Sequence) else (result,))
175
+ for key, value in orm_to_dict(db_model).items()}
176
+ for result in results
177
+ ]
178
+
179
+ col_widths = [len(col) for col in cols]
180
+ for row in rows:
181
+ for i, col in enumerate(cols):
182
+ col_widths[i] = max(col_widths[i], len(str(row.get(col, ""))))
183
+
184
+ from rich.console import Console
185
+ from rich.table import Table
186
+
187
+ console = Console()
188
+ table = Table(show_header=True)
189
+ for col, col_width in zip(cols, col_widths):
190
+ table.add_column(col, min_width=col_width)
191
+ for row in rows:
192
+ table.add_row(*[str(row.get(col)) for col in cols])
193
+
194
+ console.print(table, crop=False)
195
+
196
+
197
+ def validate_model_extended(model_base: type[SQLModel], model_extended: type[SQLModel]) -> bool:
198
+ """
199
+ Validates if ``model_extended`` is an extension of ``model_base`` by checking if all fields in ``model_base``
200
+ are present in ``model_extended`` with compatible types.
201
+
202
+ :param model_base: The base model class to compare against.
203
+ :param model_extended: The model class that is expected to extend the base model.
204
+ :return: True if ``model_extended`` extends ``model_base`` correctly, False otherwise.
205
+ """
206
+ columns_a = {column.name: column.type for column in model_base.__table__.columns}
207
+ columns_b = {column.name: column.type for column in model_extended.__table__.columns}
208
+
209
+ for field_a, field_a_type in columns_a.items():
210
+ field_b_type = columns_b.get(field_a)
211
+ if field_b_type is None or not compare_postgresql_types(field_a_type, field_b_type):
212
+ return False
213
+ return True
214
+
215
+
216
+ def collect_model_tables[ModelT: SQLModel](*models: ModelT) -> sa.MetaData:
217
+ metadata = sa.MetaData()
218
+ for base in models:
219
+ for table in base.metadata.tables.values():
220
+ table.to_metadata(metadata)
221
+ return metadata
222
+
223
+
224
+ def model_copy_from[ModelT: SQLModel, ModelU: SQLModel](dst: ModelT, src: ModelU, **kwargs) -> ModelT:
225
+ if not isinstance(dst, SQLModel) or not isinstance(src, SQLModel):
226
+ raise TypeError("both 'dst' and 'src' must be instances of SQLModel or its subclasses")
227
+
228
+ for field, value in src.model_dump(**kwargs).items():
229
+ if field not in dst.model_fields:
230
+ continue
231
+ # Skip fields that are not present in the destination model
232
+ if value is None and dst.model_fields[field].required:
233
+ raise ValueError(f"field '{field}' is required but got None")
234
+
235
+ # Only set the field if it exists in the destination model
236
+ if hasattr(dst, field):
237
+ # If the field is a SQLModel, recursively copy it
238
+ if isinstance(value, SQLModel):
239
+ value = model_copy_from(getattr(dst, field), value, **kwargs)
240
+ elif isinstance(value, list) and all(isinstance(item, SQLModel) for item in value):
241
+ value = [model_copy_from(dst_item, src_item, **kwargs)
242
+ for dst_item, src_item in zip(getattr(dst, field), value)]
243
+
244
+ setattr(dst, field, value)
245
+
246
+ return dst
247
+
248
+
249
+ def make_base_model() -> type[SQLModel]:
250
+ """
251
+ Creates a base SQLModel class with custom metadata and JSON encoding for datetime fields.
252
+ Use this as a base for all models that require these configurations.
253
+ """
254
+
255
+ class BaseModel(SQLModel):
256
+ metadata = sa.MetaData()
257
+
258
+ return BaseModel
259
+
260
+
261
+ class SequenceModelMixinProtocol(Protocol):
262
+ sqn: sa_orm.Mapped[int | None]
263
+
264
+
265
+ class ChangingModelMixinProtocol(SequenceModelMixinProtocol):
266
+ created_at: sa_orm.Mapped[datetime.datetime | None]
267
+ updated_at: sa_orm.Mapped[datetime.datetime | None]
268
+
269
+ @classmethod
270
+ def make_index_created_at(cls, index_name: str) -> sa.Index:
271
+ """
272
+ Helper to create an index on the ``created_at`` field with the given index name.
273
+
274
+ :param index_name: Name of the index to create.
275
+ :return: The created SQLAlchemy Index object.
276
+ """
277
+ ...
278
+
279
+
280
+ class SnapshotModelMixinProtocol(SequenceModelMixinProtocol):
281
+ created_at: sa_orm.Mapped[datetime.datetime | None]
282
+ expired_at: sa_orm.Mapped[datetime.datetime | None]
283
+ record_sqn: sa_orm.Mapped[int | None]
284
+
285
+ @classmethod
286
+ def make_index_created_at_expired_at(cls, index_name: str) -> sa.Index:
287
+ """
288
+ Helper to create an index on the ``created_at`` and ``expired_at`` fields with the given index name.
289
+
290
+ :param index_name: Name of the index to create.
291
+ :return: The created SQLAlchemy Index object.
292
+ """
293
+ ...
294
+
295
+ @classmethod
296
+ def make_active_unique_index_record_sqn(cls, index_name: str) -> sa.Index:
297
+ """
298
+ Helper to create a unique index on the ``record_sqn`` field for active records (where ``expired_at`` is NULL).
299
+ This ensures that there is only one active snapshot per record at any given time.
300
+
301
+ :param index_name: Name of the index to create.
302
+ :return: The created SQLAlchemy Index object.
303
+ """
304
+ ...
305
+
306
+ @classmethod
307
+ def make_active_index_for(cls, index_name: str, *fields: str) -> sa.Index:
308
+ """
309
+ Helper to create a non-unique index on the specified fields for active records (where ``expired_at`` is NULL).
310
+ This allows efficient querying of active snapshots based on the specified fields.
311
+
312
+ :param index_name: Name of the index to create.
313
+ :param fields: Fields to include in the index.
314
+ :return: The created SQLAlchemy Index object.
315
+ """
316
+ ...
317
+
318
+ @classmethod
319
+ def make_active_unique_index_for(cls, index_name: str, *fields: str) -> sa.Index:
320
+ """
321
+ Helper to create a unique index on the specified fields for active records (where ``expired_at`` is NULL).
322
+ This ensures that there is only one active snapshot per combination of the specified fields at any given
323
+ time.
324
+
325
+ :param index_name: Name of the index to create.
326
+ :param fields: Fields to include in the unique index.
327
+ :return: The created SQLAlchemy Index object.
328
+ """
329
+ ...
330
+
331
+
332
+ class RevisionModelMixinProtocol(SequenceModelMixinProtocol):
333
+ created_at: sa_orm.Mapped[datetime.datetime | None]
334
+ updated_at: sa_orm.Mapped[datetime.datetime | None]
335
+ expired_at: sa_orm.Mapped[datetime.datetime | None]
336
+ record_sqn: sa_orm.Mapped[int | None]
337
+ revision: sa_orm.Mapped[int | None]
338
+
339
+ @classmethod
340
+ def make_index_created_at_updated_at_expired_at(cls, index_name: str) -> sa.Index:
341
+ """
342
+ Helper to create an index on the ``created_at``, ``updated_at``, and ``expired_at`` fields with the given
343
+ index name.
344
+
345
+ :param index_name: Name of the index to create.
346
+ :return: The created SQLAlchemy Index object.
347
+ """
348
+ ...
349
+
350
+ @classmethod
351
+ def make_unique_index_record_sqn_revision(cls, index_name: str) -> sa.Index:
352
+ """
353
+ Helper to create a unique index on the ``record_sqn`` and ``revision`` fields.
354
+ This ensures that each revision number is unique per record.
355
+
356
+ :param index_name: Name of the index to create.
357
+ :return: The created SQLAlchemy Index object.
358
+ """
359
+ ...
360
+
361
+ @classmethod
362
+ def make_active_unique_index_record_sqn(cls, index_name: str) -> sa.Index:
363
+ """
364
+ Helper to create a unique index on the ``record_sqn`` field for active records (where ``expired_at`` is NULL).
365
+ This ensures that there is only one active revision per record at any given time.
366
+
367
+ :param index_name: Name of the index to create.
368
+ :return: The created SQLAlchemy Index object.
369
+ """
370
+ ...
371
+
372
+ @classmethod
373
+ def make_active_index_for(cls, index_name: str, *fields: str) -> sa.Index:
374
+ """
375
+ Helper to create a non-unique index on the specified fields for active records (where ``expired_at`` is NULL).
376
+ This allows efficient querying of active revisions based on the specified fields.
377
+
378
+ :param index_name: Name of the index to create.
379
+ :param fields: Fields to include in the index.
380
+ :return: The created SQLAlchemy Index object.
381
+ """
382
+ ...
383
+
384
+ @classmethod
385
+ def make_active_unique_index_for(cls, index_name: str, *fields: str) -> sa.Index:
386
+ """
387
+ Helper to create a unique index on the specified fields for active records (where ``expired_at`` is NULL).
388
+ This ensures that there is only one active revision per combination of the specified fields at any given
389
+ time.
390
+
391
+ :param index_name: Name of the index to create.
392
+ :param fields: Fields to include in the unique index.
393
+ :return: The created SQLAlchemy Index object.
394
+ """
395
+ ...
396
+
397
+
398
+ class SQLiteDateTime(sa.TypeDecorator):
399
+ """
400
+ Custom SQLAlchemy type decorator for handling timezone-aware datetimes in SQLite.
401
+ """
402
+ impl = sa.DateTime
403
+ cache_ok = True
404
+
405
+ def process_bind_param(self, value: datetime.datetime | None, dialect) -> datetime.datetime | None:
406
+ if value is None:
407
+ return None
408
+ if value.tzinfo is None:
409
+ return value.replace(tzinfo=datetime.timezone.utc)
410
+ if value.tzinfo == datetime.timezone.utc:
411
+ return value
412
+ raise ValueError("Only UTC timezone-aware datetimes are supported")
413
+
414
+ def process_result_value(self, value: datetime.datetime | None, dialect) -> datetime.datetime | None:
415
+ if value is None:
416
+ return None
417
+ return value.replace(tzinfo=datetime.timezone.utc)
418
+
419
+
420
+ def model_sqn_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
421
+ """
422
+ Returns the appropriate SQLAlchemy column type for a sequence number (sqn) based on the specified database dialect.
423
+
424
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
425
+ :return: The SQLAlchemy column type to use for the sqn field.
426
+ """
427
+ if dialect is None:
428
+ dialect = dialects.postgresql
429
+ if dialect == dialects.postgresql:
430
+ return sa_pg.BIGINT()
431
+ if dialect == dialects.sqlite:
432
+ return sa_sqlite.INTEGER()
433
+ raise ValueError(f"unsupported database dialect '{dialect}'")
434
+
435
+
436
+ def model_datetime_tz_type(dialect: str | None = None) -> sa.types.TypeEngine[datetime.datetime]:
437
+ """
438
+ Returns the appropriate SQLAlchemy column type for a timezone-aware datetime field based on the specified database
439
+ dialect.
440
+
441
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
442
+ :return: The SQLAlchemy column type to use for timezone-aware datetime fields.
443
+ """
444
+ if dialect is None:
445
+ dialect = dialects.postgresql
446
+ if dialect == dialects.postgresql:
447
+ return sa_pg.TIMESTAMP(timezone=True)
448
+ if dialect == dialects.sqlite:
449
+ return SQLiteDateTime
450
+ raise ValueError(f"unsupported database dialect '{dialect}'")
451
+
452
+
453
+ def model_revision_type(dialect: str | None = None) -> sa.types.TypeEngine[int]:
454
+ """
455
+ Returns the appropriate SQLAlchemy column type for a revision number field based on the specified database dialect.
456
+
457
+ :param dialect: The database dialect to determine the column type for. If None, defaults to PostgreSQL.
458
+ :return: The SQLAlchemy column type to use for revision number fields.
459
+ """
460
+ if dialect is None:
461
+ dialect = dialects.postgresql
462
+ if dialect == dialects.postgresql:
463
+ return sa_pg.INTEGER()
464
+ if dialect == dialects.sqlite:
465
+ return sa_sqlite.INTEGER()
466
+ raise ValueError(f"unsupported database dialect '{dialect}'")
467
+
468
+
469
+ # At the present time, we cannot express intersection of Protocol and SQLModel directly.
470
+ # Thus, we define union types here for the mixins.
471
+ SequenceModelMixin = SequenceModelMixinProtocol | pdt.BaseModel
472
+ ChangingModelMixin = ChangingModelMixinProtocol | pdt.BaseModel
473
+ SnapshotModelMixin = SnapshotModelMixinProtocol | pdt.BaseModel
474
+ RevisionModelMixin = RevisionModelMixinProtocol | pdt.BaseModel
475
+
476
+
477
+ def make_sequence_model_mixin(dialect: str | None = None) -> type[SequenceModelMixin]:
478
+ """
479
+ Creates a mixin class for SQLModel models that adds a unique identifier field ``sqn``.
480
+ Use this mixin to add an auto-incremented primary key to your models.
481
+
482
+ :param dialect: The database dialect to determine the column type for the ``sqn`` field.
483
+ :return: A mixin class that can be used with SQLModel models to add the ``sqn`` field.
484
+ """
485
+
486
+ class ModelMixin(pdt.BaseModel):
487
+ sqn: int | None = Field(
488
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
489
+ default=None,
490
+ description="Unique auto-incremented primary key for the record",
491
+ )
492
+
493
+ return ModelMixin
494
+
495
+
496
+ def make_changing_model_mixin(dialect: str | None = None) -> type[ChangingModelMixin]:
497
+ """
498
+ Creates a mixin class for SQLModel models that adds common fields and validation logic for updatable records.
499
+ This mixin includes ``sqn``, ``created_at``, and ``updated_at`` fields, along with validation for timestamps.
500
+
501
+ :param dialect: The database dialect to determine the column types for the fields.
502
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
503
+ updatable records.
504
+ """
505
+
506
+ class ModelMixin(pdt.BaseModel):
507
+ sqn: int | None = Field(
508
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
509
+ default=None,
510
+ description="Unique auto-incremented primary key for the record",
511
+ )
512
+ created_at: datetime.datetime | None = Field(
513
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
514
+ default=None,
515
+ description="Timestamp (with timezone) when the record was created",
516
+ )
517
+ updated_at: datetime.datetime | None = Field(
518
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
519
+ default=None,
520
+ description="Timestamp (with timezone) when the record was last updated",
521
+ )
522
+
523
+ @pdt.field_validator("created_at", mode="after")
524
+ @classmethod
525
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
526
+ if v is not None:
527
+ validate_dt_timezone(v)
528
+ return v
529
+
530
+ @pdt.field_validator("updated_at", mode="after")
531
+ @classmethod
532
+ def validate_updated_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
533
+ if v is not None:
534
+ validate_dt_timezone(v)
535
+ return v
536
+
537
+ @pdt.model_validator(mode="after")
538
+ def validate_created_at_updated_at(self) -> Self:
539
+ if self.created_at is not None and self.updated_at is not None and self.created_at > self.updated_at:
540
+ raise ValueError(f"create time '{self.created_at}' is greater than update time '{self.updated_at}'")
541
+ return self
542
+
543
+ @pdt.field_serializer("created_at", mode="plain")
544
+ def serialize_created_at(self, v: datetime.datetime | None) -> str | None:
545
+ return json_datetime_encoder(v) if v is not None else None
546
+
547
+ @pdt.field_serializer("updated_at", mode="plain")
548
+ def serialize_updated_at(self, v: datetime.datetime | None) -> str | None:
549
+ return json_datetime_encoder(v) if v is not None else None
550
+
551
+ @classmethod
552
+ def make_index_created_at(cls, index_name: str) -> sa.Index:
553
+ return sa.Index(index_name, "created_at")
554
+
555
+ return ModelMixin
556
+
557
+
558
+ def make_snapshot_model_mixin(dialect: str | None = None) -> type[SnapshotModelMixin]:
559
+ """
560
+ Provides a mixin class for SQLModel models that adds common fields and validation logic for record snapshots.
561
+ A snapshot model tracks the full change history of an entity: when any field changes, the current record (with a
562
+ NULL expiration time) is updated to set its expiration time, and a new record with the updated values is created.
563
+
564
+ The mixin includes the following fields:
565
+ - ``sqn``: Unique, auto-incremented primary key identifying each snapshot of the record in the change history.
566
+ - ``created_at``: Time (with timezone) when this snapshot of the record was created and became active.
567
+ - ``expired_at``: Time (with timezone) when this snapshot of the record was superseded or became inactive;
568
+ ``None`` if still active.
569
+ - ``record_sqn``: Foreign key to the record this snapshot belongs to; used to link snapshots together.
570
+
571
+ :param dialect: The database dialect to determine the column types for the fields.
572
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
573
+ record snapshots.
574
+ """
575
+
576
+ class ModelMixin(pdt.BaseModel):
577
+ sqn: int | None = Field(
578
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
579
+ default=None,
580
+ description="Unique auto-incremented primary key for each record snapshot",
581
+ )
582
+ created_at: datetime.datetime | None = Field(
583
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
584
+ default=None,
585
+ description="Timestamp (with timezone) when this record snapshot became active",
586
+ )
587
+ expired_at: datetime.datetime | None = Field(
588
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
589
+ default=None,
590
+ description="Timestamp (with timezone) when this record snapshot became inactive; None if still active",
591
+ )
592
+ record_sqn: int | None = Field(
593
+ sa_column=sa.Column(model_sqn_type(dialect), nullable=True),
594
+ default=None,
595
+ description="Foreign key to the record this snapshot belongs to",
596
+ )
597
+
598
+ @pdt.field_validator("created_at", mode="after")
599
+ @classmethod
600
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
601
+ if v is not None:
602
+ validate_dt_timezone(v)
603
+ return v
604
+
605
+ @pdt.field_validator("expired_at", mode="after")
606
+ @classmethod
607
+ def validate_expired_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
608
+ if v is not None:
609
+ validate_dt_timezone(v)
610
+ return v
611
+
612
+ @pdt.model_validator(mode="after")
613
+ def validate_created_at_expired_at(self) -> Self:
614
+ if self.created_at is not None and self.expired_at is not None and self.created_at > self.expired_at:
615
+ raise ValueError(f"create time '{self.created_at}' is greater than expire time '{self.expired_at}'")
616
+ return self
617
+
618
+ @pdt.field_serializer("created_at", mode="plain")
619
+ def serialize_created_at(self, v: datetime.datetime | None) -> str | None:
620
+ return json_datetime_encoder(v) if v is not None else None
621
+
622
+ @pdt.field_serializer("expired_at", mode="plain")
623
+ def serialize_expired_at(self, v: datetime.datetime | None) -> str | None:
624
+ return json_datetime_encoder(v) if v is not None else None
625
+
626
+ @classmethod
627
+ def make_index_created_at_expired_at(cls, index_name: str) -> sa.Index:
628
+ return sa.Index(index_name, "created_at", "expired_at")
629
+
630
+ @classmethod
631
+ def make_active_unique_index_record_sqn(cls, index_name: str) -> sa.Index:
632
+ return sa.Index(
633
+ index_name,
634
+ "record_sqn",
635
+ unique=True,
636
+ postgresql_where=sa.text('"expired_at" IS NULL'),
637
+ )
638
+
639
+ @classmethod
640
+ def make_active_index_for(cls, index_name: str, *fields: str) -> sa.Index:
641
+ return sa.Index(
642
+ index_name,
643
+ *fields,
644
+ postgresql_where=sa.text('"expired_at" IS NULL'),
645
+ )
646
+
647
+ @classmethod
648
+ def make_active_unique_index_for(cls, index_name: str, *fields: str) -> sa.Index:
649
+ return sa.Index(
650
+ index_name,
651
+ *fields,
652
+ unique=True,
653
+ postgresql_where=sa.text('"expired_at" IS NULL'),
654
+ )
655
+
656
+ return ModelMixin
657
+
658
+
659
+ def make_revision_model_mixin(dialect: str | None = None) -> type[RevisionModelMixin]:
660
+ """
661
+ Provides a mixin class for SQLModel models that adds common fields and validation logic for record revisions.
662
+ A revision model tracks the full change history of an entity: when any field changes, the current record (with a
663
+ NULL expiration time) is updated to set its expiration time, and a new record with the updated values is created.
664
+
665
+ The mixin includes the following fields:
666
+ - ``sqn``: Unique, auto-incremented primary key identifying each revision of the record in the change history.
667
+ - ``created_at``: Time (with timezone) when the record was first created.
668
+ - ``updated_at``: Time (with timezone) when the record was updated and this record revision became active.
669
+ - ``expired_at``: Time (with timezone) when this revision of the record was superseded or became inactive;
670
+ ``None`` if still active.
671
+ - ``record_sqn``: Auto-incremented key of the record this revision belongs to; used to link revisions together.
672
+ - ``revision``: Revision number for the record, used to track changes over time.
673
+
674
+ :param dialect: The database dialect to determine the column types for the fields.
675
+ :return: A mixin class that can be used with SQLModel models to add the common fields and validation logic for
676
+ record revisions.
677
+ """
678
+
679
+ class ModelMixin(pdt.BaseModel):
680
+ sqn: int | None = Field(
681
+ sa_column=sa.Column(model_sqn_type(dialect), primary_key=True, autoincrement=True),
682
+ default=None,
683
+ description="Unique auto-incremented primary key for each record revision",
684
+ )
685
+ created_at: datetime.datetime | None = Field(
686
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
687
+ default=None,
688
+ description="Timestamp (with timezone) when this record is first created (preserved across revisions)",
689
+ )
690
+ updated_at: datetime.datetime | None = Field(
691
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
692
+ default=None,
693
+ description="Timestamp (with timezone) when this record is updated and this record revision became active",
694
+ )
695
+ expired_at: datetime.datetime | None = Field(
696
+ sa_column=sa.Column(model_datetime_tz_type(dialect)),
697
+ default=None,
698
+ description="Timestamp (with timezone) when this record revision became inactive; None if still active",
699
+ )
700
+ record_sqn: int | None = Field(
701
+ sa_column=sa.Column(model_sqn_type(dialect), nullable=True),
702
+ default=None,
703
+ description="Auto-incremented key of the record this revision belongs to",
704
+ )
705
+ revision: int | None = Field(
706
+ sa_column=sa.Column(model_revision_type(dialect), nullable=True),
707
+ default=None,
708
+ description="Revision number for the record",
709
+ )
710
+
711
+ @pdt.field_validator("created_at", mode="after")
712
+ @classmethod
713
+ def validate_created_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
714
+ if v is not None:
715
+ validate_dt_timezone(v)
716
+ return v
717
+
718
+ @pdt.field_validator("updated_at", mode="after")
719
+ @classmethod
720
+ def validate_updated_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
721
+ if v is not None:
722
+ validate_dt_timezone(v)
723
+ return v
724
+
725
+ @pdt.field_validator("expired_at", mode="after")
726
+ @classmethod
727
+ def validate_expired_at(cls, v: datetime.datetime | None) -> datetime.datetime | None:
728
+ if v is not None:
729
+ validate_dt_timezone(v)
730
+ return v
731
+
732
+ @pdt.field_validator("revision", mode="after")
733
+ @classmethod
734
+ def validate_revision(cls, v: int | None) -> int | None:
735
+ if v is not None and not v > 0:
736
+ raise ValueError("revision number must be positive integer")
737
+ return v
738
+
739
+ @pdt.model_validator(mode="after")
740
+ def validate_created_at_updated_at_expired_at(self) -> Self:
741
+ if self.created_at is not None and self.updated_at is not None and self.created_at > self.updated_at:
742
+ raise ValueError(f"create time '{self.created_at}' is greater than update time '{self.updated_at}'")
743
+ if self.updated_at is not None and self.expired_at is not None and self.updated_at > self.expired_at:
744
+ raise ValueError(f"update time '{self.updated_at}' is greater than expire time '{self.expired_at}'")
745
+ return self
746
+
747
+ @pdt.field_serializer("created_at", mode="plain")
748
+ def serialize_created_at(self, v: datetime.datetime | None) -> str | None:
749
+ return json_datetime_encoder(v) if v is not None else None
750
+
751
+ @pdt.field_serializer("updated_at", mode="plain")
752
+ def serialize_updated_at(self, v: datetime.datetime | None) -> str | None:
753
+ return json_datetime_encoder(v) if v is not None else None
754
+
755
+ @pdt.field_serializer("expired_at", mode="plain")
756
+ def serialize_expired_at(self, v: datetime.datetime | None) -> str | None:
757
+ return json_datetime_encoder(v) if v is not None else None
758
+
759
+ @classmethod
760
+ def make_index_created_at_updated_at_expired_at(cls, index_name: str) -> sa.Index:
761
+ return sa.Index(index_name, "created_at", "updated_at", "expired_at")
762
+
763
+ @classmethod
764
+ def make_unique_index_record_sqn_revision(cls, index_name: str) -> sa.Index:
765
+ return sa.Index(index_name, "record_sqn", "revision", unique=True)
766
+
767
+ @classmethod
768
+ def make_active_unique_index_record_sqn(cls, index_name: str) -> sa.Index:
769
+ return sa.Index(
770
+ index_name,
771
+ "record_sqn",
772
+ unique=True,
773
+ postgresql_where=sa.text('"expired_at" IS NULL'),
774
+ )
775
+
776
+ @classmethod
777
+ def make_active_index_for(cls, index_name: str, *fields: str) -> sa.Index:
778
+ return sa.Index(
779
+ index_name,
780
+ *fields,
781
+ postgresql_where=sa.text('"expired_at" IS NULL'),
782
+ )
783
+
784
+ @classmethod
785
+ def make_active_unique_index_for(cls, index_name: str, *fields: str) -> sa.Index:
786
+ return sa.Index(
787
+ index_name,
788
+ *fields,
789
+ unique=True,
790
+ postgresql_where=sa.text('"expired_at" IS NULL'),
791
+ )
792
+
793
+ return ModelMixin
794
+
795
+
796
+ sequence_model_mixin = make_sequence_model_mixin()
797
+ changing_model_mixin = make_changing_model_mixin()
798
+ snapshot_model_mixin = make_snapshot_model_mixin()
799
+ revision_model_mixin = make_revision_model_mixin()
800
+
801
+
802
+ class SequenceModel(make_base_model(), make_sequence_model_mixin(), table=True):
803
+ pass
804
+
805
+
806
+ class ChangingModel(make_base_model(), make_changing_model_mixin(), table=True):
807
+ pass
808
+
809
+
810
+ class SnapshotModel(make_base_model(), make_snapshot_model_mixin(), table=True):
811
+ pass
812
+
813
+
814
+ class RevisionModel(make_base_model(), make_revision_model_mixin(), table=True):
815
+ pass
816
+
817
+
818
+ def make_snapshot_model_trigger[SnapshotModelT: SnapshotModelMixin](engine: sa.Engine, model: type[SnapshotModelT]):
819
+ """
820
+ Creates the necessary database objects (sequence, function, trigger) to support automatic snapshot management
821
+ for the given snapshot model. This includes a sequence for ``record_sqn``, a function to handle snapshot updates,
822
+ and a trigger to invoke the function before inserts. The model must extend ``SnapshotModel``.
823
+
824
+ :param engine: SQLAlchemy engine connected to the target database.
825
+ :param model: The snapshot model class extending ``SnapshotModel``.
826
+ """
827
+ table_name = model_name_of(model, fallback_classname=False)
828
+ if not table_name:
829
+ raise ValueError("cannot determine table name from model")
830
+
831
+ if not validate_model_extended(SnapshotModel, model):
832
+ raise ValueError("not an extended model of 'SnapshotModel'")
833
+
834
+ record_sqn_seq_name = f"{table_name}_record_sqn_seq"
835
+ snapshot_auto_update_function_name = f"{table_name}_snapshot_auto_update_function"
836
+ snapshot_auto_update_trigger_name = f"{table_name}_snapshot_auto_update_trigger"
837
+
838
+ # language=postgresql
839
+ create_record_sqn_seq_sql = f"""
840
+ CREATE SEQUENCE "{record_sqn_seq_name}" START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
841
+ """
842
+
843
+ # language=postgresql
844
+ create_snapshot_auto_update_function_sql = f"""
845
+ CREATE FUNCTION "{snapshot_auto_update_function_name}"()
846
+ RETURNS TRIGGER AS $$
847
+ BEGIN
848
+ IF NEW."record_sqn" IS NULL THEN
849
+ IF NEW."created_at" IS NULL THEN
850
+ NEW."created_at" := CURRENT_TIMESTAMP;
851
+ END IF;
852
+
853
+ NEW."expired_at" := NULL;
854
+ NEW."record_sqn" := nextval('{record_sqn_seq_name}');
855
+ ELSE
856
+ IF NEW."created_at" IS NULL THEN
857
+ NEW."created_at" := CURRENT_TIMESTAMP;
858
+ END IF;
859
+
860
+ NEW."expired_at" := NULL;
861
+
862
+ UPDATE "{table_name}"
863
+ SET "expired_at" = NEW."created_at"
864
+ WHERE "record_sqn" = NEW."record_sqn" AND "expired_at" IS NULL;
865
+ END IF;
866
+
867
+ RETURN NEW;
868
+ END;
869
+ $$ LANGUAGE plpgsql;
870
+ """
871
+
872
+ # language=postgresql
873
+ create_snapshot_auto_update_trigger_sql = f"""
874
+ CREATE TRIGGER "{snapshot_auto_update_trigger_name}"
875
+ BEFORE INSERT ON "{table_name}"
876
+ FOR EACH ROW
877
+ EXECUTE FUNCTION "{snapshot_auto_update_function_name}"();
878
+ """
879
+
880
+ with engine.connect() as conn:
881
+ with conn.begin():
882
+ conn.execute(sa.text(create_record_sqn_seq_sql))
883
+ conn.execute(sa.text(create_snapshot_auto_update_function_sql))
884
+ conn.execute(sa.text(create_snapshot_auto_update_trigger_sql))
885
+
886
+
887
+ def make_revision_model_trigger[RevisionModelT: RevisionModelMixin](engine: sa.Engine, model: type[RevisionModelT]):
888
+ """
889
+ Creates the necessary database objects (sequence, function, trigger) to support automatic revision management
890
+ for the given revision model. This includes a sequence for ``record_sqn``, a function to handle revision updates,
891
+ and a trigger to invoke the function before inserts. The model must extend ``RevisionModel``.
892
+
893
+ :param engine: SQLAlchemy engine connected to the target database.
894
+ :param model: The revision model class extending ``RevisionModel``.
895
+ """
896
+ table_name = model_name_of(model, fallback_classname=False)
897
+ if not table_name:
898
+ raise ValueError("cannot determine table name from model")
899
+
900
+ if not validate_model_extended(RevisionModel, model):
901
+ raise ValueError("not an extended model of 'RevisionModel'")
902
+
903
+ record_sqn_seq_name = f"{table_name}_record_sqn_seq"
904
+ revision_auto_update_function_name = f"{table_name}_revision_auto_update_function"
905
+ revision_auto_update_trigger_name = f"{table_name}_revision_auto_update_trigger"
906
+
907
+ # language=postgresql
908
+ create_record_sqn_seq_sql = f"""
909
+ CREATE SEQUENCE "{record_sqn_seq_name}" START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
910
+ """
911
+
912
+ # language=postgresql
913
+ create_revision_auto_update_function_sql = f"""
914
+ CREATE FUNCTION "{revision_auto_update_function_name}"()
915
+ RETURNS TRIGGER AS $$
916
+ BEGIN
917
+ IF NEW."record_sqn" IS NULL THEN
918
+ IF NEW."created_at" IS NULL THEN
919
+ NEW."created_at" := CURRENT_TIMESTAMP;
920
+ END IF;
921
+
922
+ NEW."updated_at" := NEW."created_at";
923
+ NEW."expired_at" := NULL;
924
+ NEW."record_sqn" := nextval('{record_sqn_seq_name}');
925
+ NEW."revision" := 1;
926
+ ELSE
927
+ SELECT MAX("created_at") INTO NEW."created_at"
928
+ FROM "{table_name}"
929
+ WHERE "record_sqn" = NEW."record_sqn";
930
+
931
+ IF NEW."updated_at" IS NULL THEN
932
+ NEW."updated_at" := CURRENT_TIMESTAMP;
933
+ END IF;
934
+
935
+ NEW."expired_at" := NULL;
936
+
937
+ SELECT COALESCE(MAX("revision"), 0) + 1 INTO NEW."revision"
938
+ FROM "{table_name}"
939
+ WHERE "record_sqn" = NEW."record_sqn";
940
+
941
+ UPDATE "{table_name}"
942
+ SET "expired_at" = NEW."updated_at"
943
+ WHERE "record_sqn" = NEW."record_sqn" AND "expired_at" IS NULL;
944
+ END IF;
945
+
946
+ RETURN NEW;
947
+ END;
948
+ $$ LANGUAGE plpgsql;
949
+ """
950
+
951
+ # language=postgresql
952
+ create_revision_auto_update_trigger_sql = f"""
953
+ CREATE TRIGGER "{revision_auto_update_trigger_name}"
954
+ BEFORE INSERT ON "{table_name}"
955
+ FOR EACH ROW
956
+ EXECUTE FUNCTION "{revision_auto_update_function_name}"();
957
+ """
958
+
959
+ with engine.connect() as conn:
960
+ with conn.begin():
961
+ conn.execute(sa.text(create_record_sqn_seq_sql))
962
+ conn.execute(sa.text(create_revision_auto_update_function_sql))
963
+ conn.execute(sa.text(create_revision_auto_update_trigger_sql))
964
+
965
+
966
+ def clone_sequence_model_instance[SequenceModelT: SequenceModelMixin](
967
+ model: type[SequenceModelT],
968
+ instance: SequenceModelMixin,
969
+ *,
970
+ validate_only: bool = False,
971
+ clear_meta_fields: bool = True,
972
+ ) -> SequenceModelT:
973
+ result = model.model_validate(instance)
974
+ if validate_only:
975
+ return instance
976
+ if clear_meta_fields:
977
+ result.sqn = None
978
+ return result
979
+
980
+
981
+ def clone_changing_model_instance[ChangingModelT: ChangingModelMixin](
982
+ model: type[ChangingModelT],
983
+ instance: ChangingModelMixin,
984
+ *,
985
+ validate_only: bool = False,
986
+ clear_meta_fields: bool = True,
987
+ ) -> ChangingModelT:
988
+ result = model.model_validate(instance)
989
+ if validate_only:
990
+ return instance
991
+ if clear_meta_fields:
992
+ result.sqn = None
993
+ result.created_at = None
994
+ result.updated_at = None
995
+ return result
996
+
997
+
998
+ def clone_snapshot_model_instance[SnapshotModelT: SnapshotModelMixin](
999
+ model: type[SnapshotModelT],
1000
+ instance: SnapshotModelMixin,
1001
+ *,
1002
+ validate_only: bool = False,
1003
+ clear_meta_fields: bool = True,
1004
+ ) -> SnapshotModelT:
1005
+ result = model.model_validate(instance)
1006
+ if validate_only:
1007
+ return instance
1008
+ if clear_meta_fields:
1009
+ result.sqn = None
1010
+ result.created_at = None
1011
+ result.expired_at = None
1012
+ result.record_sqn = None
1013
+ return result
1014
+
1015
+
1016
+ def clone_revision_model_instance[RevisionModelT: RevisionModelMixin](
1017
+ model: type[RevisionModelT],
1018
+ instance: RevisionModelMixin,
1019
+ *,
1020
+ validate_only: bool = False,
1021
+ clear_meta_fields: bool = True,
1022
+ ) -> RevisionModelT:
1023
+ result = model.model_validate(instance)
1024
+ if validate_only:
1025
+ return instance
1026
+ if clear_meta_fields:
1027
+ result.sqn = None
1028
+ result.created_at = None
1029
+ result.updated_at = None
1030
+ result.expired_at = None
1031
+ result.record_sqn = None
1032
+ result.revision = None
1033
+ return result
1034
+
1035
+
1036
+ def db_make_order_by_clause[SequenceModelT: SequenceModelMixin](
1037
+ model: type[SequenceModelT],
1038
+ order_by: list[str] | None = None,
1039
+ ):
1040
+ order_criteria = []
1041
+ if order_by:
1042
+ for field in order_by:
1043
+ if field.startswith("-"):
1044
+ order_criteria.append(sa.desc(getattr(model, field[1:])))
1045
+ else:
1046
+ order_criteria.append(sa.asc(getattr(model, field)))
1047
+ else:
1048
+ order_criteria.append(model.sqn)
1049
+ return order_criteria
1050
+
1051
+
1052
+ def db_create_sequence_model[SequenceModelT: SequenceModelMixin](
1053
+ db: sa_orm.Session,
1054
+ model: type[SequenceModelT],
1055
+ instance: SequenceModelMixin,
1056
+ ) -> SequenceModelT:
1057
+ db_instance = clone_sequence_model_instance(model, instance)
1058
+ db.add(db_instance)
1059
+ db.flush()
1060
+
1061
+ return db_instance
1062
+
1063
+
1064
+ def db_create_sequence_models[SequenceModelT: SequenceModelMixin](
1065
+ db: sa_orm.Session,
1066
+ model: type[SequenceModelT],
1067
+ instances: list[SequenceModelMixin],
1068
+ ) -> list[SequenceModelT]:
1069
+ db_instances = [clone_sequence_model_instance(model, instance) for instance in instances]
1070
+ db.add_all(db_instances)
1071
+ db.flush()
1072
+
1073
+ return db_instances
1074
+
1075
+
1076
+ def db_read_sequence_model[SequenceModelT: SequenceModelMixin](
1077
+ db: sa_orm.Session,
1078
+ model: type[SequenceModelT],
1079
+ sqn: int,
1080
+ ) -> SequenceModelT:
1081
+ db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1082
+ if db_instance is None:
1083
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1084
+
1085
+ return db_instance
1086
+
1087
+
1088
+ def db_read_sequence_models[SequenceModelT: SequenceModelMixin](
1089
+ db: sa_orm.Session,
1090
+ model: type[SequenceModelT],
1091
+ skip: int | None = None,
1092
+ limit: int | None = None,
1093
+ order_by: list[str] | None = None,
1094
+ ) -> list[SequenceModelT]:
1095
+ query_stmt = db.query(model).order_by(*db_make_order_by_clause(model, order_by))
1096
+ if skip is not None:
1097
+ query_stmt = query_stmt.offset(skip)
1098
+ if limit is not None:
1099
+ query_stmt = query_stmt.limit(limit)
1100
+ return query_stmt.all()
1101
+
1102
+
1103
+ def db_update_sequence_model[SequenceModelT: SequenceModelMixin](
1104
+ db: sa_orm.Session,
1105
+ model: type[SequenceModelT],
1106
+ instance: SequenceModelMixin,
1107
+ sqn: int,
1108
+ ) -> SequenceModelT:
1109
+ db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1110
+ if db_instance is None:
1111
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1112
+
1113
+ db_instance = model_copy_from(db_instance, clone_sequence_model_instance(model, instance), exclude_none=True)
1114
+ clone_sequence_model_instance(model, db_instance, validate_only=True)
1115
+ db.flush()
1116
+
1117
+ return db_instance
1118
+
1119
+
1120
+ def db_delete_sequence_model[SequenceModelT: SequenceModelMixin](
1121
+ db: sa_orm.Session,
1122
+ model: type[SequenceModelT],
1123
+ sqn: int,
1124
+ ) -> SequenceModelT:
1125
+ db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1126
+ if db_instance is None:
1127
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1128
+
1129
+ db.delete(db_instance)
1130
+ db.flush()
1131
+
1132
+ return db_instance
1133
+
1134
+
1135
+ def db_create_changing_model[ChangingModelT: ChangingModelMixin](
1136
+ db: sa_orm.Session,
1137
+ model: type[ChangingModelT],
1138
+ instance: ChangingModelMixin,
1139
+ created_at: datetime.datetime | None = None,
1140
+ ) -> ChangingModelT:
1141
+ db_instance = clone_changing_model_instance(model, instance)
1142
+ db_instance.created_at = created_at
1143
+ db_instance.updated_at = created_at
1144
+ db.add(db_instance)
1145
+ db.flush()
1146
+
1147
+ return db_instance
1148
+
1149
+
1150
+ def db_create_changing_models[ChangingModelT: ChangingModelMixin](
1151
+ db: sa_orm.Session,
1152
+ model: type[ChangingModelT],
1153
+ instances: list[ChangingModelMixin],
1154
+ created_at: datetime.datetime | None = None,
1155
+ ) -> list[ChangingModelT]:
1156
+ db_instances = [clone_changing_model_instance(model, instance) for instance in instances]
1157
+ for db_instance in db_instances:
1158
+ db_instance.created_at = created_at
1159
+ db_instance.updated_at = created_at
1160
+ db.add_all(db_instances)
1161
+ db.flush()
1162
+
1163
+ return db_instances
1164
+
1165
+
1166
+ def db_update_changing_model[ChangingModelT: ChangingModelMixin](
1167
+ db: sa_orm.Session,
1168
+ model: type[ChangingModelT],
1169
+ instance: ChangingModelMixin,
1170
+ sqn: int,
1171
+ updated_at: datetime.datetime,
1172
+ ) -> ChangingModelT:
1173
+ db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1174
+ if db_instance is None:
1175
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1176
+
1177
+ db_instance = model_copy_from(db_instance, clone_changing_model_instance(model, instance), exclude_none=True)
1178
+ db_instance.updated_at = updated_at
1179
+ clone_changing_model_instance(model, db_instance, validate_only=True)
1180
+ db.flush()
1181
+
1182
+ return db_instance
1183
+
1184
+
1185
+ def db_create_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1186
+ db: sa_orm.Session,
1187
+ model: type[SnapshotModelT],
1188
+ instance: SnapshotModelMixin,
1189
+ created_at: datetime.datetime,
1190
+ ) -> SnapshotModelT:
1191
+ db_instance = clone_snapshot_model_instance(model, instance)
1192
+ db_instance.created_at = created_at
1193
+ db_instance.expired_at = None
1194
+ db.add(db_instance)
1195
+ db.flush()
1196
+
1197
+ db_instance.record_sqn = db_instance.sqn
1198
+ db.flush()
1199
+
1200
+ return db_instance
1201
+
1202
+
1203
+ def db_create_snapshot_models[SnapshotModelT: SnapshotModelMixin](
1204
+ db: sa_orm.Session,
1205
+ model: type[SnapshotModelT],
1206
+ instances: list[SnapshotModelMixin],
1207
+ created_at: datetime.datetime,
1208
+ ) -> list[SnapshotModelT]:
1209
+ db_instances = [clone_snapshot_model_instance(model, instance) for instance in instances]
1210
+ for db_instance in db_instances:
1211
+ db_instance.created_at = created_at
1212
+ db_instance.expired_at = None
1213
+ db.add_all(db_instances)
1214
+ db.flush()
1215
+
1216
+ for db_instance in db_instances:
1217
+ db_instance.record_sqn = db_instance.sqn
1218
+ db.flush()
1219
+
1220
+ return db_instances
1221
+
1222
+
1223
+ def db_read_snapshot_models_of_record[SnapshotModelT: SnapshotModelMixin](
1224
+ db: sa_orm.Session,
1225
+ model: type[SnapshotModelT],
1226
+ record_sqn: int,
1227
+ ) -> list[SnapshotModelT]:
1228
+ return (
1229
+ db
1230
+ .query(model)
1231
+ .where(model.record_sqn == record_sqn)
1232
+ .order_by(model.created_at.desc())
1233
+ .all()
1234
+ )
1235
+
1236
+
1237
+ def db_read_latest_snapshot_model_of_record[SnapshotModelT: SnapshotModelMixin](
1238
+ db: sa_orm.Session,
1239
+ model: type[SnapshotModelT],
1240
+ record_sqn: int,
1241
+ ) -> SnapshotModelT:
1242
+ db_instance = (
1243
+ db
1244
+ .query(model)
1245
+ .where(model.record_sqn == record_sqn)
1246
+ .order_by(model.created_at.desc())
1247
+ .first()
1248
+ )
1249
+ if db_instance is None:
1250
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1251
+
1252
+ return db_instance
1253
+
1254
+
1255
+ def db_read_active_snapshot_model_of_record[SnapshotModelT: SnapshotModelMixin](
1256
+ db: sa_orm.Session,
1257
+ model: type[SnapshotModelT],
1258
+ record_sqn: int,
1259
+ ) -> SnapshotModelT:
1260
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1261
+ if db_instance is None:
1262
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1263
+
1264
+ return db_instance
1265
+
1266
+
1267
+ def db_read_expired_snapshot_models_of_record[SnapshotModelT: SnapshotModelMixin](
1268
+ db: sa_orm.Session,
1269
+ model: type[SnapshotModelT],
1270
+ record_sqn: int,
1271
+ ) -> list[SnapshotModelT]:
1272
+ return (
1273
+ db
1274
+ .query(model)
1275
+ .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1276
+ .order_by(model.created_at.desc())
1277
+ .all()
1278
+ )
1279
+
1280
+
1281
+ def db_read_latest_snapshot_models[SnapshotModelT: SnapshotModelMixin](
1282
+ db: sa_orm.Session,
1283
+ model: type[SnapshotModelT],
1284
+ skip: int | None = None,
1285
+ limit: int | None = None,
1286
+ order_by: list[str] | None = None,
1287
+ ) -> list[SnapshotModelT]:
1288
+ subquery_stmt = (
1289
+ db
1290
+ .query(model.record_sqn,
1291
+ sa.func.max(model.created_at).label("max_created_at"))
1292
+ .group_by(model.record_sqn)
1293
+ .subquery()
1294
+ )
1295
+
1296
+ query_stmt = (
1297
+ db
1298
+ .query(model)
1299
+ .join(subquery_stmt,
1300
+ sa.and_(model.record_sqn == subquery_stmt.c.record_sqn,
1301
+ model.created_at == subquery_stmt.c.max_created_at))
1302
+ .order_by(*db_make_order_by_clause(model, order_by))
1303
+ )
1304
+ if skip is not None:
1305
+ query_stmt = query_stmt.offset(skip)
1306
+ if limit is not None:
1307
+ query_stmt = query_stmt.limit(limit)
1308
+ return query_stmt.all()
1309
+
1310
+
1311
+ def db_read_active_snapshot_models[SnapshotModelT: SnapshotModelMixin](
1312
+ db: sa_orm.Session,
1313
+ model: type[SnapshotModelT],
1314
+ skip: int | None = None,
1315
+ limit: int | None = None,
1316
+ order_by: list[str] | None = None,
1317
+ ) -> list[SnapshotModelT]:
1318
+ query_stmt = db.query(model).where(model.expired_at.is_(None)).order_by(*db_make_order_by_clause(model, order_by))
1319
+ if skip is not None:
1320
+ query_stmt = query_stmt.offset(skip)
1321
+ if limit is not None:
1322
+ query_stmt = query_stmt.limit(limit)
1323
+ return query_stmt.all()
1324
+
1325
+
1326
+ def db_update_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1327
+ db: sa_orm.Session,
1328
+ model: type[SnapshotModelT],
1329
+ instance: SnapshotModelMixin,
1330
+ record_sqn: int,
1331
+ updated_at: datetime.datetime,
1332
+ ) -> SnapshotModelT:
1333
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1334
+ if db_instance is None:
1335
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1336
+
1337
+ db_instance.expired_at = updated_at
1338
+ clone_snapshot_model_instance(model, db_instance, validate_only=True)
1339
+ db.flush()
1340
+
1341
+ db_new_instance = clone_snapshot_model_instance(model, instance)
1342
+ db_new_instance.record_sqn = record_sqn
1343
+ db_new_instance.created_at = updated_at
1344
+ db_new_instance.expired_at = None
1345
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1346
+ db.add(db_new_instance)
1347
+ db.flush()
1348
+
1349
+ return db_new_instance
1350
+
1351
+
1352
+ def db_expire_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1353
+ db: sa_orm.Session,
1354
+ model: type[SnapshotModelT],
1355
+ record_sqn: int,
1356
+ updated_at: datetime.datetime,
1357
+ ) -> SnapshotModelT:
1358
+ db_instance = (
1359
+ db
1360
+ .query(model)
1361
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1362
+ .one_or_none()
1363
+ )
1364
+ if db_instance is None:
1365
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1366
+
1367
+ db_instance.expired_at = updated_at
1368
+ clone_snapshot_model_instance(model, db_instance, validate_only=True)
1369
+ db.flush()
1370
+
1371
+ return db_instance
1372
+
1373
+
1374
+ def db_activate_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1375
+ db: sa_orm.Session,
1376
+ model: type[SnapshotModelT],
1377
+ record_sqn: int,
1378
+ updated_at: datetime.datetime,
1379
+ ) -> SnapshotModelT:
1380
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1381
+ if db_instance is not None:
1382
+ raise sa_exc.MultipleResultsFound(
1383
+ f"Active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' already exists")
1384
+
1385
+ db_instance = (
1386
+ db
1387
+ .query(model)
1388
+ .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1389
+ .order_by(model.created_at.desc())
1390
+ .first()
1391
+ )
1392
+ if db_instance is None:
1393
+ raise sa_exc.NoResultFound(f"expired '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1394
+
1395
+ db_new_instance = clone_snapshot_model_instance(model, db_instance)
1396
+ db_new_instance.record_sqn = record_sqn
1397
+ db_new_instance.created_at = db_instance.expired_at
1398
+ db_new_instance.expired_at = updated_at
1399
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1400
+ db_new_instance.created_at = updated_at
1401
+ db_new_instance.expired_at = None
1402
+ clone_snapshot_model_instance(model, db_new_instance, validate_only=True)
1403
+ db.add(db_new_instance)
1404
+ db.flush()
1405
+
1406
+ return db_new_instance
1407
+
1408
+
1409
+ def db_create_revision_model[RevisionModelT: RevisionModelMixin](
1410
+ db: sa_orm.Session,
1411
+ model: type[RevisionModelT],
1412
+ instance: RevisionModelMixin,
1413
+ created_at: datetime.datetime,
1414
+ ) -> RevisionModelT:
1415
+ db_instance = clone_revision_model_instance(model, instance)
1416
+ db_instance.created_at = created_at
1417
+ db_instance.updated_at = created_at
1418
+ db_instance.expired_at = None
1419
+ db_instance.revision = 1
1420
+ db.add(db_instance)
1421
+ db.flush()
1422
+
1423
+ db_instance.record_sqn = db_instance.sqn
1424
+ db.flush()
1425
+
1426
+ return db_instance
1427
+
1428
+
1429
+ def db_create_revision_models[RevisionModelT: RevisionModelMixin](
1430
+ db: sa_orm.Session,
1431
+ model: type[RevisionModelT],
1432
+ instances: list[RevisionModelMixin],
1433
+ created_at: datetime.datetime,
1434
+ ) -> list[RevisionModelT]:
1435
+ db_instances = [clone_revision_model_instance(model, instance) for instance in instances]
1436
+ for db_instance in db_instances:
1437
+ db_instance.created_at = created_at
1438
+ db_instance.updated_at = created_at
1439
+ db_instance.expired_at = None
1440
+ db_instance.revision = 1
1441
+ db.add_all(db_instances)
1442
+ db.flush()
1443
+
1444
+ for db_instance in db_instances:
1445
+ db_instance.record_sqn = db_instance.sqn
1446
+ db.flush()
1447
+
1448
+ return db_instances
1449
+
1450
+
1451
+ def db_read_revision_models_of_record[RevisionModelT: RevisionModelMixin](
1452
+ db: sa_orm.Session,
1453
+ model: type[RevisionModelT],
1454
+ record_sqn: int,
1455
+ ) -> list[RevisionModelT]:
1456
+ return (
1457
+ db
1458
+ .query(model)
1459
+ .where(model.record_sqn == record_sqn)
1460
+ .order_by(model.revision.desc())
1461
+ .all()
1462
+ )
1463
+
1464
+
1465
+ def db_read_latest_revision_model_of_record[RevisionModelT: RevisionModelMixin](
1466
+ db: sa_orm.Session,
1467
+ model: type[RevisionModelT],
1468
+ record_sqn: int,
1469
+ ) -> RevisionModelT:
1470
+ db_instance = (
1471
+ db
1472
+ .query(model)
1473
+ .where(model.record_sqn == record_sqn)
1474
+ .order_by(model.revision.desc())
1475
+ .first()
1476
+ )
1477
+ if db_instance is None:
1478
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1479
+
1480
+ return db_instance
1481
+
1482
+
1483
+ def db_read_active_revision_model_of_record[RevisionModelT: RevisionModelMixin](
1484
+ db: sa_orm.Session,
1485
+ model: type[RevisionModelT],
1486
+ record_sqn: int,
1487
+ ) -> RevisionModelT:
1488
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1489
+ if db_instance is None:
1490
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1491
+
1492
+ return db_instance
1493
+
1494
+
1495
+ def db_read_expired_revision_models_of_record[RevisionModelT: RevisionModelMixin](
1496
+ db: sa_orm.Session,
1497
+ model: type[RevisionModelT],
1498
+ record_sqn: int,
1499
+ ) -> list[RevisionModelT]:
1500
+ return (
1501
+ db
1502
+ .query(model)
1503
+ .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1504
+ .order_by(model.revision.desc())
1505
+ .all()
1506
+ )
1507
+
1508
+
1509
+ def db_read_latest_revision_models[RevisionModelT: RevisionModelMixin](
1510
+ db: sa_orm.Session,
1511
+ model: type[RevisionModelT],
1512
+ skip: int | None = None,
1513
+ limit: int | None = None,
1514
+ order_by: list[str] | None = None,
1515
+ ) -> list[RevisionModelT]:
1516
+ subquery_stmt = (
1517
+ db
1518
+ .query(model.record_sqn,
1519
+ sa.func.max(model.revision).label("max_revision"))
1520
+ .group_by(model.record_sqn)
1521
+ .subquery()
1522
+ )
1523
+
1524
+ query_stmt = (
1525
+ db
1526
+ .query(model)
1527
+ .join(subquery_stmt,
1528
+ sa.and_(model.record_sqn == subquery_stmt.c.record_sqn,
1529
+ model.revision == subquery_stmt.c.max_revision))
1530
+ .order_by(*db_make_order_by_clause(model, order_by))
1531
+ )
1532
+ if skip is not None:
1533
+ query_stmt = query_stmt.offset(skip)
1534
+ if limit is not None:
1535
+ query_stmt = query_stmt.limit(limit)
1536
+ return query_stmt.all()
1537
+
1538
+
1539
+ def db_read_active_revision_models[RevisionModelT: RevisionModelMixin](
1540
+ db: sa_orm.Session,
1541
+ model: type[RevisionModelT],
1542
+ skip: int | None = None,
1543
+ limit: int | None = None,
1544
+ order_by: list[str] | None = None,
1545
+ ) -> list[RevisionModelT]:
1546
+ query_stmt = db.query(model).where(model.expired_at.is_(None)).order_by(*db_make_order_by_clause(model, order_by))
1547
+ if skip is not None:
1548
+ query_stmt = query_stmt.offset(skip)
1549
+ if limit is not None:
1550
+ query_stmt = query_stmt.limit(limit)
1551
+ return query_stmt.all()
1552
+
1553
+
1554
+ def db_update_revision_model[RevisionModelT: RevisionModelMixin](
1555
+ db: sa_orm.Session,
1556
+ model: type[RevisionModelT],
1557
+ instance: RevisionModelMixin,
1558
+ record_sqn: int,
1559
+ updated_at: datetime.datetime,
1560
+ ) -> RevisionModelT:
1561
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1562
+ if db_instance is None:
1563
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1564
+
1565
+ db_instance.expired_at = updated_at
1566
+ clone_revision_model_instance(model, db_instance, validate_only=True)
1567
+ db.flush()
1568
+
1569
+ db_new_instance = clone_revision_model_instance(model, instance)
1570
+ db_new_instance.record_sqn = record_sqn
1571
+ db_new_instance.created_at = db_instance.created_at
1572
+ db_new_instance.updated_at = updated_at
1573
+ db_new_instance.expired_at = None
1574
+ db_new_instance.revision = db_instance.revision + 1
1575
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1576
+ db.add(db_new_instance)
1577
+ db.flush()
1578
+
1579
+ return db_new_instance
1580
+
1581
+
1582
+ def db_expire_revision_model[RevisionModelT: RevisionModelMixin](
1583
+ db: sa_orm.Session,
1584
+ model: type[RevisionModelT],
1585
+ record_sqn: int,
1586
+ updated_at: datetime.datetime,
1587
+ ) -> RevisionModelT:
1588
+ db_instance = (
1589
+ db
1590
+ .query(model)
1591
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1592
+ .one_or_none()
1593
+ )
1594
+ if db_instance is None:
1595
+ raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1596
+
1597
+ db_instance.expired_at = updated_at
1598
+ clone_revision_model_instance(model, db_instance, validate_only=True)
1599
+ db.flush()
1600
+
1601
+ return db_instance
1602
+
1603
+
1604
+ def db_activate_revision_model[RevisionModelT: RevisionModelMixin](
1605
+ db: sa_orm.Session,
1606
+ model: type[RevisionModelT],
1607
+ record_sqn: int,
1608
+ updated_at: datetime.datetime,
1609
+ ) -> RevisionModelT:
1610
+ db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1611
+ if db_instance is not None:
1612
+ raise sa_exc.MultipleResultsFound(
1613
+ f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' already exists")
1614
+
1615
+ db_instance = (
1616
+ db
1617
+ .query(model)
1618
+ .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1619
+ .order_by(model.revision.desc())
1620
+ .first()
1621
+ )
1622
+ if db_instance is None:
1623
+ raise sa_exc.NoResultFound(f"expired '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1624
+
1625
+ db_new_instance = clone_revision_model_instance(model, db_instance)
1626
+ db_new_instance.record_sqn = record_sqn
1627
+ db_new_instance.created_at = db_instance.created_at
1628
+ db_new_instance.updated_at = db_instance.expired_at
1629
+ db_new_instance.expired_at = updated_at
1630
+ db_new_instance.revision = db_instance.revision + 1
1631
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1632
+ db_new_instance.updated_at = updated_at
1633
+ db_new_instance.expired_at = None
1634
+ clone_revision_model_instance(model, db_new_instance, validate_only=True)
1635
+ db.add(db_new_instance)
1636
+ db.flush()
1637
+
1638
+ return db_new_instance