sqlmodel-ext 0.3.5__py3-none-any.whl → 0.4.0__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.
- sqlmodel_ext/__init__.py +42 -3
- sqlmodel_ext/base.py +202 -5
- sqlmodel_ext/field_types/__init__.py +316 -3
- sqlmodel_ext/mixins/__init__.py +1 -1
- sqlmodel_ext/mixins/cached_table.py +276 -239
- sqlmodel_ext/mixins/polymorphic.py +118 -2
- sqlmodel_ext/mixins/table.py +35 -13
- sqlmodel_ext/session.py +105 -0
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.0.dist-info}/METADATA +32 -8
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.0.dist-info}/RECORD +12 -11
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.0.dist-info}/WHEEL +1 -1
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.0.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py
CHANGED
|
@@ -20,10 +20,13 @@ Quick start::
|
|
|
20
20
|
user = await user.save(session)
|
|
21
21
|
users = await User.get(session, fetch_mode="all")
|
|
22
22
|
"""
|
|
23
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.4.0"
|
|
24
24
|
|
|
25
25
|
# Base
|
|
26
|
-
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
|
|
26
|
+
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase, CustomTableArg
|
|
27
|
+
|
|
28
|
+
# Enhanced session (cache-aware commit/reset/refresh/execute)
|
|
29
|
+
from sqlmodel_ext.session import AsyncSession
|
|
27
30
|
|
|
28
31
|
# Exceptions
|
|
29
32
|
from sqlmodel_ext._exceptions import RecordNotFoundError
|
|
@@ -44,10 +47,10 @@ from sqlmodel_ext.mixins import (
|
|
|
44
47
|
UUIDTableBaseMixin,
|
|
45
48
|
rel,
|
|
46
49
|
cond,
|
|
47
|
-
safe_reset,
|
|
48
50
|
# Polymorphic
|
|
49
51
|
PolymorphicBaseMixin,
|
|
50
52
|
AutoPolymorphicIdentityMixin,
|
|
53
|
+
DeferredIndex,
|
|
51
54
|
create_subclass_id_mixin,
|
|
52
55
|
register_sti_columns_for_all_subclasses,
|
|
53
56
|
register_sti_column_properties_for_all_subclasses,
|
|
@@ -93,14 +96,24 @@ from sqlmodel_ext.field_types import (
|
|
|
93
96
|
Text2500,
|
|
94
97
|
Text3K,
|
|
95
98
|
Text5K,
|
|
99
|
+
Text8K,
|
|
96
100
|
Text10K,
|
|
97
101
|
Text16K,
|
|
98
102
|
Text32K,
|
|
103
|
+
Text48K,
|
|
99
104
|
Text60K,
|
|
100
105
|
Text64K,
|
|
101
106
|
Text100K,
|
|
102
107
|
Text128K,
|
|
103
108
|
Text1M,
|
|
109
|
+
NonEmptyStr64,
|
|
110
|
+
NonEmptyStr128,
|
|
111
|
+
NonEmptyStr256,
|
|
112
|
+
NonEmptyStrippedStr64,
|
|
113
|
+
NonEmptyStrippedStr128,
|
|
114
|
+
NonEmptyStrippedStr256,
|
|
115
|
+
Sha256Hex,
|
|
116
|
+
BCP47LanguageCode,
|
|
104
117
|
# Numeric constraints
|
|
105
118
|
INT32_MAX,
|
|
106
119
|
INT64_MAX,
|
|
@@ -113,6 +126,32 @@ from sqlmodel_ext.field_types import (
|
|
|
113
126
|
NonNegativeBigInt,
|
|
114
127
|
PositiveFloat,
|
|
115
128
|
NonNegativeFloat,
|
|
129
|
+
# Decimal constraints (NUMERIC(p, s) + sign + JSON-string serialization)
|
|
130
|
+
SignedDecimal38_18,
|
|
131
|
+
NonNegativeDecimal38_18,
|
|
132
|
+
PositiveDecimal38_18,
|
|
133
|
+
OptionalNonNegativeDecimal38_18,
|
|
134
|
+
SignedDecimal20_10,
|
|
135
|
+
NonNegativeDecimal20_10,
|
|
136
|
+
OptionalNonNegativeDecimal20_10,
|
|
137
|
+
# Bounded-length list aliases
|
|
138
|
+
List,
|
|
139
|
+
List1,
|
|
140
|
+
List2,
|
|
141
|
+
List3,
|
|
142
|
+
List7,
|
|
143
|
+
List10,
|
|
144
|
+
List16,
|
|
145
|
+
List20,
|
|
146
|
+
List32,
|
|
147
|
+
List40,
|
|
148
|
+
List50,
|
|
149
|
+
List64,
|
|
150
|
+
List100,
|
|
151
|
+
List128,
|
|
152
|
+
List200,
|
|
153
|
+
List256,
|
|
154
|
+
List1024,
|
|
116
155
|
# Custom types
|
|
117
156
|
IPAddress,
|
|
118
157
|
Url,
|
sqlmodel_ext/base.py
CHANGED
|
@@ -17,6 +17,7 @@ import types
|
|
|
17
17
|
import typing
|
|
18
18
|
from typing import Any, Self, Sequence, get_args, get_origin
|
|
19
19
|
|
|
20
|
+
import annotated_types as at
|
|
20
21
|
from pydantic import ConfigDict, model_validator
|
|
21
22
|
from pydantic.fields import FieldInfo
|
|
22
23
|
from pydantic_core import PydanticUndefined as Undefined
|
|
@@ -192,27 +193,133 @@ def _durably_set_sa_type(field_info: Any, sa_type: Any) -> None:
|
|
|
192
193
|
pass
|
|
193
194
|
|
|
194
195
|
|
|
196
|
+
_FIELDINFO_NON_CONSTRAINT_ATTRS = (
|
|
197
|
+
'default', 'default_factory', 'alias', 'serialization_alias', 'validation_alias',
|
|
198
|
+
'title', 'description', 'examples', 'json_schema_extra',
|
|
199
|
+
'exclude', 'repr', 'init', 'init_var', 'kw_only', 'discriminator',
|
|
200
|
+
'frozen', 'validate_default',
|
|
201
|
+
'primary_key', 'foreign_key', 'unique', 'nullable', 'index',
|
|
202
|
+
'sa_type', 'sa_column', 'sa_column_args', 'sa_column_kwargs',
|
|
203
|
+
'ondelete', 'schema_extra',
|
|
204
|
+
)
|
|
205
|
+
"""Non-constraint attributes on FieldInfo -- sa/orm/default/alias/docs, safe to keep in the outer Annotated.
|
|
206
|
+
|
|
207
|
+
See the ``_split_metadata_for_optional`` docstring for semantics.
|
|
208
|
+
Constraint-related attributes (ge/le/gt/lt/multiple_of/max_digits/decimal_places/
|
|
209
|
+
min_length/max_length/pattern/strict) have already been expanded by Pydantic into
|
|
210
|
+
``Ge/Le/Gt/Lt/MultipleOf/_PydanticGeneralMetadata/MinLen/MaxLen/Predicate`` markers
|
|
211
|
+
inside ``FieldInfo.metadata``, so they need no re-detection on the FieldInfo attrs."""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _split_metadata_for_optional(
|
|
215
|
+
metadata: tuple[typing.Any, ...],
|
|
216
|
+
) -> tuple[list[typing.Any], list[typing.Any]]:
|
|
217
|
+
"""Split the metadata of ``Annotated[T, *metadata]`` into (inner_constraints, outer_safe).
|
|
218
|
+
|
|
219
|
+
**Why**: Pydantic constraint markers (``Ge/Le/MultipleOf/_PydanticGeneralMetadata``
|
|
220
|
+
etc.) raise ``TypeError: Unable to apply constraint ... to supplied value None`` on
|
|
221
|
+
None -- so the constraint markers of a ``T | None`` field must be wrapped inside the
|
|
222
|
+
inner ``Annotated[T, ...]`` so Pydantic only applies them when the value is not
|
|
223
|
+
None. Schema markers (``PlainSerializer/BeforeValidator`` etc.) and ORM markers
|
|
224
|
+
(``sa_type``/``default`` etc.) are None-safe and may stay in the outer layer. This
|
|
225
|
+
is the essence behind the hand-written nested-Annotated form of optional
|
|
226
|
+
constrained aliases like ``OptionalNonNegativeDecimal38_18``.
|
|
227
|
+
|
|
228
|
+
Classification rules:
|
|
229
|
+
|
|
230
|
+
- ``annotated_types.BaseMetadata`` subclasses (``Ge/Le/Gt/Lt/MultipleOf/MinLen/
|
|
231
|
+
MaxLen/Predicate`` etc.) -> inner (all constraint validators, crash on None)
|
|
232
|
+
- Pydantic ``FieldInfo`` -> split: ``fi.metadata`` (the constraint marker sequence
|
|
233
|
+
Pydantic already expanded) -> inner; the remaining schema/orm attrs are rebuilt
|
|
234
|
+
into a fresh FieldInfo -> outer
|
|
235
|
+
- objects with ``max_digits`` / ``decimal_places`` / ``pattern`` attributes that are
|
|
236
|
+
not FieldInfo (i.e. ``_PydanticGeneralMetadata``) -> inner
|
|
237
|
+
- everything else (``PlainSerializer/WrapSerializer/BeforeValidator/AfterValidator/
|
|
238
|
+
PlainValidator/WrapValidator`` etc.) -> outer (validators are expected to be
|
|
239
|
+
None-safe, like a reject-float BeforeValidator)
|
|
240
|
+
"""
|
|
241
|
+
inner: list[typing.Any] = []
|
|
242
|
+
outer: list[typing.Any] = []
|
|
243
|
+
for m in metadata:
|
|
244
|
+
if isinstance(m, at.BaseMetadata):
|
|
245
|
+
inner.append(m)
|
|
246
|
+
elif isinstance(m, FieldInfo):
|
|
247
|
+
inner.extend(m.metadata)
|
|
248
|
+
outer_fi = _strip_constraint_metadata_from_field_info(m)
|
|
249
|
+
if outer_fi is not None:
|
|
250
|
+
outer.append(outer_fi)
|
|
251
|
+
elif isinstance(m, at.GroupedMetadata):
|
|
252
|
+
# GroupedMetadata protocol (``pydantic.StringConstraints`` etc.):
|
|
253
|
+
# iterating yields a sequence of _PydanticGeneralMetadata /
|
|
254
|
+
# annotated_types markers; all go inner.
|
|
255
|
+
inner.extend(list(m))
|
|
256
|
+
elif hasattr(m, 'max_digits') or hasattr(m, 'decimal_places') or hasattr(m, 'pattern'):
|
|
257
|
+
# _PydanticGeneralMetadata (private, duck-typed): max_digits/decimal_places/pattern
|
|
258
|
+
inner.append(m)
|
|
259
|
+
else:
|
|
260
|
+
outer.append(m)
|
|
261
|
+
return inner, outer
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _strip_constraint_metadata_from_field_info(fi: FieldInfo) -> FieldInfo | None:
|
|
265
|
+
"""Copy an outer-layer version of a FieldInfo: drop ``fi.metadata`` (constraint markers,
|
|
266
|
+
moved inner), keep sa_type/default/alias/description and other schema/orm attrs.
|
|
267
|
+
Returns None when no non-constraint attrs remain."""
|
|
268
|
+
new_kwargs: dict[str, typing.Any] = {}
|
|
269
|
+
for attr in _FIELDINFO_NON_CONSTRAINT_ATTRS:
|
|
270
|
+
v = getattr(fi, attr, Undefined)
|
|
271
|
+
if v is None or v is Undefined:
|
|
272
|
+
continue
|
|
273
|
+
new_kwargs[attr] = v
|
|
274
|
+
if not new_kwargs:
|
|
275
|
+
return None
|
|
276
|
+
return Field(**new_kwargs)
|
|
277
|
+
|
|
278
|
+
|
|
195
279
|
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
196
280
|
"""
|
|
197
281
|
Convert type annotation to optional: ``T → T | None``
|
|
198
282
|
|
|
199
283
|
Places ``| None`` inside ``Annotated[]`` to preserve Field metadata.
|
|
200
284
|
|
|
285
|
+
**Key constraint** (isomorphic to ``OptionalNonNegativeDecimal38_18``): when ``T``
|
|
286
|
+
carries constraint validators (``Ge/Le/MultipleOf/MaxDigits/DecimalPlaces`` etc.),
|
|
287
|
+
the constraints **must** be nested into the inner ``Annotated[T, *constraints]``,
|
|
288
|
+
otherwise Pydantic crashes parsing JSON ``null`` with
|
|
289
|
+
``TypeError: Unable to apply constraint 'ge' to supplied value None``.
|
|
290
|
+
Schema/serializer/ORM markers (``PlainSerializer/sa_type/default`` etc.) are
|
|
291
|
+
None-safe and stay outer.
|
|
292
|
+
|
|
201
293
|
Examples::
|
|
202
294
|
|
|
203
295
|
str → str | None
|
|
204
|
-
Annotated[float,
|
|
296
|
+
Annotated[float, Ge(0)] → Annotated[Annotated[float, Ge(0)] | None]
|
|
297
|
+
Annotated[Decimal, Field(ge=0, sa_type=...), PlainSerializer(...)]
|
|
298
|
+
→ Annotated[Annotated[Decimal, Ge(0)] | None, Field(sa_type=...), PlainSerializer(...)]
|
|
205
299
|
str | None → str | None (already optional, unchanged)
|
|
206
300
|
"""
|
|
207
301
|
origin = get_origin(annotation)
|
|
208
302
|
|
|
209
|
-
# Annotated[T, metadata...]
|
|
303
|
+
# Annotated[T, metadata...]: split constraint/schema metadata, then build
|
|
304
|
+
# the nested optional annotation.
|
|
210
305
|
if origin is typing.Annotated:
|
|
211
306
|
args = get_args(annotation)
|
|
212
307
|
inner_type = args[0]
|
|
213
308
|
metadata = args[1:]
|
|
214
|
-
|
|
215
|
-
|
|
309
|
+
inner_meta, outer_meta = _split_metadata_for_optional(metadata)
|
|
310
|
+
# Recurse into inner_type: it may itself be Annotated (nested aliases).
|
|
311
|
+
recursed_inner_type = _maybe_recurse_into_annotated(inner_type)
|
|
312
|
+
if inner_meta:
|
|
313
|
+
inner_annotated = typing.Annotated[tuple([recursed_inner_type, *inner_meta])]
|
|
314
|
+
else:
|
|
315
|
+
inner_annotated = recursed_inner_type
|
|
316
|
+
# ``typing.Union[X, None]`` is equivalent to PEP 604 ``X | None``, but type
|
|
317
|
+
# checkers can correctly infer ``inner_annotated`` is ``Annotated[...]``
|
|
318
|
+
# (a dynamic type without static ``__or__`` support).
|
|
319
|
+
optional_inner = typing.Union[inner_annotated, type(None)] # pyright: ignore[reportGeneralTypeIssues]
|
|
320
|
+
if outer_meta:
|
|
321
|
+
return typing.Annotated[tuple([optional_inner, *outer_meta])]
|
|
322
|
+
return optional_inner
|
|
216
323
|
|
|
217
324
|
# Union / UnionType already contains None → unchanged
|
|
218
325
|
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
@@ -224,6 +331,25 @@ def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
|
224
331
|
return annotation | None
|
|
225
332
|
|
|
226
333
|
|
|
334
|
+
def _maybe_recurse_into_annotated(annotation: typing.Any) -> typing.Any:
|
|
335
|
+
"""For nested Annotated (``Annotated[Annotated[T, ...], ...]``), split the inner
|
|
336
|
+
layer's constraints too. Non-Annotated values are returned unchanged."""
|
|
337
|
+
if get_origin(annotation) is typing.Annotated:
|
|
338
|
+
args = get_args(annotation)
|
|
339
|
+
inner_type = args[0]
|
|
340
|
+
metadata = args[1:]
|
|
341
|
+
inner_meta, outer_meta = _split_metadata_for_optional(metadata)
|
|
342
|
+
# This layer is already at the outer position or wrapped as inner --
|
|
343
|
+
# keep its structure: the constraints are in inner_meta, reassemble
|
|
344
|
+
# into Annotated without appending | None (the parent caller does that).
|
|
345
|
+
recursed = _maybe_recurse_into_annotated(inner_type)
|
|
346
|
+
all_meta = list(inner_meta) + list(outer_meta)
|
|
347
|
+
if all_meta:
|
|
348
|
+
return typing.Annotated[tuple([recursed, *all_meta])]
|
|
349
|
+
return recursed
|
|
350
|
+
return annotation
|
|
351
|
+
|
|
352
|
+
|
|
227
353
|
def _apply_all_fields_optional(
|
|
228
354
|
annotations: dict[str, typing.Any],
|
|
229
355
|
attrs: dict[str, typing.Any],
|
|
@@ -520,6 +646,52 @@ def _make_sti_fk_resolver(
|
|
|
520
646
|
return _resolve
|
|
521
647
|
|
|
522
648
|
|
|
649
|
+
# ==================== Custom table_args element interception ====================
|
|
650
|
+
#
|
|
651
|
+
# SQLAlchemy's ``Table.__init__`` consumes every ``__table_args__`` element
|
|
652
|
+
# **immediately**; an ``Index`` referencing a column that does not exist yet
|
|
653
|
+
# raises ``ConstraintColumnNotFoundError`` on the spot.
|
|
654
|
+
#
|
|
655
|
+
# ``CustomTableArg`` is a generic base class that lets users place
|
|
656
|
+
# **non-SQLAlchemy-native** marker objects into ``table_args`` -- the
|
|
657
|
+
# metaclass (``__DeclarativeMeta.__new__``) **intercepts** these markers,
|
|
658
|
+
# removes them from ``table_args`` (never handing them to SQLAlchemy), and
|
|
659
|
+
# pushes (target class, markers) onto the module-level queue
|
|
660
|
+
# ``_classes_with_custom_table_args`` for downstream infrastructure (e.g. the
|
|
661
|
+
# STI deferred index in ``mixins.polymorphic``) to scan and consume at the
|
|
662
|
+
# right moment.
|
|
663
|
+
#
|
|
664
|
+
# The base layer only knows the generic "defer processing" mechanism and
|
|
665
|
+
# **not** the concrete semantics (STI, JTI, ...) -- concrete types (e.g.
|
|
666
|
+
# ``DeferredIndex``) are defined by their own submodules as ``CustomTableArg``
|
|
667
|
+
# subclasses.
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
class CustomTableArg:
|
|
671
|
+
"""
|
|
672
|
+
Generic base class for non-SQLAlchemy-native ``table_args`` elements.
|
|
673
|
+
|
|
674
|
+
Objects inheriting this class, when placed in ``table_args``, are
|
|
675
|
+
**intercepted** by the SQLModel metaclass -- never passed to SQLAlchemy
|
|
676
|
+
``Table.__init__`` (avoiding immediate-evaluation failures) and instead
|
|
677
|
+
stashed on the ``_classes_with_custom_table_args`` queue for downstream
|
|
678
|
+
infrastructure to consume.
|
|
679
|
+
|
|
680
|
+
Concrete subclass example: ``mixins.polymorphic.DeferredIndex``
|
|
681
|
+
(deferred indexes over STI subclass columns).
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
_classes_with_custom_table_args: list[tuple[type, list[CustomTableArg]]] = []
|
|
686
|
+
"""
|
|
687
|
+
Queue of classes carrying custom ``table_args`` elements: ``(class, [CustomTableArg, ...])``.
|
|
688
|
+
|
|
689
|
+
Appended by ``__DeclarativeMeta.__new__`` after class creation completes.
|
|
690
|
+
Downstream infrastructure (e.g. ``mixins.polymorphic._create_sti_deferred_indexes()``)
|
|
691
|
+
scans this queue and consumes entries as needed (dispatching on ``isinstance``).
|
|
692
|
+
"""
|
|
693
|
+
|
|
694
|
+
|
|
523
695
|
class __DeclarativeMeta(SQLModelMetaclass):
|
|
524
696
|
"""
|
|
525
697
|
A smart hybrid metaclass providing flexibility and clarity:
|
|
@@ -595,7 +767,25 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
595
767
|
|
|
596
768
|
# Process other explicit args
|
|
597
769
|
if 'table_args' in kwargs:
|
|
598
|
-
|
|
770
|
+
raw_table_args = kwargs.pop('table_args')
|
|
771
|
+
# Split out CustomTableArg markers -- never handed to SQLAlchemy
|
|
772
|
+
# (avoiding immediate evaluation against not-yet-existing
|
|
773
|
+
# columns); stashed on the module-level
|
|
774
|
+
# ``_classes_with_custom_table_args`` queue for downstream
|
|
775
|
+
# infrastructure (e.g. STI deferred indexes) to consume.
|
|
776
|
+
real_table_args: list[Any] = []
|
|
777
|
+
custom_table_args: list[CustomTableArg] = []
|
|
778
|
+
for arg in raw_table_args:
|
|
779
|
+
if isinstance(arg, CustomTableArg):
|
|
780
|
+
custom_table_args.append(arg)
|
|
781
|
+
else:
|
|
782
|
+
real_table_args.append(arg)
|
|
783
|
+
attrs['__table_args__'] = tuple(real_table_args) if real_table_args else ()
|
|
784
|
+
if custom_table_args:
|
|
785
|
+
# Temporarily hung on attrs; appended to the module-level
|
|
786
|
+
# queue after super().__new__ (the result class is fully
|
|
787
|
+
# constructed then, so downstream can read __table__ etc.).
|
|
788
|
+
attrs['__custom_table_args__'] = custom_table_args
|
|
599
789
|
if 'table_name' in kwargs:
|
|
600
790
|
attrs['__tablename__'] = kwargs.pop('table_name')
|
|
601
791
|
if 'abstract' in kwargs:
|
|
@@ -675,6 +865,13 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
675
865
|
# 6. Call parent __new__
|
|
676
866
|
result = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
677
867
|
|
|
868
|
+
# 6.5. Append intercepted CustomTableArg markers to the module-level
|
|
869
|
+
# queue for downstream infrastructure. The result class is fully
|
|
870
|
+
# constructed at this point, so consumers can access __table__ etc.
|
|
871
|
+
_custom_args = attrs.get('__custom_table_args__')
|
|
872
|
+
if _custom_args:
|
|
873
|
+
_classes_with_custom_table_args.append((result, _custom_args))
|
|
874
|
+
|
|
678
875
|
# 7. Restore SQLModel FieldInfo attributes discarded by Pydantic and rebuild Columns.
|
|
679
876
|
# Pydantic FieldInfo uses __slots__, so setattr for SQLModel extensions is silently
|
|
680
877
|
# ignored. We replace the Pydantic FieldInfo with our saved SQLModelFieldInfo and
|