sqlmodel-ext 0.4.0__py3-none-any.whl → 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlmodel_ext/__init__.py +1 -1
- sqlmodel_ext/base.py +127 -3
- sqlmodel_ext/field_types/__init__.py +53 -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.1.dist-info}/METADATA +44 -29
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.1.dist-info}/RECORD +9 -9
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.1.dist-info}/WHEEL +0 -0
- {sqlmodel_ext-0.4.0.dist-info → sqlmodel_ext-0.4.1.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')
|
|
@@ -1172,7 +1296,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
1172
1296
|
ann = raw_ann.__args__[0]
|
|
1173
1297
|
else:
|
|
1174
1298
|
ann = raw_ann
|
|
1175
|
-
relationship_to =
|
|
1299
|
+
relationship_to = _resolve_relationship_target(
|
|
1176
1300
|
name=rel_name, rel_info=rel_info, annotation=ann
|
|
1177
1301
|
)
|
|
1178
1302
|
rel_kwargs: dict[str, typing.Any] = {}
|
|
@@ -1267,7 +1391,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
1267
1391
|
else:
|
|
1268
1392
|
ann = raw_ann
|
|
1269
1393
|
|
|
1270
|
-
relationship_to =
|
|
1394
|
+
relationship_to = _resolve_relationship_target(
|
|
1271
1395
|
name=rel_name, rel_info=rel_info, annotation=ann
|
|
1272
1396
|
)
|
|
1273
1397
|
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
|
|
|
@@ -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.1
|
|
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,7 +24,7 @@ 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'
|
|
@@ -70,7 +70,7 @@ Extended SQLModel infrastructure: smart metaclass, async CRUD mixins, polymorphi
|
|
|
70
70
|
| **RelationPreloadMixin** | Decorator-based automatic relationship preloading (prevents `MissingGreenlet` errors) |
|
|
71
71
|
| **ListResponse[T]** | Generic paginated response model for list endpoints |
|
|
72
72
|
| **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 |
|
|
73
|
+
| **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
74
|
| **Info Response DTOs** | Pre-built mixins for API response models with id/timestamp fields |
|
|
75
75
|
|
|
76
76
|
## Installation
|
|
@@ -108,12 +108,13 @@ pip install sqlmodel-ext[pgvector]
|
|
|
108
108
|
### Define Models
|
|
109
109
|
|
|
110
110
|
```python
|
|
111
|
-
from
|
|
111
|
+
from pydantic import EmailStr # requires: pip install 'pydantic[email]'
|
|
112
|
+
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin, NonEmptyStrippedStr64
|
|
112
113
|
|
|
113
114
|
# Base class -- fields only, no database table
|
|
114
115
|
class UserBase(SQLModelBase):
|
|
115
|
-
name:
|
|
116
|
-
email:
|
|
116
|
+
name: NonEmptyStrippedStr64 # user-visible name: rejects "" and whitespace-only
|
|
117
|
+
email: EmailStr
|
|
117
118
|
|
|
118
119
|
# Table class -- inherits fields + gains async CRUD + UUID primary key
|
|
119
120
|
class User(UserBase, UUIDTableBaseMixin, table=True):
|
|
@@ -438,17 +439,19 @@ async def delete_article(session: SessionDep, article_id: UUID) -> None:
|
|
|
438
439
|
|
|
439
440
|
```python
|
|
440
441
|
from abc import ABC, abstractmethod
|
|
442
|
+
from pydantic import EmailStr
|
|
441
443
|
from sqlmodel_ext import (
|
|
442
444
|
SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin,
|
|
443
445
|
AutoPolymorphicIdentityMixin, create_subclass_id_mixin,
|
|
444
446
|
ListResponse, TableViewRequest,
|
|
447
|
+
Str512, Text1K,
|
|
445
448
|
)
|
|
446
449
|
|
|
447
450
|
# ── Polymorphic models ────────────────────────────────────────────
|
|
448
451
|
|
|
449
452
|
class NotificationBase(SQLModelBase):
|
|
450
453
|
user_id: UUID = Field(foreign_key='user.id')
|
|
451
|
-
message:
|
|
454
|
+
message: Text1K
|
|
452
455
|
|
|
453
456
|
class Notification(NotificationBase, UUIDTableBaseMixin, PolymorphicBaseMixin, ABC):
|
|
454
457
|
@abstractmethod
|
|
@@ -457,13 +460,13 @@ class Notification(NotificationBase, UUIDTableBaseMixin, PolymorphicBaseMixin, A
|
|
|
457
460
|
NotifSubclassId = create_subclass_id_mixin('notification')
|
|
458
461
|
|
|
459
462
|
class EmailNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMixin, table=True):
|
|
460
|
-
email_to:
|
|
463
|
+
email_to: EmailStr
|
|
461
464
|
|
|
462
465
|
def summary(self) -> str:
|
|
463
466
|
return f"Email to {self.email_to}: {self.message}"
|
|
464
467
|
|
|
465
468
|
class PushNotification(NotifSubclassId, Notification, AutoPolymorphicIdentityMixin, table=True):
|
|
466
|
-
device_token:
|
|
469
|
+
device_token: Str512
|
|
467
470
|
|
|
468
471
|
def summary(self) -> str:
|
|
469
472
|
return f"Push to {self.device_token}: {self.message}"
|
|
@@ -493,15 +496,15 @@ These mixins provide the async CRUD interface. `TableBaseMixin` uses an auto-inc
|
|
|
493
496
|
Both mixins automatically add `id`, `created_at`, and `updated_at` fields.
|
|
494
497
|
|
|
495
498
|
```python
|
|
496
|
-
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin
|
|
499
|
+
from sqlmodel_ext import SQLModelBase, TableBaseMixin, UUIDTableBaseMixin, NonEmptyStrippedStr64, Text1K
|
|
497
500
|
|
|
498
501
|
# Integer primary key
|
|
499
502
|
class LogEntry(SQLModelBase, TableBaseMixin, table=True):
|
|
500
|
-
message:
|
|
503
|
+
message: Text1K
|
|
501
504
|
|
|
502
505
|
# UUID primary key (recommended for most use cases)
|
|
503
506
|
class Project(SQLModelBase, UUIDTableBaseMixin, table=True):
|
|
504
|
-
name:
|
|
507
|
+
name: NonEmptyStrippedStr64
|
|
505
508
|
```
|
|
506
509
|
|
|
507
510
|
#### `add()` -- Batch Insert
|
|
@@ -529,7 +532,9 @@ user = await User.add(session, User(name="Alice", email="a@x.com"))
|
|
|
529
532
|
user = await user.save(session)
|
|
530
533
|
|
|
531
534
|
# Save with relationship preloading
|
|
532
|
-
|
|
535
|
+
# (rel() casts the Relationship field for type checkers, which would
|
|
536
|
+
# otherwise infer User.profile as Profile instead of a loadable attribute)
|
|
537
|
+
user = await user.save(session, load=rel(User.profile))
|
|
533
538
|
|
|
534
539
|
# Save with optimistic lock retry
|
|
535
540
|
user = await user.save(session, optimistic_retry_count=3)
|
|
@@ -562,9 +567,10 @@ user3 = await user3.save(session) # commits all three
|
|
|
562
567
|
#### `update()` -- Partial Update from a Model
|
|
563
568
|
|
|
564
569
|
```python
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
570
|
+
# all_fields_optional auto-converts every inherited field to ``T | None = None``
|
|
571
|
+
# while preserving constraints -- no hand-written Optional boilerplate
|
|
572
|
+
class UserUpdate(UserBase, all_fields_optional=True):
|
|
573
|
+
pass
|
|
568
574
|
|
|
569
575
|
# Only updates fields that were explicitly set
|
|
570
576
|
user = await user.update(session, UserUpdate(name="Charlie"))
|
|
@@ -643,7 +649,7 @@ users = await User.get(session, fetch_mode="all")
|
|
|
643
649
|
user = await User.get(
|
|
644
650
|
session,
|
|
645
651
|
User.id == user_id,
|
|
646
|
-
load=[User.profile, User.orders],
|
|
652
|
+
load=[rel(User.profile), rel(User.orders)],
|
|
647
653
|
)
|
|
648
654
|
|
|
649
655
|
# With JOIN
|
|
@@ -767,11 +773,12 @@ from sqlmodel_ext import (
|
|
|
767
773
|
SQLModelBase, UUIDTableBaseMixin,
|
|
768
774
|
PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
|
|
769
775
|
create_subclass_id_mixin,
|
|
776
|
+
HttpUrl, NonEmptyStrippedStr64, NonNegativeInt,
|
|
770
777
|
)
|
|
771
778
|
|
|
772
779
|
# 1. Base class (fields only, no table)
|
|
773
780
|
class ToolBase(SQLModelBase):
|
|
774
|
-
name:
|
|
781
|
+
name: NonEmptyStrippedStr64
|
|
775
782
|
|
|
776
783
|
# 2. Abstract parent (creates the parent table)
|
|
777
784
|
class Tool(ToolBase, UUIDTableBaseMixin, PolymorphicBaseMixin, ABC):
|
|
@@ -783,13 +790,13 @@ ToolSubclassIdMixin = create_subclass_id_mixin('tool')
|
|
|
783
790
|
|
|
784
791
|
# 4. Concrete subclasses (each gets its own table)
|
|
785
792
|
class WebSearchTool(ToolSubclassIdMixin, Tool, AutoPolymorphicIdentityMixin, table=True):
|
|
786
|
-
search_url:
|
|
793
|
+
search_url: HttpUrl
|
|
787
794
|
|
|
788
795
|
async def execute(self) -> str:
|
|
789
796
|
return f"Searching {self.search_url}"
|
|
790
797
|
|
|
791
798
|
class CalculatorTool(ToolSubclassIdMixin, Tool, AutoPolymorphicIdentityMixin, table=True):
|
|
792
|
-
precision:
|
|
799
|
+
precision: NonNegativeInt = 2
|
|
793
800
|
|
|
794
801
|
async def execute(self) -> str:
|
|
795
802
|
return "Calculating..."
|
|
@@ -823,16 +830,17 @@ from sqlmodel_ext import (
|
|
|
823
830
|
PolymorphicBaseMixin, AutoPolymorphicIdentityMixin,
|
|
824
831
|
register_sti_columns_for_all_subclasses,
|
|
825
832
|
register_sti_column_properties_for_all_subclasses,
|
|
833
|
+
NonNegativeBigInt, Str256,
|
|
826
834
|
)
|
|
827
835
|
|
|
828
836
|
class UserFile(SQLModelBase, UUIDTableBaseMixin, PolymorphicBaseMixin, table=True):
|
|
829
|
-
filename:
|
|
837
|
+
filename: Str256
|
|
830
838
|
|
|
831
839
|
class PendingFile(UserFile, AutoPolymorphicIdentityMixin, table=True):
|
|
832
840
|
upload_deadline: datetime | None = None # Added to userfile table as nullable
|
|
833
841
|
|
|
834
842
|
class CompletedFile(UserFile, AutoPolymorphicIdentityMixin, table=True):
|
|
835
|
-
file_size:
|
|
843
|
+
file_size: NonNegativeBigInt | None = None # Added to userfile table as nullable
|
|
836
844
|
|
|
837
845
|
# After all models are defined, before configure_mappers():
|
|
838
846
|
register_sti_columns_for_all_subclasses()
|
|
@@ -858,7 +866,7 @@ class ToolSet(SQLModelBase, UUIDTableBaseMixin, table=True):
|
|
|
858
866
|
tool_set = await ToolSet.get(
|
|
859
867
|
session,
|
|
860
868
|
ToolSet.id == ts_id,
|
|
861
|
-
load=ToolSet.tools,
|
|
869
|
+
load=rel(ToolSet.tools),
|
|
862
870
|
jti_subclasses='all', # Loads all subclass-specific columns
|
|
863
871
|
)
|
|
864
872
|
```
|
|
@@ -885,15 +893,22 @@ Tool._is_joined_table_inheritance() # True for JTI, False for STI
|
|
|
885
893
|
Prevents lost updates in concurrent environments using SQLAlchemy's `version_id_col` mechanism.
|
|
886
894
|
|
|
887
895
|
```python
|
|
896
|
+
from enum import StrEnum
|
|
897
|
+
|
|
888
898
|
from sqlmodel_ext import (
|
|
889
899
|
SQLModelBase, UUIDTableBaseMixin,
|
|
890
900
|
OptimisticLockMixin, OptimisticLockError,
|
|
901
|
+
NonNegativeDecimal38_18,
|
|
891
902
|
)
|
|
892
903
|
|
|
904
|
+
class OrderStatusEnum(StrEnum):
|
|
905
|
+
pending = 'pending'
|
|
906
|
+
paid = 'paid'
|
|
907
|
+
|
|
893
908
|
# OptimisticLockMixin MUST come before TableBaseMixin in MRO
|
|
894
909
|
class Order(OptimisticLockMixin, UUIDTableBaseMixin, table=True):
|
|
895
|
-
status:
|
|
896
|
-
amount:
|
|
910
|
+
status: OrderStatusEnum = OrderStatusEnum.pending
|
|
911
|
+
amount: NonNegativeDecimal38_18
|
|
897
912
|
```
|
|
898
913
|
|
|
899
914
|
The mixin adds a `version` integer field (starting at 0). Every `UPDATE` generates SQL like:
|
|
@@ -945,11 +960,11 @@ The `RelationPreloadMixin` and `@requires_relations` decorator automatically loa
|
|
|
945
960
|
|
|
946
961
|
```python
|
|
947
962
|
from sqlmodel import Relationship
|
|
948
|
-
from sqlmodel_ext import UUIDTableBaseMixin, SQLModelBase
|
|
963
|
+
from sqlmodel_ext import UUIDTableBaseMixin, SQLModelBase, NonNegativeDecimal38_18
|
|
949
964
|
from sqlmodel_ext.mixins import RelationPreloadMixin, requires_relations
|
|
950
965
|
|
|
951
966
|
class GeneratorConfig(SQLModelBase, UUIDTableBaseMixin, table=True):
|
|
952
|
-
price:
|
|
967
|
+
price: NonNegativeDecimal38_18
|
|
953
968
|
|
|
954
969
|
class Generator(SQLModelBase, UUIDTableBaseMixin, table=True):
|
|
955
970
|
config: GeneratorConfig = Relationship()
|
|
@@ -1391,12 +1406,12 @@ When using Pydantic's `use_attribute_docstrings=True` (enabled by default in `SQ
|
|
|
1391
1406
|
|
|
1392
1407
|
```python
|
|
1393
1408
|
class UserBase(SQLModelBase):
|
|
1394
|
-
name:
|
|
1409
|
+
name: NonEmptyStrippedStr64
|
|
1395
1410
|
"""User display name""" # ← parsed by Pydantic
|
|
1396
1411
|
|
|
1397
1412
|
class UserUpdateRequest(UserBase, all_fields_optional=True):
|
|
1398
1413
|
pass
|
|
1399
|
-
# name:
|
|
1414
|
+
# name: NonEmptyStrippedStr64 | None = None — description "User display name" is inherited
|
|
1400
1415
|
# Shows up correctly in OpenAPI/Swagger docs
|
|
1401
1416
|
```
|
|
1402
1417
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
sqlmodel_ext/__init__.py,sha256=
|
|
1
|
+
sqlmodel_ext/__init__.py,sha256=xG-l39gVbrxx3WH7tH1MTKrQKBnpQ10_bd5QOB0NTFI,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=0m6XkHsZBqLr9d2kpCMStZSDpHxua-UiBtRiL8R4dDk,72263
|
|
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
|
|
@@ -15,7 +15,7 @@ sqlmodel_ext/field_types/_internal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQe
|
|
|
15
15
|
sqlmodel_ext/field_types/_internal/path.py,sha256=bJhrGnidXbEFQyJBoMIKHo2p-a2T805BV4mMSZ8vmDE,2070
|
|
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.1.dist-info/METADATA,sha256=XI34AHmMUOEQG8ib-MYblozJrvyqNHRJRD4bhvYSu0I,53102
|
|
32
|
+
sqlmodel_ext-0.4.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
33
|
+
sqlmodel_ext-0.4.1.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
|
|
34
|
+
sqlmodel_ext-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|