sqlmodel-ext 0.3.4__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 +257 -9
- 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.4.dist-info → sqlmodel_ext-0.4.0.dist-info}/METADATA +80 -24
- {sqlmodel_ext-0.3.4.dist-info → sqlmodel_ext-0.4.0.dist-info}/RECORD +12 -11
- {sqlmodel_ext-0.3.4.dist-info → sqlmodel_ext-0.4.0.dist-info}/WHEEL +1 -1
- {sqlmodel_ext-0.3.4.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
|
|
@@ -28,9 +29,15 @@ from sqlmodel.main import (
|
|
|
28
29
|
is_table_model_class,
|
|
29
30
|
get_relationship_to,
|
|
30
31
|
FieldInfo as SQLModelFieldInfo, # Internal API: stable since sqlmodel 0.0.22
|
|
32
|
+
FieldInfoMetadata, # Internal API: pydantic-rebuild-safe sa_type carrier
|
|
31
33
|
get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
|
|
32
34
|
)
|
|
33
35
|
|
|
36
|
+
# sqlmodel's FieldInfoMetadata fields default to sqlmodel's own Undefined
|
|
37
|
+
# sentinel (distinct from pydantic_core PydanticUndefined imported above);
|
|
38
|
+
# capture it to detect "sa_type not yet set" without importing sqlmodel internals.
|
|
39
|
+
_FIM_UNSET_SA_TYPE = FieldInfoMetadata().sa_type
|
|
40
|
+
|
|
34
41
|
# Import _compat for side effects (Python 3.14 monkey-patches)
|
|
35
42
|
import sqlmodel_ext._compat # noqa: F401
|
|
36
43
|
|
|
@@ -143,27 +150,176 @@ def _find_field_info_in_annotated(annotation: Any) -> SQLModelFieldInfo | None:
|
|
|
143
150
|
return merged
|
|
144
151
|
|
|
145
152
|
|
|
153
|
+
def _durably_set_sa_type(field_info: Any, sa_type: Any) -> None:
|
|
154
|
+
"""
|
|
155
|
+
Inject ``sa_type`` so it survives Pydantic's model_fields rebuild.
|
|
156
|
+
|
|
157
|
+
Root cause this fixes: the metaclass extracts ``sa_type`` from
|
|
158
|
+
``Array[T]`` / custom Annotated handlers and must hand it to SQLModel's
|
|
159
|
+
column builder. A plain ``setattr(field_info, 'sa_type', sa_type)`` is
|
|
160
|
+
LOST: ``SQLModelMetaclass.__new__`` (invoked from our ``super().__new__``)
|
|
161
|
+
runs ``get_column_from_field`` *before* step-7's SQLModelFieldInfo
|
|
162
|
+
restore, and Pydantic has by then rebuilt ``model_fields`` into fresh
|
|
163
|
+
FieldInfo objects that never saw the post-hoc attribute. The previous
|
|
164
|
+
code only fixed the no-``= Field(...)`` branch (c00696c); the explicit
|
|
165
|
+
``Array[T] = Field(default_factory=list)`` form still raised
|
|
166
|
+
``<class 'list'> has no matching SQLAlchemy type``.
|
|
167
|
+
|
|
168
|
+
Fix: write ``sa_type`` into a ``FieldInfoMetadata`` entry inside the
|
|
169
|
+
FieldInfo's pydantic ``metadata`` list — the exact channel SQLModel's
|
|
170
|
+
own ``Field(sa_type=...)`` uses and which ``get_sqlalchemy_type``
|
|
171
|
+
(via ``_get_sqlmodel_field_value``) reads *first*. Pydantic preserves
|
|
172
|
+
the ``metadata`` list across rebuilds, so the type survives into the
|
|
173
|
+
column build. The instance attribute is also set as a belt-and-braces
|
|
174
|
+
fallback for any direct ``getattr(field_info, 'sa_type')`` reader.
|
|
175
|
+
|
|
176
|
+
:param field_info: target FieldInfo (user's Field or recovered Annotated FI)
|
|
177
|
+
:param sa_type: SQLAlchemy type extracted from the annotation handler
|
|
178
|
+
"""
|
|
179
|
+
md = list(getattr(field_info, 'metadata', None) or [])
|
|
180
|
+
existing = next(
|
|
181
|
+
(m for m in md if isinstance(m, FieldInfoMetadata)), None
|
|
182
|
+
)
|
|
183
|
+
if existing is not None:
|
|
184
|
+
if existing.sa_type is _FIM_UNSET_SA_TYPE:
|
|
185
|
+
existing.sa_type = sa_type
|
|
186
|
+
else:
|
|
187
|
+
md.append(FieldInfoMetadata(sa_type=sa_type))
|
|
188
|
+
field_info.metadata = md
|
|
189
|
+
if getattr(field_info, 'sa_type', Undefined) is Undefined:
|
|
190
|
+
try:
|
|
191
|
+
field_info.sa_type = sa_type
|
|
192
|
+
except (AttributeError, TypeError):
|
|
193
|
+
pass
|
|
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
|
+
|
|
146
279
|
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
147
280
|
"""
|
|
148
281
|
Convert type annotation to optional: ``T → T | None``
|
|
149
282
|
|
|
150
283
|
Places ``| None`` inside ``Annotated[]`` to preserve Field metadata.
|
|
151
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
|
+
|
|
152
293
|
Examples::
|
|
153
294
|
|
|
154
295
|
str → str | None
|
|
155
|
-
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(...)]
|
|
156
299
|
str | None → str | None (already optional, unchanged)
|
|
157
300
|
"""
|
|
158
301
|
origin = get_origin(annotation)
|
|
159
302
|
|
|
160
|
-
# Annotated[T, metadata...]
|
|
303
|
+
# Annotated[T, metadata...]: split constraint/schema metadata, then build
|
|
304
|
+
# the nested optional annotation.
|
|
161
305
|
if origin is typing.Annotated:
|
|
162
306
|
args = get_args(annotation)
|
|
163
307
|
inner_type = args[0]
|
|
164
308
|
metadata = args[1:]
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
167
323
|
|
|
168
324
|
# Union / UnionType already contains None → unchanged
|
|
169
325
|
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
@@ -175,6 +331,25 @@ def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
|
175
331
|
return annotation | None
|
|
176
332
|
|
|
177
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
|
+
|
|
178
353
|
def _apply_all_fields_optional(
|
|
179
354
|
annotations: dict[str, typing.Any],
|
|
180
355
|
attrs: dict[str, typing.Any],
|
|
@@ -471,6 +646,52 @@ def _make_sti_fk_resolver(
|
|
|
471
646
|
return _resolve
|
|
472
647
|
|
|
473
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
|
+
|
|
474
695
|
class __DeclarativeMeta(SQLModelMetaclass):
|
|
475
696
|
"""
|
|
476
697
|
A smart hybrid metaclass providing flexibility and clarity:
|
|
@@ -546,7 +767,25 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
546
767
|
|
|
547
768
|
# Process other explicit args
|
|
548
769
|
if 'table_args' in kwargs:
|
|
549
|
-
|
|
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
|
|
550
789
|
if 'table_name' in kwargs:
|
|
551
790
|
attrs['__tablename__'] = kwargs.pop('table_name')
|
|
552
791
|
if 'abstract' in kwargs:
|
|
@@ -602,14 +841,16 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
602
841
|
# inheritance.
|
|
603
842
|
annotated_fi = _find_field_info_in_annotated(field_type)
|
|
604
843
|
if annotated_fi is not None:
|
|
605
|
-
|
|
606
|
-
setattr(annotated_fi, 'sa_type', sa_type)
|
|
844
|
+
_durably_set_sa_type(annotated_fi, sa_type)
|
|
607
845
|
attrs[field_name] = annotated_fi
|
|
608
846
|
else:
|
|
609
847
|
attrs[field_name] = Field(sa_type=sa_type)
|
|
610
848
|
elif isinstance(field_value, FieldInfo):
|
|
611
|
-
|
|
612
|
-
|
|
849
|
+
# Explicit ``Array[T] = Field(default_factory=list)`` form.
|
|
850
|
+
# Must inject sa_type via the pydantic-rebuild-safe
|
|
851
|
+
# FieldInfoMetadata channel — plain setattr is dropped
|
|
852
|
+
# before SQLModel's column build (see _durably_set_sa_type).
|
|
853
|
+
_durably_set_sa_type(field_value, sa_type)
|
|
613
854
|
|
|
614
855
|
# 5. Save SQLModel FieldInfo from Annotated fields before super().__new__(),
|
|
615
856
|
# because Pydantic rebuilds model_fields with plain FieldInfo that lacks
|
|
@@ -624,6 +865,13 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
624
865
|
# 6. Call parent __new__
|
|
625
866
|
result = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
626
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
|
+
|
|
627
875
|
# 7. Restore SQLModel FieldInfo attributes discarded by Pydantic and rebuild Columns.
|
|
628
876
|
# Pydantic FieldInfo uses __slots__, so setattr for SQLModel extensions is silently
|
|
629
877
|
# ignored. We replace the Pydantic FieldInfo with our saved SQLModelFieldInfo and
|