sqlmodel-ext 0.4.0__py3-none-any.whl → 0.4.2__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,7 +20,7 @@ Quick start::
20
20
  user = await user.save(session)
21
21
  users = await User.get(session, fetch_mode="all")
22
22
  """
23
- __version__ = "0.4.0"
23
+ __version__ = "0.4.2"
24
24
 
25
25
  # Base
26
26
  from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase, CustomTableArg
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
@@ -22,7 +23,7 @@ from pydantic import ConfigDict, model_validator
22
23
  from pydantic.fields import FieldInfo
23
24
  from pydantic_core import PydanticUndefined as Undefined
24
25
  from sqlalchemy import inspect as sa_inspect
25
- from sqlalchemy.orm import Mapped, relationship as sa_relationship
26
+ from sqlalchemy.orm import Mapped, declared_attr, relationship as sa_relationship
26
27
  from sqlmodel import Field, SQLModel
27
28
  from sqlmodel.main import (
28
29
  SQLModelMetaclass,
@@ -33,6 +34,14 @@ from sqlmodel.main import (
33
34
  get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
34
35
  )
35
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
+
36
45
  # sqlmodel's FieldInfoMetadata fields default to sqlmodel's own Undefined
37
46
  # sentinel (distinct from pydantic_core PydanticUndefined imported above);
38
47
  # capture it to detect "sa_type not yet set" without importing sqlmodel internals.
@@ -593,6 +602,99 @@ def _recover_annotated_sqlmodel_fields(
593
602
  annotations[field_name] = new_inner
594
603
 
595
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
+
596
698
  def _make_sti_fk_resolver(
597
699
  fk_string: str,
598
700
  sa_registry: typing.Any,
@@ -765,6 +867,28 @@ class __DeclarativeMeta(SQLModelMetaclass):
765
867
  existing.update(collected_mapper_args)
766
868
  attrs['__mapper_args__'] = existing
767
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
+
768
892
  # Process other explicit args
769
893
  if 'table_args' in kwargs:
770
894
  raw_table_args = kwargs.pop('table_args')
@@ -851,6 +975,14 @@ class __DeclarativeMeta(SQLModelMetaclass):
851
975
  # FieldInfoMetadata channel — plain setattr is dropped
852
976
  # before SQLModel's column build (see _durably_set_sa_type).
853
977
  _durably_set_sa_type(field_value, sa_type)
978
+ else:
979
+ # Bare default value (e.g. ``fpath: FilePathType = Path("a.txt")``):
980
+ # the assignment is neither Undefined nor a FieldInfo. Wrap it in a
981
+ # Field so the extracted sa_type reaches the column builder while the
982
+ # default is preserved — without this branch the column silently
983
+ # falls back to the base-type inference (AutoString for Path),
984
+ # dropping the type's result_processor.
985
+ attrs[field_name] = Field(default=field_value, sa_type=sa_type)
854
986
 
855
987
  # 5. Save SQLModel FieldInfo from Annotated fields before super().__new__(),
856
988
  # because Pydantic rebuilds model_fields with plain FieldInfo that lacks
@@ -1172,7 +1304,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
1172
1304
  ann = raw_ann.__args__[0]
1173
1305
  else:
1174
1306
  ann = raw_ann
1175
- relationship_to = get_relationship_to(
1307
+ relationship_to = _resolve_relationship_target(
1176
1308
  name=rel_name, rel_info=rel_info, annotation=ann
1177
1309
  )
1178
1310
  rel_kwargs: dict[str, typing.Any] = {}
@@ -1267,7 +1399,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
1267
1399
  else:
1268
1400
  ann = raw_ann
1269
1401
 
1270
- relationship_to = get_relationship_to(
1402
+ relationship_to = _resolve_relationship_target(
1271
1403
  name=rel_name, rel_info=rel_info, annotation=ann
1272
1404
  )
1273
1405
  rel_kwargs: dict[str, typing.Any] = {}
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
  from typing import Annotated, Any, Generic, TypeAlias, TypeVar
10
10
 
11
11
  from annotated_types import Ge, Gt
12
- from pydantic import BeforeValidator, PlainSerializer, StringConstraints
12
+ from pydantic import BeforeValidator, PlainSerializer, StringConstraints, WithJsonSchema
13
13
  from sqlalchemy import BigInteger, Numeric
14
14
  from sqlmodel import Field
15
15
 
@@ -373,6 +373,51 @@ This promotes "string at the boundary" from a documentation convention into a
373
373
  hard type-system contract.
374
374
  """
375
375
 
