sqlmodel-ext 0.3.5__py3-none-any.whl → 0.4.0__py3-none-any.whl

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