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.
- plexus/pulse/common/__init__.py +6 -0
- plexus/pulse/common/carto/OSMFile.py +259 -0
- plexus/pulse/common/carto/OSMNode.py +25 -0
- plexus/pulse/common/carto/OSMTags.py +101 -0
- plexus/pulse/common/carto/OSMWay.py +24 -0
- plexus/pulse/common/carto/__init__.py +11 -0
- plexus/pulse/common/resources/__init__.py +0 -0
- plexus/pulse/common/resources/tags/__init__.py +35 -0
- plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml +98 -0
- plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml +1390 -0
- plexus/pulse/common/utils/__init__.py +0 -0
- plexus/pulse/common/utils/apiutils.py +31 -0
- plexus/pulse/common/utils/bagutils.py +331 -0
- plexus/pulse/common/utils/config.py +63 -0
- plexus/pulse/common/utils/datautils.py +233 -0
- plexus/pulse/common/utils/dockerutils.py +181 -0
- plexus/pulse/common/utils/gisutils.py +406 -0
- plexus/pulse/common/utils/jsonutils.py +96 -0
- plexus/pulse/common/utils/ormutils.py +1638 -0
- plexus/pulse/common/utils/pathutils.py +234 -0
- plexus/pulse/common/utils/s3utils.py +939 -0
- plexus/pulse/common/utils/sqlutils.py +9 -0
- plexus/pulse/common/utils/strutils.py +401 -0
- plexus/pulse/common/utils/tagutils.py +1476 -0
- plexus/pulse/common/utils/testutils.py +172 -0
- plexus_python_pulse_common-1.1.75.dist-info/METADATA +42 -0
- plexus_python_pulse_common-1.1.75.dist-info/RECORD +29 -0
- plexus_python_pulse_common-1.1.75.dist-info/WHEEL +5 -0
- plexus_python_pulse_common-1.1.75.dist-info/top_level.txt +1 -0
|
@@ -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
|