376
+ # ---------------------------------------------------------------------------
377
+ # Decimal OpenAPI validation-schema fix (drop ``number``, keep ``string`` only)
378
+ #
379
+ # Pydantic maps ``Decimal`` to ``anyOf: [number, string]`` in JSON Schema, but
380
+ # ``_reject_float_decimal_input`` rejects float at runtime (a JSON number with a
381
+ # decimal point *is* a float). So the docs would advertise ``0.00001005`` as a
382
+ # valid request value while the server returns 422 — a contract mismatch.
383
+ # ``WithJsonSchema(mode='validation')`` overrides the request-body schema only;
384
+ # response-body schemas are unaffected.
385
+ # ---------------------------------------------------------------------------
386
+
387
+ _DECIMAL_38_18_STR_PATTERN = (
388
+ r'^(?!^[-+.]*$)[+-]?0*(?:\d{0,20}|(?=[\d.]{1,39}0*$)\d{0,20}\.\d{0,18}0*$)'
389
+ )
390
+ _DECIMAL_20_10_STR_PATTERN = (
391
+ r'^(?!^[-+.]*$)[+-]?0*(?:\d{0,10}|(?=[\d.]{1,21}0*$)\d{0,10}\.\d{0,10}0*$)'
392
+ )
393
+
394
+ _DECIMAL_38_18_STR_SCHEMA: WithJsonSchema = WithJsonSchema(
395
+ {'type': 'string', 'pattern': _DECIMAL_38_18_STR_PATTERN},
396
+ mode='validation',
397
+ )
398
+ _DECIMAL_20_10_STR_SCHEMA: WithJsonSchema = WithJsonSchema(
399
+ {'type': 'string', 'pattern': _DECIMAL_20_10_STR_PATTERN},
400
+ mode='validation',
401
+ )
402
+ _OPTIONAL_DECIMAL_38_18_STR_SCHEMA: WithJsonSchema = WithJsonSchema(
403
+ {
404
+ 'anyOf': [
405
+ {'type': 'string', 'pattern': _DECIMAL_38_18_STR_PATTERN},
406
+ {'type': 'null'},
407
+ ]
408
+ },
409
+ mode='validation',
410
+ )
411
+ _OPTIONAL_DECIMAL_20_10_STR_SCHEMA: WithJsonSchema = WithJsonSchema(
412
+ {
413
+ 'anyOf': [
414
+ {'type': 'string', 'pattern': _DECIMAL_20_10_STR_PATTERN},
415
+ {'type': 'null'},
416
+ ]
417
+ },
418
+ mode='validation',
419
+ )
420
+
376
421
  # NUMERIC(38, 18) — 20 integer digits + 18 fractional digits (matches the EVM
377
422
  # wei = 1e-18 ether de-facto standard for high-precision amounts)
378
423
 
@@ -381,6 +426,7 @@ SignedDecimal38_18: TypeAlias = Annotated[
381
426
  _REJECT_FLOAT,
382
427
  Field(max_digits=38, decimal_places=18, sa_type=Numeric(38, 18)), # pyright: ignore[reportArgumentType]
383
428
  _DECIMAL_TO_JSON_STR,
429
+ _DECIMAL_38_18_STR_SCHEMA,
384
430
  ]
385
431
  """NUMERIC(38, 18) Decimal, positive or negative"""
386
432
 
@@ -394,6 +440,7 @@ NonNegativeDecimal38_18: TypeAlias = Annotated[
394
440
  # annotated_types so the ignore scope stays minimal.
395
441
  Field(max_digits=38, decimal_places=18, sa_type=Numeric(38, 18)), # pyright: ignore[reportArgumentType]
396
442
  _DECIMAL_TO_JSON_STR,
443
+ _DECIMAL_38_18_STR_SCHEMA,
397
444
  ]
398
445
  """NUMERIC(38, 18) Decimal, >= 0"""
399
446
 
@@ -404,6 +451,7 @@ PositiveDecimal38_18: TypeAlias = Annotated[
404
451
  # pyright ignore targets ``sa_type`` only (see NonNegativeDecimal38_18)
405
452
  Field(max_digits=38, decimal_places=18, sa_type=Numeric(38, 18)), # pyright: ignore[reportArgumentType]
406
453
  _DECIMAL_TO_JSON_STR,
454
+ _DECIMAL_38_18_STR_SCHEMA,
407
455
  ]
408
456
  """NUMERIC(38, 18) Decimal, > 0"""
409
457
 
@@ -418,6 +466,7 @@ OptionalNonNegativeDecimal38_18: TypeAlias = Annotated[
418
466
  # pyright ignore targets ``sa_type`` only (see NonNegativeDecimal38_18)
419
467
  Field(default=None, sa_type=Numeric(38, 18)), # pyright: ignore[reportArgumentType]
420
468
  _DECIMAL_TO_JSON_STR,
469
+ _OPTIONAL_DECIMAL_38_18_STR_SCHEMA,
421
470
  ]
422
471
  """NUMERIC(38, 18) Decimal, >= 0 or None"""
423
472
 
@@ -429,6 +478,7 @@ SignedDecimal20_10: TypeAlias = Annotated[
429
478
  _REJECT_FLOAT,
430
479
  Field(max_digits=20, decimal_places=10, sa_type=Numeric(20, 10)), # pyright: ignore[reportArgumentType]
431
480
  _DECIMAL_TO_JSON_STR,
481
+ _DECIMAL_20_10_STR_SCHEMA,
432
482
  ]
433
483
  """NUMERIC(20, 10) Decimal, positive or negative"""
434
484
 
@@ -439,6 +489,7 @@ NonNegativeDecimal20_10: TypeAlias = Annotated[
439
489
  # pyright ignore targets ``sa_type`` only (see NonNegativeDecimal38_18)
440
490
  Field(max_digits=20, decimal_places=10, sa_type=Numeric(20, 10)), # pyright: ignore[reportArgumentType]
441
491
  _DECIMAL_TO_JSON_STR,
492
+ _DECIMAL_20_10_STR_SCHEMA,
442
493
  ]
443
494
  """NUMERIC(20, 10) Decimal, >= 0"""
444
495
 
@@ -449,6 +500,7 @@ OptionalNonNegativeDecimal20_10: TypeAlias = Annotated[
449
500
  # pyright ignore targets ``sa_type`` only (see NonNegativeDecimal38_18)
450
501
  Field(default=None, sa_type=Numeric(20, 10)), # pyright: ignore[reportArgumentType]
451
502
  _DECIMAL_TO_JSON_STR,
503
+ _OPTIONAL_DECIMAL_20_10_STR_SCHEMA,
452
504
  ]
453
505
  """NUMERIC(20, 10) Decimal, >= 0 or None"""
454
506
 
@@ -17,7 +17,11 @@ class _PathAsSQLString(TypeDecorator):
17
17
 
18
18
  class _BasePathHandler(ABC):
19
19
  """(Internal) Base class for single-value type handlers like Path."""
