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 +1 -1
- sqlmodel_ext/base.py +135 -3
- sqlmodel_ext/field_types/__init__.py +53 -1
- sqlmodel_ext/field_types/_internal/path.py +5 -1
- sqlmodel_ext/field_types/dialects/postgresql/array.py +106 -7
- sqlmodel_ext/mixins/table.py +70 -13
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.2.dist-info}/METADATA +50 -29
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.2.dist-info}/RECORD +10 -10
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.2.dist-info}/WHEEL +0 -0
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.2.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py
CHANGED
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
45
|
-
# string values (StrEnum.value) rather than the
|
|
46
|
-
# (Enum.name). Required so StrEnum subclasses
|
|
47
|
-
# user-facing strings (e.g. 'read:own' instead of
|
|
48
|
-
|
|
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
|
|
sqlmodel_ext/mixins/table.py
CHANGED
|
@@ -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(
|
|
401
|
-
expected_version=
|
|
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
|
-
|
|
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(
|
|
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(
|
|
492
|
-
expected_version=
|
|
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 ==
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
116
|
-
email:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
896
|
-
amount:
|
|
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:
|
|
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:
|
|
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:
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
31
|
-
sqlmodel_ext-0.4.
|
|
32
|
-
sqlmodel_ext-0.4.
|
|
33
|
-
sqlmodel_ext-0.4.
|
|
34
|
-
sqlmodel_ext-0.4.
|
|
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,,
|
|
File without changes
|
|
File without changes
|