sqlmodel-ext 0.3.5__py3-none-any.whl → 0.4.1__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 +329 -8
- sqlmodel_ext/field_types/__init__.py +368 -3
- sqlmodel_ext/field_types/dialects/postgresql/array.py +106 -7
- 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 +105 -26
- sqlmodel_ext/session.py +105 -0
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.1.dist-info}/METADATA +75 -36
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.1.dist-info}/RECORD +13 -12
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.1.dist-info}/WHEEL +1 -1
- {sqlmodel_ext-0.3.5.dist-info → sqlmodel_ext-0.4.1.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.1"
|
|
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
|
@@ -9,6 +9,7 @@ Provides a smart metaclass that handles:
|
|
|
9
9
|
- Annotated sa_type extraction and injection
|
|
10
10
|
- Python 3.14 (PEP 649) compatibility
|
|
11
11
|
"""
|
|
12
|
+
import ast
|
|
12
13
|
import copy
|
|
13
14
|
import logging
|
|
14
15
|
import re
|
|
@@ -17,11 +18,12 @@ import types
|
|
|
17
18
|
import typing
|
|
18
19
|
from typing import Any, Self, Sequence, get_args, get_origin
|
|
19
20
|
|
|
21
|
+
import annotated_types as at
|
|
20
22
|
from pydantic import ConfigDict, model_validator
|
|
21
23
|
from pydantic.fields import FieldInfo
|
|
22
24
|
from pydantic_core import PydanticUndefined as Undefined
|
|
23
25
|
from sqlalchemy import inspect as sa_inspect
|
|
24
|
-
from sqlalchemy.orm import Mapped, relationship as sa_relationship
|
|
26
|
+
from sqlalchemy.orm import Mapped, declared_attr, relationship as sa_relationship
|
|
25
27
|
from sqlmodel import Field, SQLModel
|
|
26
28
|
from sqlmodel.main import (
|
|
27
29
|
SQLModelMetaclass,
|
|
@@ -32,6 +34,14 @@ from sqlmodel.main import (
|
|
|
32
34
|
get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
|
|
33
35
|
)
|
|
34
36
|
|
|
37
|
+
# sqlmodel 0.0.32+ FieldInfoMetadata is a @dataclass whose auto-generated
|
|
38
|
+
# __eq__ causes __hash__ = None. Annotated[T, FieldInfoMetadata(...)] then
|
|
39
|
+
# becomes unhashable, breaking FastAPI's OpenAPI set-based dedup in
|
|
40
|
+
# get_definitions(). Restore identity-based hashing until upstream fixes it.
|
|
41
|
+
# Ref: https://github.com/fastapi/sqlmodel/pull/1889
|
|
42
|
+
if getattr(FieldInfoMetadata, '__hash__') is None:
|
|
43
|
+
FieldInfoMetadata.__hash__ = object.__hash__ # type: ignore[assignment]
|
|
44
|
+
|
|
35
45
|
# sqlmodel's FieldInfoMetadata fields default to sqlmodel's own Undefined
|
|
36
46
|
# sentinel (distinct from pydantic_core PydanticUndefined imported above);
|
|
37
47
|
# capture it to detect "sa_type not yet set" without importing sqlmodel internals.
|
|
@@ -192,27 +202,133 @@ def _durably_set_sa_type(field_info: Any, sa_type: Any) -> None:
|
|
|
192
202
|
pass
|
|
193
203
|
|
|
194
204
|
|
|
205
|
+
_FIELDINFO_NON_CONSTRAINT_ATTRS = (
|
|
206
|
+
'default', 'default_factory', 'alias', 'serialization_alias', 'validation_alias',
|
|
207
|
+
'title', 'description', 'examples', 'json_schema_extra',
|
|
208
|
+
'exclude', 'repr', 'init', 'init_var', 'kw_only', 'discriminator',
|
|
209
|
+
'frozen', 'validate_default',
|
|
210
|
+
'primary_key', 'foreign_key', 'unique', 'nullable', 'index',
|
|
211
|
+
'sa_type', 'sa_column', 'sa_column_args', 'sa_column_kwargs',
|
|
212
|
+
'ondelete', 'schema_extra',
|
|
213
|
+
)
|
|
214
|
+
"""Non-constraint attributes on FieldInfo -- sa/orm/default/alias/docs, safe to keep in the outer Annotated.
|
|
215
|
+
|
|
216
|
+
See the ``_split_metadata_for_optional`` docstring for semantics.
|
|
217
|
+
Constraint-related attributes (ge/le/gt/lt/multiple_of/max_digits/decimal_places/
|
|
218
|
+
min_length/max_length/pattern/strict) have already been expanded by Pydantic into
|
|
219
|
+
``Ge/Le/Gt/Lt/MultipleOf/_PydanticGeneralMetadata/MinLen/MaxLen/Predicate`` markers
|
|
220
|
+
inside ``FieldInfo.metadata``, so they need no re-detection on the FieldInfo attrs."""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _split_metadata_for_optional(
|
|
224
|
+
metadata: tuple[typing.Any, ...],
|
|
225
|
+
) -> tuple[list[typing.Any], list[typing.Any]]:
|
|
226
|
+
"""Split the metadata of ``Annotated[T, *metadata]`` into (inner_constraints, outer_safe).
|
|
227
|
+
|
|
228
|
+
**Why**: Pydantic constraint markers (``Ge/Le/MultipleOf/_PydanticGeneralMetadata``
|
|
229
|
+
etc.) raise ``TypeError: Unable to apply constraint ... to supplied value None`` on
|
|
230
|
+
None -- so the constraint markers of a ``T | None`` field must be wrapped inside the
|
|
231
|
+
inner ``Annotated[T, ...]`` so Pydantic only applies them when the value is not
|
|
232
|
+
None. Schema markers (``PlainSerializer/BeforeValidator`` etc.) and ORM markers
|
|
233
|
+
(``sa_type``/``default`` etc.) are None-safe and may stay in the outer layer. This
|
|
234
|
+
is the essence behind the hand-written nested-Annotated form of optional
|
|
235
|
+
constrained aliases like ``OptionalNonNegativeDecimal38_18``.
|
|
236
|
+
|
|
237
|
+
Classification rules:
|
|
238
|
+
|
|
239
|
+
- ``annotated_types.BaseMetadata`` subclasses (``Ge/Le/Gt/Lt/MultipleOf/MinLen/
|
|
240
|
+
MaxLen/Predicate`` etc.) -> inner (all constraint validators, crash on None)
|
|
241
|
+
- Pydantic ``FieldInfo`` -> split: ``fi.metadata`` (the constraint marker sequence
|
|
242
|
+
Pydantic already expanded) -> inner; the remaining schema/orm attrs are rebuilt
|
|
243
|
+
into a fresh FieldInfo -> outer
|
|
244
|
+
- objects with ``max_digits`` / ``decimal_places`` / ``pattern`` attributes that are
|
|
245
|
+
not FieldInfo (i.e. ``_PydanticGeneralMetadata``) -> inner
|
|
246
|
+
- everything else (``PlainSerializer/WrapSerializer/BeforeValidator/AfterValidator/
|
|
247
|
+
PlainValidator/WrapValidator`` etc.) -> outer (validators are expected to be
|
|
248
|
+
None-safe, like a reject-float BeforeValidator)
|
|
249
|
+
"""
|
|
250
|
+
inner: list[typing.Any] = []
|
|
251
|
+
outer: list[typing.Any] = []
|
|
252
|
+
for m in metadata:
|
|
253
|
+
if isinstance(m, at.BaseMetadata):
|
|
254
|
+
inner.append(m)
|
|
255
|
+
elif isinstance(m, FieldInfo):
|
|
256
|
+
inner.extend(m.metadata)
|
|
257
|
+
outer_fi = _strip_constraint_metadata_from_field_info(m)
|
|
258
|
+
if outer_fi is not None:
|
|
259
|
+
outer.append(outer_fi)
|
|
260
|
+
elif isinstance(m, at.GroupedMetadata):
|
|
261
|
+
# GroupedMetadata protocol (``pydantic.StringConstraints`` etc.):
|
|
262
|
+
# iterating yields a sequence of _PydanticGeneralMetadata /
|
|
263
|
+
# annotated_types markers; all go inner.
|
|
264
|
+
inner.extend(list(m))
|
|
265
|
+
elif hasattr(m, 'max_digits') or hasattr(m, 'decimal_places') or hasattr(m, 'pattern'):
|
|
266
|
+
# _PydanticGeneralMetadata (private, duck-typed): max_digits/decimal_places/pattern
|
|
267
|
+
inner.append(m)
|
|
268
|
+
else:
|
|
269
|
+
outer.append(m)
|
|
270
|
+
return inner, outer
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _strip_constraint_metadata_from_field_info(fi: FieldInfo) -> FieldInfo | None:
|
|
274
|
+
"""Copy an outer-layer version of a FieldInfo: drop ``fi.metadata`` (constraint markers,
|
|
275
|
+
moved inner), keep sa_type/default/alias/description and other schema/orm attrs.
|
|
276
|
+
Returns None when no non-constraint attrs remain."""
|
|
277
|
+
new_kwargs: dict[str, typing.Any] = {}
|
|
278
|
+
for attr in _FIELDINFO_NON_CONSTRAINT_ATTRS:
|
|
279
|
+
v = getattr(fi, attr, Undefined)
|
|
280
|
+
if v is None or v is Undefined:
|
|
281
|
+
continue
|
|
282
|
+
new_kwargs[attr] = v
|
|
283
|
+
if not new_kwargs:
|
|
284
|
+
return None
|
|
285
|
+
return Field(**new_kwargs)
|
|
286
|
+
|
|
287
|
+
|
|
195
288
|
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
196
289
|
"""
|
|
197
290
|
Convert type annotation to optional: ``T → T | None``
|
|
198
291
|
|
|
199
292
|
Places ``| None`` inside ``Annotated[]`` to preserve Field metadata.
|
|
200
293
|
|
|
294
|
+
**Key constraint** (isomorphic to ``OptionalNonNegativeDecimal38_18``): when ``T``
|
|
295
|
+
carries constraint validators (``Ge/Le/MultipleOf/MaxDigits/DecimalPlaces`` etc.),
|
|
296
|
+
the constraints **must** be nested into the inner ``Annotated[T, *constraints]``,
|
|
297
|
+
otherwise Pydantic crashes parsing JSON ``null`` with
|
|
298
|
+
``TypeError: Unable to apply constraint 'ge' to supplied value None``.
|
|
299
|
+
Schema/serializer/ORM markers (``PlainSerializer/sa_type/default`` etc.) are
|
|
300
|
+
None-safe and stay outer.
|
|
301
|
+
|
|
201
302
|
Examples::
|
|
202
303
|
|
|
203
304
|
str → str | None
|
|
204
|
-
Annotated[float,
|
|
305
|
+
Annotated[float, Ge(0)] → Annotated[Annotated[float, Ge(0)] | None]
|
|
306
|
+
Annotated[Decimal, Field(ge=0, sa_type=...), PlainSerializer(...)]
|
|
307
|
+
→ Annotated[Annotated[Decimal, Ge(0)] | None, Field(sa_type=...), PlainSerializer(...)]
|
|
205
308
|
str | None → str | None (already optional, unchanged)
|
|
206
309
|
"""
|
|
207
310
|
origin = get_origin(annotation)
|
|
208
311
|
|
|
209
|
-
# Annotated[T, metadata...]
|
|
312
|
+
# Annotated[T, metadata...]: split constraint/schema metadata, then build
|
|
313
|
+
# the nested optional annotation.
|
|
210
314
|
if origin is typing.Annotated:
|
|
211
315
|
args = get_args(annotation)
|
|
212
316
|
inner_type = args[0]
|
|
213
317
|
metadata = args[1:]
|
|
214
|
-
|
|
215
|
-
|
|
318
|
+
inner_meta, outer_meta = _split_metadata_for_optional(metadata)
|
|
319
|
+
# Recurse into inner_type: it may itself be Annotated (nested aliases).
|
|
320
|
+
recursed_inner_type = _maybe_recurse_into_annotated(inner_type)
|
|
321
|
+
if inner_meta:
|
|
322
|
+
inner_annotated = typing.Annotated[tuple([recursed_inner_type, *inner_meta])]
|
|
323
|
+
else:
|
|
324
|
+
inner_annotated = recursed_inner_type
|
|
325
|
+
# ``typing.Union[X, None]`` is equivalent to PEP 604 ``X | None``, but type
|
|
326
|
+
# checkers can correctly infer ``inner_annotated`` is ``Annotated[...]``
|
|
327
|
+
# (a dynamic type without static ``__or__`` support).
|
|
328
|
+
optional_inner = typing.Union[inner_annotated, type(None)] # pyright: ignore[reportGeneralTypeIssues]
|
|
329
|
+
if outer_meta:
|
|
330
|
+
return typing.Annotated[tuple([optional_inner, *outer_meta])]
|
|
331
|
+
return optional_inner
|
|
216
332
|
|
|
217
333
|
# Union / UnionType already contains None → unchanged
|
|
218
334
|
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
@@ -224,6 +340,25 @@ def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
|
224
340
|
return annotation | None
|
|
225
341
|
|
|
226
342
|
|
|
343
|
+
def _maybe_recurse_into_annotated(annotation: typing.Any) -> typing.Any:
|
|
344
|
+
"""For nested Annotated (``Annotated[Annotated[T, ...], ...]``), split the inner
|
|
345
|
+
layer's constraints too. Non-Annotated values are returned unchanged."""
|
|
346
|
+
if get_origin(annotation) is typing.Annotated:
|
|
347
|
+
args = get_args(annotation)
|
|
348
|
+
inner_type = args[0]
|
|
349
|
+
metadata = args[1:]
|
|
350
|
+
inner_meta, outer_meta = _split_metadata_for_optional(metadata)
|
|
351
|
+
# This layer is already at the outer position or wrapped as inner --
|
|
352
|
+
# keep its structure: the constraints are in inner_meta, reassemble
|
|
353
|
+
# into Annotated without appending | None (the parent caller does that).
|
|
354
|
+
recursed = _maybe_recurse_into_annotated(inner_type)
|
|
355
|
+
all_meta = list(inner_meta) + list(outer_meta)
|
|
356
|
+
if all_meta:
|
|
357
|
+
return typing.Annotated[tuple([recursed, *all_meta])]
|
|
358
|
+
return recursed
|
|
359
|
+
return annotation
|
|
360
|
+
|
|
361
|
+
|
|
227
362
|
def _apply_all_fields_optional(
|
|
228
363
|
annotations: dict[str, typing.Any],
|
|
229
364
|
attrs: dict[str, typing.Any],
|
|
@@ -467,6 +602,99 @@ def _recover_annotated_sqlmodel_fields(
|
|
|
467
602
|
annotations[field_name] = new_inner
|
|
468
603
|
|
|
469
604
|
|
|
605
|
+
def _is_none_node(node: ast.expr) -> bool:
|
|
606
|
+
"""Whether a type-expression AST node denotes ``None`` / ``NoneType``."""
|
|
607
|
+
if isinstance(node, ast.Constant) and node.value is None:
|
|
608
|
+
return True
|
|
609
|
+
return isinstance(node, ast.Name) and node.id in ('None', 'NoneType')
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _relationship_target_node(node: ast.expr) -> ast.expr | None:
|
|
613
|
+
"""
|
|
614
|
+
Extract the single non-``None`` target node from a type-expression AST.
|
|
615
|
+
|
|
616
|
+
Covers every shape a relationship annotation can take: ``Foo`` / ``pkg.Foo`` /
|
|
617
|
+
``Foo | None`` / ``None | Foo`` / ``Optional[Foo]`` / ``Union[Foo, None]``, plus
|
|
618
|
+
an inner requoted form (``Optional['Foo']``). When the expression cannot be
|
|
619
|
+
reduced to a single target (e.g. a multi-member union ``Foo | Bar``), returns
|
|
620
|
+
``None`` so the caller hands the annotation back to SQLModel's native parser
|
|
621
|
+
untouched, letting it raise its own clear error.
|
|
622
|
+
"""
|
|
623
|
+
# Bare class name / dotted (module-qualified) class name.
|
|
624
|
+
if isinstance(node, (ast.Name, ast.Attribute)):
|
|
625
|
+
return node
|
|
626
|
+
# Inner requoted string constant (e.g. ``Optional['Foo']``): recurse into it.
|
|
627
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
628
|
+
try:
|
|
629
|
+
return _relationship_target_node(ast.parse(node.value.strip(), mode='eval').body)
|
|
630
|
+
except SyntaxError:
|
|
631
|
+
return None
|
|
632
|
+
# ``A | None`` / ``None | A``.
|
|
633
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
|
|
634
|
+
left_none, right_none = _is_none_node(node.left), _is_none_node(node.right)
|
|
635
|
+
if left_none ^ right_none: # exactly one side is None
|
|
636
|
+
return _relationship_target_node(node.right if left_none else node.left)
|
|
637
|
+
return None
|
|
638
|
+
# ``Optional[A]`` / ``Union[A, None]``.
|
|
639
|
+
if isinstance(node, ast.Subscript):
|
|
640
|
+
base = node.value
|
|
641
|
+
base_name = (
|
|
642
|
+
base.id if isinstance(base, ast.Name)
|
|
643
|
+
else base.attr if isinstance(base, ast.Attribute)
|
|
644
|
+
else None
|
|
645
|
+
)
|
|
646
|
+
if base_name == 'Optional':
|
|
647
|
+
return _relationship_target_node(node.slice)
|
|
648
|
+
if base_name == 'Union':
|
|
649
|
+
elts = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice]
|
|
650
|
+
non_none = [e for e in elts if not _is_none_node(e)]
|
|
651
|
+
if len(non_none) == 1:
|
|
652
|
+
return _relationship_target_node(non_none[0])
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _resolve_relationship_target(
|
|
657
|
+
name: str,
|
|
658
|
+
rel_info: typing.Any,
|
|
659
|
+
annotation: typing.Any,
|
|
660
|
+
) -> typing.Any:
|
|
661
|
+
"""
|
|
662
|
+
Wrapper around SQLModel's ``get_relationship_to`` that first normalizes a
|
|
663
|
+
flat-string / ``ForwardRef`` nullable relationship annotation (e.g.
|
|
664
|
+
``'UserFolder | None'``) into a structured ``ForwardRef('UserFolder')`` via
|
|
665
|
+
``ast``, then delegates to upstream.
|
|
666
|
+
|
|
667
|
+
Root cause: ``get_relationship_to`` can only strip ``None`` from an
|
|
668
|
+
**already-evaluated** ``typing.Union``; it cannot parse a whole-string PEP 604
|
|
669
|
+
annotation -- it would treat the entire ``'UserFolder | None'`` string as the
|
|
670
|
+
class name and hand it to SQLAlchemy. ``Optional['T']`` works only because
|
|
671
|
+
``Optional[...]`` is evaluated at class-definition time into
|
|
672
|
+
``Union[ForwardRef('T'), None]``, leaving just the inner ``'T'`` as a ForwardRef.
|
|
673
|
+
|
|
674
|
+
This wrapper lets relationship fields be annotated with the PEP 604
|
|
675
|
+
``'X | None'`` form (pyright-friendly, no ``reportDeprecated`` from
|
|
676
|
+
``Optional[...]``) and converts it back at runtime into a form
|
|
677
|
+
``get_relationship_to`` recognizes. Already-evaluated typing objects
|
|
678
|
+
(``list[...]`` / ``Optional[...]`` / concrete classes) pass through unchanged;
|
|
679
|
+
unrecognized expressions are likewise passed through to upstream, preserving
|
|
680
|
+
its original error semantics. Note: only a temporary local value is produced
|
|
681
|
+
for resolution -- ``cls.__annotations__`` is left intact.
|
|
682
|
+
"""
|
|
683
|
+
if isinstance(annotation, (str, typing.ForwardRef)):
|
|
684
|
+
expr = (
|
|
685
|
+
annotation.__forward_arg__
|
|
686
|
+
if isinstance(annotation, typing.ForwardRef)
|
|
687
|
+
else annotation
|
|
688
|
+
)
|
|
689
|
+
try:
|
|
690
|
+
target = _relationship_target_node(ast.parse(expr.strip(), mode='eval').body)
|
|
691
|
+
except SyntaxError:
|
|
692
|
+
target = None
|
|
693
|
+
if target is not None:
|
|
694
|
+
annotation = typing.ForwardRef(ast.unparse(target))
|
|
695
|
+
return get_relationship_to(name=name, rel_info=rel_info, annotation=annotation)
|
|
696
|
+
|
|
697
|
+
|
|
470
698
|
def _make_sti_fk_resolver(
|
|
471
699
|
fk_string: str,
|
|
472
700
|
sa_registry: typing.Any,
|
|
@@ -520,6 +748,52 @@ def _make_sti_fk_resolver(
|
|
|
520
748
|
return _resolve
|
|
521
749
|
|
|
522
750
|
|
|
751
|
+
# ==================== Custom table_args element interception ====================
|
|
752
|
+
#
|
|
753
|
+
# SQLAlchemy's ``Table.__init__`` consumes every ``__table_args__`` element
|
|
754
|
+
# **immediately**; an ``Index`` referencing a column that does not exist yet
|
|
755
|
+
# raises ``ConstraintColumnNotFoundError`` on the spot.
|
|
756
|
+
#
|
|
757
|
+
# ``CustomTableArg`` is a generic base class that lets users place
|
|
758
|
+
# **non-SQLAlchemy-native** marker objects into ``table_args`` -- the
|
|
759
|
+
# metaclass (``__DeclarativeMeta.__new__``) **intercepts** these markers,
|
|
760
|
+
# removes them from ``table_args`` (never handing them to SQLAlchemy), and
|
|
761
|
+
# pushes (target class, markers) onto the module-level queue
|
|
762
|
+
# ``_classes_with_custom_table_args`` for downstream infrastructure (e.g. the
|
|
763
|
+
# STI deferred index in ``mixins.polymorphic``) to scan and consume at the
|
|
764
|
+
# right moment.
|
|
765
|
+
#
|
|
766
|
+
# The base layer only knows the generic "defer processing" mechanism and
|
|
767
|
+
# **not** the concrete semantics (STI, JTI, ...) -- concrete types (e.g.
|
|
768
|
+
# ``DeferredIndex``) are defined by their own submodules as ``CustomTableArg``
|
|
769
|
+
# subclasses.
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class CustomTableArg:
|
|
773
|
+
"""
|
|
774
|
+
Generic base class for non-SQLAlchemy-native ``table_args`` elements.
|
|
775
|
+
|
|
776
|
+
Objects inheriting this class, when placed in ``table_args``, are
|
|
777
|
+
**intercepted** by the SQLModel metaclass -- never passed to SQLAlchemy
|
|
778
|
+
``Table.__init__`` (avoiding immediate-evaluation failures) and instead
|
|
779
|
+
stashed on the ``_classes_with_custom_table_args`` queue for downstream
|
|
780
|
+
infrastructure to consume.
|
|
781
|
+
|
|
782
|
+
Concrete subclass example: ``mixins.polymorphic.DeferredIndex``
|
|
783
|
+
(deferred indexes over STI subclass columns).
|
|
784
|
+
"""
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
_classes_with_custom_table_args: list[tuple[type, list[CustomTableArg]]] = []
|
|
788
|
+
"""
|
|
789
|
+
Queue of classes carrying custom ``table_args`` elements: ``(class, [CustomTableArg, ...])``.
|
|
790
|
+
|
|
791
|
+
Appended by ``__DeclarativeMeta.__new__`` after class creation completes.
|
|
792
|
+
Downstream infrastructure (e.g. ``mixins.polymorphic._create_sti_deferred_indexes()``)
|
|
793
|
+
scans this queue and consumes entries as needed (dispatching on ``isinstance``).
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
|
|
523
797
|
class __DeclarativeMeta(SQLModelMetaclass):
|
|
524
798
|
"""
|
|
525
799
|
A smart hybrid metaclass providing flexibility and clarity:
|
|
@@ -593,9 +867,49 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
593
867
|
existing.update(collected_mapper_args)
|
|
594
868
|
attrs['__mapper_args__'] = existing
|
|
595
869
|
|
|
870
|
+
# 3.5. OptimisticLockMixin wiring: register the mixin's ``version``
|
|
871
|
+
# column as SQLAlchemy's ``version_id_col`` so every UPDATE emits
|
|
872
|
+
# ``SET version = version + 1 WHERE ... AND version = :current`` and a
|
|
873
|
+
# lost update surfaces as StaleDataError. The Column object only exists
|
|
874
|
+
# after the Table is built, so this must be a ``declared_attr`` that
|
|
875
|
+
# declarative evaluates late. Only applied to the root table class --
|
|
876
|
+
# STI/JTI children inherit version_id_col from the root mapper.
|
|
877
|
+
if will_be_table and any(getattr(b, '_has_optimistic_lock', False) for b in bases):
|
|
878
|
+
_is_inheriting_table = parent_tablename is not None
|
|
879
|
+
if not _is_inheriting_table and 'version_id_col' not in attrs.get('__mapper_args__', {}):
|
|
880
|
+
_static_mapper_args = dict(attrs.get('__mapper_args__', {}))
|
|
881
|
+
|
|
882
|
+
def _mapper_args_with_version_col(target_cls, _static=_static_mapper_args):
|
|
883
|
+
merged = dict(_static)
|
|
884
|
+
merged['version_id_col'] = target_cls.__table__.c.version
|
|
885
|
+
return merged
|
|
886
|
+
|
|
887
|
+
# ``.directive`` is SQLAlchemy 2.0's spelling for declarative
|
|
888
|
+
# dunder directives like __mapper_args__ (plain declared_attr
|
|
889
|
+
# on a dunder emits a usage warning).
|
|
890
|
+
attrs['__mapper_args__'] = declared_attr.directive(_mapper_args_with_version_col)
|
|
891
|
+
|
|
596
892
|
# Process other explicit args
|
|
597
893
|
if 'table_args' in kwargs:
|
|
598
|
-
|
|
894
|
+
raw_table_args = kwargs.pop('table_args')
|
|
895
|
+
# Split out CustomTableArg markers -- never handed to SQLAlchemy
|
|
896
|
+
# (avoiding immediate evaluation against not-yet-existing
|
|
897
|
+
# columns); stashed on the module-level
|
|
898
|
+
# ``_classes_with_custom_table_args`` queue for downstream
|
|
899
|
+
# infrastructure (e.g. STI deferred indexes) to consume.
|
|
900
|
+
real_table_args: list[Any] = []
|
|
901
|
+
custom_table_args: list[CustomTableArg] = []
|
|
902
|
+
for arg in raw_table_args:
|
|
903
|
+
if isinstance(arg, CustomTableArg):
|
|
904
|
+
custom_table_args.append(arg)
|
|
905
|
+
else:
|
|
906
|
+
real_table_args.append(arg)
|
|
907
|
+
attrs['__table_args__'] = tuple(real_table_args) if real_table_args else ()
|
|
908
|
+
if custom_table_args:
|
|
909
|
+
# Temporarily hung on attrs; appended to the module-level
|
|
910
|
+
# queue after super().__new__ (the result class is fully
|
|
911
|
+
# constructed then, so downstream can read __table__ etc.).
|
|
912
|
+
attrs['__custom_table_args__'] = custom_table_args
|
|
599
913
|
if 'table_name' in kwargs:
|
|
600
914
|
attrs['__tablename__'] = kwargs.pop('table_name')
|
|
601
915
|
if 'abstract' in kwargs:
|
|
@@ -675,6 +989,13 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
675
989
|
# 6. Call parent __new__
|
|
676
990
|
result = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
677
991
|
|
|
992
|
+
# 6.5. Append intercepted CustomTableArg markers to the module-level
|
|
993
|
+
# queue for downstream infrastructure. The result class is fully
|
|
994
|
+
# constructed at this point, so consumers can access __table__ etc.
|
|
995
|
+
_custom_args = attrs.get('__custom_table_args__')
|
|
996
|
+
if _custom_args:
|
|
997
|
+
_classes_with_custom_table_args.append((result, _custom_args))
|
|
998
|
+
|
|
678
999
|
# 7. Restore SQLModel FieldInfo attributes discarded by Pydantic and rebuild Columns.
|
|
679
1000
|
# Pydantic FieldInfo uses __slots__, so setattr for SQLModel extensions is silently
|
|
680
1001
|
# ignored. We replace the Pydantic FieldInfo with our saved SQLModelFieldInfo and
|
|
@@ -975,7 +1296,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
975
1296
|
ann = raw_ann.__args__[0]
|
|
976
1297
|
else:
|
|
977
1298
|
ann = raw_ann
|
|
978
|
-
relationship_to =
|
|
1299
|
+
relationship_to = _resolve_relationship_target(
|
|
979
1300
|
name=rel_name, rel_info=rel_info, annotation=ann
|
|
980
1301
|
)
|
|
981
1302
|
rel_kwargs: dict[str, typing.Any] = {}
|
|
@@ -1070,7 +1391,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
1070
1391
|
else:
|
|
1071
1392
|
ann = raw_ann
|
|
1072
1393
|
|
|
1073
|
-
relationship_to =
|
|
1394
|
+
relationship_to = _resolve_relationship_target(
|
|
1074
1395
|
name=rel_name, rel_info=rel_info, annotation=ann
|
|
1075
1396
|
)
|
|
1076
1397
|
rel_kwargs: dict[str, typing.Any] = {}
|