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 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.4"
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, Field(ge=0.0)] → Annotated[float | None, Field(ge=0.0)]
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...] → Annotated[T | None, 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
- optional_inner = _make_annotation_optional(inner_type)
166
- return typing.Annotated[tuple([optional_inner, *metadata])]
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
- attrs['__table_args__'] = kwargs.pop('table_args')
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
- if getattr(annotated_fi, 'sa_type', Undefined) is Undefined:
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
- if getattr(field_value, 'sa_type', Undefined) is Undefined:
612
- setattr(field_value, 'sa_type', sa_type)
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