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 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.3.5"
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, Field(ge=0.0)] → Annotated[float | None, Field(ge=0.0)]
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...] → Annotated[T | None, 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
- optional_inner = _make_annotation_optional(inner_type)
215
- return typing.Annotated[tuple([optional_inner, *metadata])]
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
- attrs['__table_args__'] = kwargs.pop('table_args')
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 = get_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 = get_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] = {}