20
- sa_type: TypeDecorator = _PathAsSQLString
20
+ # Must be an *instance*, not the class: the metaclass extracts this value
21
+ # from the core-schema metadata and hands it straight to the column
22
+ # builder, which expects a TypeEngine instance (a bare class silently
23
+ # falls back to AutoString, dropping the Path<->str result_processor).
24
+ sa_type: TypeDecorator = _PathAsSQLString()
21
25
 
22
26
  @classmethod
23
27
  @abstractmethod
@@ -12,8 +12,9 @@ Usage::
12
12
  # Limit to 20 elements
13
13
  version_vector: Array[dict, 20] = Field(default_factory=list)
14
14
  """
15
+ import logging
15
16
  from enum import Enum
16
- from typing import TYPE_CHECKING, Annotated, Generic, TypeVar, final, get_origin
17
+ from typing import TYPE_CHECKING, Annotated, Any, Callable, Generic, TypeVar, final, get_origin
17
18
  from uuid import UUID
18
19
 
19
20
  import sqlalchemy as sa
@@ -21,11 +22,107 @@ from pydantic import GetCoreSchemaHandler
21
22
  from pydantic_core import CoreSchema, core_schema
22
23
  from sqlalchemy import Integer, String
23
24
  from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID as PG_UUID
25
+ from sqlalchemy.engine.interfaces import Dialect
26
+
27
+ logger = logging.getLogger(__name__)
24
28
 
25
29
  # --- Generic Type Definitions ---
26
30
  T = TypeVar('T')
27
31
 
28
32
 
33
+ _UNKNOWN_ENUM_ELEM = object()
34
+ """Internal sentinel yielded by ``_TolerantEnum`` when it reads an unknown DB
35
+ value; filtered out at the array level by ``_TolerantEnumArray``. A dedicated
36
+ sentinel (rather than None) is used so it stays distinct from a genuine NULL
37
+ element inside the array."""
38
+
39
+
40
+ class _TolerantEnum(sa.TypeDecorator[Any]):
41
+ """Read-tolerant enum (a ``TypeDecorator`` wrapping ``sa.Enum``): when the
42
+ value returned by the database is not a member of the Python enum, it is
43
+ downgraded to a sentinel instead of raising ``LookupError``.
44
+
45
+ Motivation: during a rolling deployment's version-skew window, a newer pod
46
+ may run a migration that backfills a new enum value into an ``ARRAY(enum)``
47
+ column while an older pod -- still serving traffic with a Python enum that
48
+ lacks that value -- reads the row. ``sa.Enum`` raises ``LookupError`` on an
49
+ unknown value by default, so **every** request on the old pod that loads
50
+ such a row would 500 until the rollout completes.
51
+
52
+ Why a ``TypeDecorator`` rather than subclassing ``sa.Enum`` directly:
53
+ dialect adaptation (``dialect.colspecs`` -> ``type.adapt(impltype)``) copies
54
+ a ``sa.Enum`` subclass instance into a ``postgresql.ENUM`` / asyncpg
55
+ ``AsyncPgEnum`` instance, discarding the subclass overrides. ``TypeDecorator``
56
+ is SQLAlchemy's official mechanism for "behavior wrapping that must survive
57
+ dialect adaptation": the decorator itself stays as the column type, its impl
58
+ is adapted internally, and the ``result_processor`` hook is guaranteed to be
59
+ called.
60
+
61
+ Semantics: unknown values are dropped on the **read path only** (with a
62
+ warning); the write path still goes through Pydantic / PG enum strict
63
+ validation, so no dirty writes are introduced. Dropped values remain in the
64
+ database and become visible again once code that knows them is deployed (the
65
+ DB is the superset source of truth; each code version consumes the subset it
66
+ recognizes -- consistent with Pydantic's ``extra='ignore'`` handling of
67
+ unknown fields).
68
+ """
69
+
70
+ impl = sa.Enum
71
+ cache_ok = True
72
+
73
+ def __init__(self, enum_class: type[Enum], name: str):
74
+ super().__init__(
75
+ enum_class,
76
+ name=name,
77
+ values_callable=lambda e: [m.value for m in e],
78
+ )
79
+ self._enum_name = name
80
+
81
+ def result_processor(
82
+ self, dialect: Dialect, coltype: object,
83
+ ) -> 'Callable[[Any], Any]':
84
+ impl_processor = self.impl_instance.result_processor(dialect, coltype)
85
+
86
+ def process(value: Any) -> Any:
87
+ if value is None:
88
+ return None
89
+ try:
90
+ return impl_processor(value) if impl_processor is not None else value
91
+ except LookupError:
92
+ logger.warning(
93
+ "Enum column read an unknown value %r (enum %s); ignored -- "
94
+ "likely a rolling-deployment version-skew window (the DB holds "
95
+ "a newer enum value that this process's code does not yet know).",
96
+ value, self._enum_name,
97
+ )
98
+ return _UNKNOWN_ENUM_ELEM
99
+
100
+ return process
101
+
102
+
103
+ class _TolerantEnumArray(sa.TypeDecorator[Any]):
104
+ """ARRAY companion to ``_TolerantEnum`` (a ``TypeDecorator`` wrapping
105
+ ``ARRAY``): filters out the unknown-enum sentinel elements while processing
106
+ results.
107
+
108
+ The sentinel is matched by identity (``is``) so genuine NULL elements
109
+ (``None``) inside the array are unaffected.
110
+ """
111
+
112
+ impl = ARRAY
113
+ cache_ok = True
114
+
115
+ def __init__(self, item_type: '_TolerantEnum'):
116
+ super().__init__(item_type)
117
+
118
+ def process_result_value(
119
+ self, value: 'list[Any] | None', dialect: Dialect,
120
+ ) -> 'list[Any] | None':
121
+ if value is None:
122
+ return None
123
+ return [elem for elem in value if elem is not _UNKNOWN_ENUM_ELEM]
124
+
125
+
29
126
  # --- Pydantic/SQLModel Integration Layer ---
30
127
  @final
31
128
  class _ArrayTypeHandler:
@@ -41,14 +138,16 @@ class _ArrayTypeHandler:
41
138
  self.item_schema = core_schema.literal_schema(
42
139
  [member.value for member in origin_type] # pyright: ignore[reportAny]
43
140
  )
44
- # values_callable ensures the PG ENUM is populated with the member
45
- # string values (StrEnum.value) rather than the Python identifiers
46
- # (Enum.name). Required so StrEnum subclasses serialize as their
47
- # user-facing strings (e.g. 'read:own' instead of 'READ_OWN').
48
- self.sa_array_type = ARRAY(sa.Enum(
141
+ # _TolerantEnum carries values_callable so the PG ENUM is populated
142
+ # with the member string values (StrEnum.value) rather than the
143
+ # Python identifiers (Enum.name). Required so StrEnum subclasses
144
+ # serialize as their user-facing strings (e.g. 'read:own' instead of
145
+ # 'READ_OWN'). The _TolerantEnumArray/_TolerantEnum pair also makes
146
+ # the read path tolerant of unknown DB enum values (see their
147
+ # docstrings) instead of raising LookupError during version skew.
148
+ self.sa_array_type = _TolerantEnumArray(_TolerantEnum(
49
149
  origin_type,
50
150
  name=origin_type.__name__.lower(),
51
- values_callable=lambda e: [m.value for m in e],
52
151
  ))
53
152
  return
54
153
 
@@ -384,6 +384,30 @@ class TableBaseMixin(AsyncAttrs):
384
384
  current_data: dict[str, Any] | None = None
385
385
 
386
386
  while True:
387
+ # Snapshot scalar state BEFORE attempting the flush: a failed
388
+ # versioned UPDATE rolls back the inner transaction, which expires
389
+ # every attribute -- inside the except block any attribute read
390
+ # would emit SQL and raise PendingRollbackError/MissingGreenlet.
391
+ # Read via identity/__dict__ only: the instance itself may already
392
+ # be expired by an earlier commit, so plain getattr could emit SQL.
393
+ _pre_insp = cast(InstanceState[Any], inspect(instance))
394
+ instance_id = _pre_insp.identity[0] if _pre_insp.identity else instance.__dict__.get('id')
395
+ instance_version = instance.__dict__.get('version')
396
+ if optimistic_retry_count > 0 and current_data is None:
397
+ # Capture only the columns the caller actually modified (via
398
+ # SQLAlchemy attribute history). Re-applying a full model_dump
399
+ # on retry would overwrite the other transaction's committed
400
+ # values with our stale ones -- the exact lost update the
401
+ # optimistic lock exists to prevent. For a transient instance
402
+ # every set attribute has history, so this also covers INSERTs.
403
+ _hist_insp = cast(InstanceState[Any], inspect(instance))
404
+ current_data = {}
405
+ for _col_attr in _hist_insp.mapper.column_attrs:
406
+ if _col_attr.key in ('id', 'version', 'created_at', 'updated_at'):
407
+ continue
408
+ if _hist_insp.attrs[_col_attr.key].history.has_changes():
409
+ current_data[_col_attr.key] = _hist_insp.attrs[_col_attr.key].value
410
+
387
411
  session.add(instance)
388
412
  try:
389
413
  if commit:
@@ -397,22 +421,18 @@ class TableBaseMixin(AsyncAttrs):
397
421
  raise OptimisticLockError(
398
422
  message=f"{cls.__name__} optimistic lock conflict: record modified by another transaction",
399
423
  model_class=cls.__name__,
400
- record_id=str(getattr(instance, 'id', None)),
401
- expected_version=getattr(instance, 'version', None),
424
+ record_id=str(instance_id),
425
+ expected_version=instance_version,
402
426
  original_error=e,
403
427
  ) from e
404
428
 
405
429
  retries_remaining -= 1
406
- if current_data is None:
407
- # TableBaseMixin is always used with SQLModelBase; model_dump provided by Pydantic
408
- current_data = cast(SQLModelBase, self).model_dump(exclude={'id', 'version', 'created_at', 'updated_at'})
409
-
410
- fresh = await cls.get(session, cls.id == self.id)
430
+ fresh = await cls.get(session, cls.id == instance_id)
411
431
  if fresh is None:
412
432
  raise OptimisticLockError(
413
433
  message=f"{cls.__name__} retry failed: record has been deleted",
414
434
  model_class=cls.__name__,
415
- record_id=str(getattr(self, 'id', None)),
435
+ record_id=str(instance_id),
416
436
  original_error=e,
417
437
  ) from e
418
438
 
@@ -472,6 +492,16 @@ class TableBaseMixin(AsyncAttrs):
472
492
  retries_remaining = optimistic_retry_count
473
493
 
474
494
  while True:
495
+ # Snapshot scalar state BEFORE attempting the flush: a failed
496
+ # versioned UPDATE rolls back the inner transaction, which expires
497
+ # every attribute -- inside the except block any attribute read
498
+ # would emit SQL and raise PendingRollbackError/MissingGreenlet.
499
+ # Read via identity/__dict__ only: the instance itself may already
500
+ # be expired by an earlier commit, so plain getattr could emit SQL.
501
+ _pre_insp = cast(InstanceState[Any], inspect(instance))
502
+ instance_id = _pre_insp.identity[0] if _pre_insp.identity else instance.__dict__.get('id')
503
+ instance_version = instance.__dict__.get('version')
504
+
475
505
  # TableBaseMixin is always used with SQLModelBase; sqlmodel_update provided by SQLModel
476
506
  _ = cast(SQLModelBase, instance).sqlmodel_update(update_data, update=extra_data)
477
507
  session.add(instance)
@@ -488,18 +518,18 @@ class TableBaseMixin(AsyncAttrs):
488
518
  raise OptimisticLockError(
489
519
  message=f"{cls.__name__} optimistic lock conflict: record modified by another transaction",
490
520
  model_class=cls.__name__,
491
- record_id=str(getattr(instance, 'id', None)),
492
- expected_version=getattr(instance, 'version', None),
521
+ record_id=str(instance_id),
522
+ expected_version=instance_version,
493
523
  original_error=e,
494
524
  ) from e
495
525
 
496
526
  retries_remaining -= 1
497
- fresh = await cls.get(session, cls.id == self.id)
527
+ fresh = await cls.get(session, cls.id == instance_id)
498
528
  if fresh is None:
499
529
  raise OptimisticLockError(
500
530
  message=f"{cls.__name__} retry failed: record has been deleted",
501
531
  model_class=cls.__name__,
502
- record_id=str(getattr(self, 'id', None)),
532
+ record_id=str(instance_id),
503
533
  original_error=e,
504
534
  ) from e
505
535
  instance = fresh
@@ -575,6 +605,21 @@ class TableBaseMixin(AsyncAttrs):
575
605
  if condition is not None:
576
606
  # cast to ColumnElement[bool]: at runtime condition is always a column expression
577
607
  stmt = sql_delete(cls).where(cast(ColumnElement[bool], condition))
608
+ # STI auto-filter: a Core DELETE built from an STI subclass targets the
609
+ # shared table with no discriminator criteria, so without this filter a
610
+ # subclass-level conditional delete would also remove sibling-subclass
611
+ # rows. Mirrors the WHERE discriminator IN (...) logic used by get()/count().
612
+ if issubclass(cls, PolymorphicBaseMixin) and not cls._is_joined_table_inheritance():
613
+ mapper = cast(Mapper[Any], inspect(cls))
614
+ poly_on = mapper.polymorphic_on
615
+ if poly_on is not None:
616
+ descendant_identities = [
617
+ m.polymorphic_identity
618
+ for m in mapper.self_and_descendants
619
+ if m.polymorphic_identity is not None
620
+ ]
621
+ if descendant_identities:
622
+ stmt = stmt.where(poly_on.in_(descendant_identities))
578
623
  result = cast(CursorResult[Any], await session.execute(stmt))
579
624
  deleted_count = result.rowcount
580
625
  else:
@@ -950,7 +995,7 @@ class TableBaseMixin(AsyncAttrs):
950
995
  chains: list[list[QueryableAttribute[Any]]] = []
951
996
  used: set[QueryableAttribute[Any]] = set()
952
997
 
953
- for root in roots:
998
+ def _walk_chain(root: QueryableAttribute[Any]) -> None:
954
999
  chain = [root]
955
1000
  used.add(root)
956
1001
  current = root
@@ -968,6 +1013,18 @@ class TableBaseMixin(AsyncAttrs):
968
1013
  current = next_rel
969
1014
  chains.append(chain)
970
1015
 
1016
+ for root in roots:
1017
+ _walk_chain(root)
1018
+
1019
+ # Cycle guard: a bidirectional pair (A.b + B.a) makes every
1020
+ # relationship someone's successor, so it never appears under any
1021
+ # root and the requested loads would be silently dropped. Break each
1022
+ # remaining cycle at its first-listed member -- list order expresses
1023
+ # the caller's intended chain direction.
1024
+ for r in load_list:
1025
+ if r not in used:
1026
+ _walk_chain(r)
1027
+
971
1028
  return chains
972
1029
 
973
1030
  @classmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlmodel-ext
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphic inheritance, optimistic locking, relation preloading, and reusable field types.
5
5
  Project-URL: Homepage, https://github.com/Foxerine/sqlmodel-ext
6
6
  Project-URL: Repository, https://github.com/Foxerine/sqlmodel-ext
@@ -24,14 +24,20 @@ Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.12
25
25
  Requires-Dist: pydantic>=2.0
26
26
  Requires-Dist: sqlalchemy>=2.0
27
- Requires-Dist: sqlmodel>=0.0.22
27
+ Requires-Dist: sqlmodel>=0.0.32
28
28
  Provides-Extra: cache
29
29
  Requires-Dist: orjson>=3.0; extra == 'cache'
30
30
  Requires-Dist: redis>=5.0; extra == 'cache'
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: aiosqlite>=0.20; extra == 'dev'
33
+ Requires-Dist: fakeredis>=2.20; extra == 'dev'
34
+ Requires-Dist: fastapi>=0.100.0; extra == 'dev'
35
+ Requires-Dist: numpy>=1.24; extra == 'dev'
36
+ Requires-Dist: orjson>=3.0; extra == 'dev'
37
+ Requires-Dist: pgvector>=0.3; extra == 'dev'
33
38
  Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
34
39
  Requires-Dist: pytest>=8.0; extra == 'dev'
40
+ Requires-Dist: redis>=5.0; extra == 'dev'
35
41
  Requires-Dist: ruff>=0.4; extra == 'dev'
36
42
  Provides-Extra: fastapi
37
43
  Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
@@ -70,7 +76,7 @@ Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphi
70
76
  | **RelationPreloadMixin** | Decorator-based automatic relationship preloading (prevents `MissingGreenlet` errors) |
71
77
  | **ListResponse[T]** | Generic paginated response model for list endpoints |
72
78
  | **Field Types** | Reusable constrained types: `Str64`, `Port`, `IPAddress`, `HttpUrl`, `SafeHttpUrl`, and more |
73
- | **PostgreSQL Types** | `Array[T]` for native ARRAY, `JSON100K`/`JSONList100K` for size-limited JSONB, `NumpyVector` for pgvector+NumPy |
79
+ | **PostgreSQL Types** | `Array[T]` for native ARRAY (read-tolerant for enum arrays during rolling-deploy version skew), `JSON100K`/`JSONList100K` for size-limited JSONB, `NumpyVector` for pgvector+NumPy |
74
80
  | **Info Response DTOs** | Pre-built mixins for API response models with id/timestamp fields |
75
81
 
76
82
  ## Installation
@@ -108,12 +114,13 @@ pip install sqlmodel-ext[pgvector]
108
114
  ### Define Models
109
115
 
110
116
  ```python
111
- from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, Str64
117
+ from pydantic import EmailStr # requires: pip install 'pydantic[email]'
118
+ from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, NonEmptyStrippedStr64
112
119
 
113
120
  # Base class -- fields only, no database table
114
121
  class UserBase(SQLModelBase):
115
- name: Str64
116
- email: str
122
+ name: NonEmptyStrippedStr64 # user-visible name: rejects "" and whitespace-only
123
+ email: EmailStr
117
124
 
118
125
  # Table class -- inherits fields + gains async CRUD + UUID primary key
119
126
  class User(UserBase, UUIDTableBaseMixin, table=True):
@@ -438,17 +445,19 @@ async def delete_article(session: SessionDep, article_id: UUID) -> None:
438
445
 
439
446
  ```python
440
447
  from abc import ABC, abstractmethod
448
+ from pydantic import EmailStr
441
449
  from sqlmodel_ext import (
442
450
  SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin,
443
451
  AutoPolymorphicIdentityMixin, create_subclass_id_mixin,
444
452
  ListResponse, TableViewRequest,
453
+ Str512, Text1K,
445
454
  )
446
455
 
447
456
  # ── Polymorphic models ────────────────────────────────────────────
448
457
 
449
458
  class NotificationBase(SQLModelBase):
450
459
  user_id: UUID = Field(foreign_key='user.id')
451
- message: str
460
+ message: Text1K
452
461
 
453
462
  class Notification(NotificationBase, UUIDTableBaseMixin, PolymorphicBaseMixin, ABC):
454
463
  @abstractmethod
@@ -457,13 +466,13 @@ class Notification(NotificationBase, UUIDTableBaseMixin, PolymorphicBaseMixin, A
457
466
  NotifSubclassId = create_subclass_id_mixin('notification')
458
467
 
459
468
  class EmailNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMixin, table=True):
460
- email_to: str
469
+ email_to: EmailStr
461
470
 
462
471
  def summary(self) -> str:
463
472
  return f"Email to {self.email_to}: {self.message}"
464
473
 
465
474
  class PushNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMixin, table=True):
466
- device_token: str
475
+ device_token: Str512
467
476
 
468
477
  def summary(self) -> str:
469
478
  return f"Push to {self.device_token}: {self.message}"
@@ -493,15 +502,15 @@ These mixins provide the async CRUD interface. `TableBaseMixin` uses an auto-inc
493
502
  Both mixins automatically add `id`, `created_at`, and `updated_at` fields.
494
503
 
495
504
  ```python
496
- from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin
505
+ from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin, NonEmptyStrippedStr64, Text1K
497
506
 
498
507
  # Integer primary key
499
508
  class LogEntry(SQLModelBase, TableBaseMixin, table=True):
500
- message: str
509
+ message: Text1K
501
510
 
502
511
  # UUID primary key (recommended for most use cases)
503
512
  class Project(SQLModelBase, UUIDTableBaseMixin, table=True):
504
- name: str
513
+ name: NonEmptyStrippedStr64
505
514
  ```
506
515
 
507
516
  #### `add()` -- Batch Insert
@@ -529,7 +538,9 @@ user = await User.add(session, User(name="Alice", email="a@x.com"))
529
538
  user = await user.save(session)
530
539
 
531
540
  # Save with relationship preloading
532
- user = await user.save(session, load=User.profile)
541
+ # (rel() casts the Relationship field for type checkers, which would
542
+ # otherwise infer User.profile as Profile instead of a loadable attribute)
543
+ user = await user.save(session, load=rel(User.profile))
533
544
 
534
545
  # Save with optimistic lock retry
535
546
  user = await user.save(session, optimistic_retry_count=3)
@@ -562,9 +573,10 @@ user3 = await user3.save(session) # commits all three
562
573
  #### `update()` -- Partial Update from a Model
563
574
 
564
575
  ```python
565
- class UserUpdate(SQLModelBase):
566
- name: str | None = None
567
- email: str | None = None
576
+ # all_fields_optional auto-converts every inherited field to ``T | None = None``
577
+ # while preserving constraints -- no hand-written Optional boilerplate
578
+ class UserUpdate(UserBase, all_fields_optional=True):
579
+ pass
568
580
 
569
581
  # Only updates fields that were explicitly set
570
582
  user = await user.update(session, UserUpdate(name="Charlie"))
@@ -643,7 +655,7 @@ users = await User.get(session, fetch_mode="all")
643
655
  user = await User.get(
644
656
  session,
645
657
  User.id == user_id,
646
- load=[User.profile, User.orders],
658
+ load=[rel(User.profile), rel(User.orders)],
647
659
  )
648
660
 
649
661
  # With JOIN
@@ -767,11 +779,12 @@ from sqlmodel_ext import (
767
779
  SQLModelBase, UUIDTableBaseMixin,
768
780
  PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
769
781
  create_subclass_id_mixin,
782
+ HttpUrl, NonEmptyStrippedStr64, NonNegativeInt,
770
783
  )
771
784
 
772
785
  # 1. Base class (fields only, no table)
773
786
  class ToolBase(SQLModelBase):
774
- name: str
787
+ name: NonEmptyStrippedStr64
775
788
 
776
789
  # 2. Abstract parent (creates the parent table)
777
790
  class Tool(ToolBase, UUIDTableBaseMixin, PolymorphicBaseMixin, ABC):
@@ -783,13 +796,13 @@ ToolSubclassIdMixin = create_subclass_id_mixin('tool')
783
796
 
784
797
  # 4. Concrete subclasses (each gets its own table)
785
798
  class WebSearchTool(ToolSubclassIdMixin, Tool, AutoPolymorphicIdentityMixin, table=True):
786
- search_url: str
799
+ search_url: HttpUrl
787
800
 
788
801
  async def execute(self) -> str:
789
802
  return f"Searching {self.search_url}"
790
803
 
791
804
  class CalculatorTool(ToolSubclassIdMixin, Tool, AutoPolymorphicIdentityMixin, table=True):
792
- precision: int = 2
805
+ precision: NonNegativeInt = 2
793
806
 
794
807
  async def execute(self) -> str:
795
808
  return "Calculating..."
@@ -823,16 +836,17 @@ from sqlmodel_ext import (
823
836
  PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
824
837
  register_sti_columns_for_all_subclasses,
825
838
  register_sti_column_properties_for_all_subclasses,
839
+ NonNegativeBigInt, Str256,
826
840
  )
827
841
 
828
842
  class UserFile(SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin, table=True):
829
- filename: str
843
+ filename: Str256
830
844
 
831
845
  class PendingFile(UserFile, AutoPolymorphicIdentityMixin, table=True):
832
846
  upload_deadline: datetime | None = None # Added to userfile table as nullable
833
847
 
834
848
  class CompletedFile(UserFile, AutoPolymorphicIdentityMixin, table=True):
835
- file_size: int | None = None # Added to userfile table as nullable
849
+ file_size: NonNegativeBigInt | None = None # Added to userfile table as nullable
836
850
 
837
851
  # After all models are defined, before configure_mappers():
838
852
  register_sti_columns_for_all_subclasses()
@@ -858,7 +872,7 @@ class ToolSet(SQLModelBase, UUIDTableBaseMixin, table=True):
858
872
  tool_set = await ToolSet.get(
859
873
  session,
860
874
  ToolSet.id == ts_id,
861
- load=ToolSet.tools,
875
+ load=rel(ToolSet.tools),
862
876
  jti_subclasses='all', # Loads all subclass-specific columns
863
877
  )
864
878
  ```
@@ -885,15 +899,22 @@ Tool._is_joined_table_inheritance() # True for JTI, False for STI
885
899
  Prevents lost updates in concurrent environments using SQLAlchemy's `version_id_col` mechanism.
886
900
 
887
901
  ```python
902
+ from enum import StrEnum
903
+
888
904
  from sqlmodel_ext import (
889
905
  SQLModelBase, UUIDTableBaseMixin,
890
906
  OptimisticLockMixin, OptimisticLockError,
907
+ NonNegativeDecimal38_18,
891
908
  )
892
909
 
910
+ class OrderStatusEnum(StrEnum):
911
+ pending = 'pending'
912
+ paid = 'paid'
913
+
893
914
  # OptimisticLockMixin MUST come before TableBaseMixin in MRO
894
915
  class Order(OptimisticLockMixin, UUIDTableBaseMixin, table=True):
895
- status: str
896
- amount: int
916
+ status: OrderStatusEnum = OrderStatusEnum.pending
917
+ amount: NonNegativeDecimal38_18
897
918
  ```
898
919
 
899
920
  The mixin adds a `version` integer field (starting at 0). Every `UPDATE` generates SQL like:
@@ -945,11 +966,11 @@ The `RelationPreloadMixin` and `@requires_relations` decorator automatically loa
945
966
 
946
967
  ```python
947
968
  from sqlmodel import Relationship
948
- from sqlmodel_ext import UUIDTableBaseMixin, SQLModelBase
969
+ from sqlmodel_ext import UUIDTableBaseMixin, SQLModelBase, NonNegativeDecimal38_18
949
970
  from sqlmodel_ext.mixins import RelationPreloadMixin, requires_relations
950
971
 
951
972
  class GeneratorConfig(SQLModelBase, UUIDTableBaseMixin, table=True):
952
- price: int
973
+ price: NonNegativeDecimal38_18
953
974
 
954
975
  class Generator(SQLModelBase, UUIDTableBaseMixin, table=True):
955
976
  config: GeneratorConfig = Relationship()
@@ -1391,12 +1412,12 @@ When using Pydantic's `use_attribute_docstrings=True` (enabled by default in `SQ
1391
1412
 
1392
1413
  ```python
1393
1414
  class UserBase(SQLModelBase):
1394
- name: str
1415
+ name: NonEmptyStrippedStr64
1395
1416
  """User display name""" # ← parsed by Pydantic
1396
1417
 
1397
1418
  class UserUpdateRequest(UserBase, all_fields_optional=True):
1398
1419
  pass
1399
- # name: str | None = None — description "User display name" is automatically inherited
1420
+ # name: NonEmptyStrippedStr64 | None = None — description "User display name" is inherited
1400
1421
  # Shows up correctly in OpenAPI/Swagger docs
1401
1422
  ```
1402
1423
 
@@ -1,21 +1,21 @@
1
- sqlmodel_ext/__init__.py,sha256=e5IwbXgYXGE_lV1Fx9oCXnNKWgdZAgHRtFLCzH1xOos,3755
1
+ sqlmodel_ext/__init__.py,sha256=vfES2vYjiQ-pNQ6vLkH8DMbi5jG8NX5tJQCr4z8WssU,3755
2
2
  sqlmodel_ext/_compat.py,sha256=zia-2vSQqmzk6_4Y404IKzcip5n4fe7PrRAYntlY-Zg,12186
3
3
  sqlmodel_ext/_exceptions.py,sha256=2mC_BVvcreaFutR4UxIra238o1sr4zWv8vctBI6CL2o,413
4
4
  sqlmodel_ext/_sa_type.py,sha256=zDwXBR0O7dne3Y6j8V8CaMtfqZqcpvsPucZUxGh8dAk,4866
5
5
  sqlmodel_ext/_utils.py,sha256=cFrlQp1Uq-4ZAdvi_u79UbEI0IHZaAm5VkXadCXrBmI,174
6
- sqlmodel_ext/base.py,sha256=M1vkigsfoIx4M2iiWBPZ_X5WpA6XSYM-gWhJVyyunlA,65797
6
+ sqlmodel_ext/base.py,sha256=p0P3NR1OPYfY-UsJsor1bm4Hudt2WAQZRNpgfgY11nk,72874
7
7
  sqlmodel_ext/pagination.py,sha256=1JOzt51KvIZTyjgYsQTP0v7KDytIkQKlYN4vGkq9RH8,4611
8
8
  sqlmodel_ext/relation_load_checker.py,sha256=1W2vSdk_PVTqBRRcSyIffw-ECysJRfg1bJYVTmx9Wxs,172457
9
9
  sqlmodel_ext/session.py,sha256=LNt9zZn5KUH8BA9evulKrh7233VYDtj9mn4j_uPkHjw,5570
10
- sqlmodel_ext/field_types/__init__.py,sha256=vOka_SrEi9q_iY0UeQC5fQmwP1m2f3k3r3q82kbEOu8,22020
10
+ sqlmodel_ext/field_types/__init__.py,sha256=O7XYyX0lnu-Q6xaFMJlXKsuV5AO2tE7gU5v76BY-5nI,24002
11
11
  sqlmodel_ext/field_types/_ssrf.py,sha256=QdrtwWMFN6xZkllyHGwvJ0nwL3rSBn2FkEoxv3L8kvs,2627
12
12
  sqlmodel_ext/field_types/ip_address.py,sha256=OeyOAVgDV7aGk56z0LUqgYCinthYYusmZSCR1BLkET4,1589
13
13
  sqlmodel_ext/field_types/url.py,sha256=1iAdu3A9uyMR3XJMs0b3NJ2_VeBAEAfja-_xbdjph5I,5209
14
14
  sqlmodel_ext/field_types/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- sqlmodel_ext/field_types/_internal/path.py,sha256=bJhrGnidXbEFQyJBoMIKHo2p-a2T805BV4mMSZ8vmDE,2070
15
+ sqlmodel_ext/field_types/_internal/path.py,sha256=HNbW0oXjZK654XSU-dckAHhDmTT4Vz4x89iRSdwSbqs,2375
16
16
  sqlmodel_ext/field_types/dialects/__init__.py,sha256=ENJXbWbIZaU8eXNxAG2rjH_w7oQpb1f790ugCZsm1ro,267
17
17
  sqlmodel_ext/field_types/dialects/postgresql/__init__.py,sha256=3hvtu9Z0E4BfZ4VwYWbmDYDV_-NKoQJYA8mUWynCLCU,1291
18
- sqlmodel_ext/field_types/dialects/postgresql/array.py,sha256=NczdAXv3sdAN6nzs5y8wZzv-Ce4RXSx5De0LE70ByHM,4693
18
+ sqlmodel_ext/field_types/dialects/postgresql/array.py,sha256=bj1wMjVE1Hsj_rxfFXa57U3FV_3a8vq1y-0-bmqdGRE,8913
19
19
  sqlmodel_ext/field_types/dialects/postgresql/exceptions.py,sha256=cLFO3bogpP-naidw31JRjh6lJu2eEfahKdz-EQebfMU,771
20
20
  sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py,sha256=Hunep_DyNVNt9v4JVPOn_AW2a8DAkvtcQsrq2_ahdDo,6107
21
21
  sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py,sha256=q9eOCcFyaw4djvmafd1ah_9epRfxE8X9iLRvkphOcyU,14992
@@ -27,8 +27,8 @@ sqlmodel_ext/mixins/info_response.py,sha256=Z8akOpN9wlhiq5_MbzEjwGpGDQ99jYvGQt9s
27
27
  sqlmodel_ext/mixins/optimistic_lock.py,sha256=XVjO6NgKYfpcsGlH1Yj8Ou9NkpCsqVN9AZyWxDBTb_k,2925
28
28
  sqlmodel_ext/mixins/polymorphic.py,sha256=tGg_V98hQu2L0WcI8oOr7hzO9MZ7QfqNVFaACuaD1Dc,33211
29
29
  sqlmodel_ext/mixins/relation_preload.py,sha256=SD9Nnc8zIbUqWqI85gr4XRpbPsmJwXQd7JPHj_EVrLs,15967
30
- sqlmodel_ext/mixins/table.py,sha256=QQ6_4ibzZfD9mdenh0uHpLEJLH-YO2RUflt1VlFrnRA,57251
31
- sqlmodel_ext-0.4.0.dist-info/METADATA,sha256=zWnO5eOAbw80LqqU-Gq_kWpu3kcJuLX-kbDlqXU1rY4,52081
32
- sqlmodel_ext-0.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
33
- sqlmodel_ext-0.4.0.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
34
- sqlmodel_ext-0.4.0.dist-info/RECORD,,
30
+ sqlmodel_ext/mixins/table.py,sha256=JbEhCkbhbYC0TyCSSfSz_3FFtcbvg2nNmh7F-Rtzg-E,60817
31
+ sqlmodel_ext-0.4.2.dist-info/METADATA,sha256=5VlaFg4iU1PI5psQjI2XcmiREVJPsIyjvkQ5f2MJLZs,53370
32
+ sqlmodel_ext-0.4.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
33
+ sqlmodel_ext-0.4.2.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
34
+ sqlmodel_ext-0.4.2.dist-info/RECORD,,