sqlmodel-ext 0.3.2__py3-none-any.whl → 0.3.3__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/_compat.py +13 -3
- sqlmodel_ext/base.py +123 -7
- sqlmodel_ext/field_types/__init__.py +23 -0
- sqlmodel_ext/mixins/cached_table.py +71 -36
- sqlmodel_ext/mixins/table.py +182 -33
- sqlmodel_ext/pagination.py +2 -2
- sqlmodel_ext/relation_load_checker.py +97 -9
- {sqlmodel_ext-0.3.2.dist-info → sqlmodel_ext-0.3.3.dist-info}/METADATA +1 -1
- {sqlmodel_ext-0.3.2.dist-info → sqlmodel_ext-0.3.3.dist-info}/RECORD +12 -12
- {sqlmodel_ext-0.3.2.dist-info → sqlmodel_ext-0.3.3.dist-info}/WHEEL +0 -0
- {sqlmodel_ext-0.3.2.dist-info → sqlmodel_ext-0.3.3.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py
CHANGED
sqlmodel_ext/_compat.py
CHANGED
|
@@ -67,10 +67,15 @@ if sys.version_info >= (3, 14):
|
|
|
67
67
|
# Check field.metadata (Pydantic-processed Annotated types)
|
|
68
68
|
metadata = getattr(field, 'metadata', None)
|
|
69
69
|
if metadata:
|
|
70
|
+
# Pass field.annotation as source_type so handlers that need the element type
|
|
71
|
+
# (e.g. _PendingArrayHandler extracting T from list[T] to compute ARRAY(T))
|
|
72
|
+
# can resolve. handler=None is fine because the sa_type extraction path only
|
|
73
|
+
# reads metadata['sa_type'] and never invokes the schema itself.
|
|
74
|
+
field_annotation = getattr(field, 'annotation', None)
|
|
70
75
|
for metadata_item in metadata:
|
|
71
76
|
if hasattr(metadata_item, '__get_pydantic_core_schema__'):
|
|
72
77
|
try:
|
|
73
|
-
schema = metadata_item.__get_pydantic_core_schema__(
|
|
78
|
+
schema = metadata_item.__get_pydantic_core_schema__(field_annotation, None)
|
|
74
79
|
if isinstance(schema, dict) and 'metadata' in schema:
|
|
75
80
|
sa_type = schema['metadata'].get('sa_type')
|
|
76
81
|
if sa_type is not None:
|
|
@@ -105,7 +110,9 @@ if sys.version_info >= (3, 14):
|
|
|
105
110
|
for md in inner_args[1:]:
|
|
106
111
|
if hasattr(md, '__get_pydantic_core_schema__'):
|
|
107
112
|
try:
|
|
108
|
-
|
|
113
|
+
# Pass first_arg (the full Annotated alias) as source_type so
|
|
114
|
+
# handlers like _PendingArrayHandler can extract the element type.
|
|
115
|
+
schema = md.__get_pydantic_core_schema__(first_arg, None)
|
|
109
116
|
if isinstance(schema, dict) and 'metadata' in schema:
|
|
110
117
|
sa_type = schema['metadata'].get('sa_type')
|
|
111
118
|
if sa_type is not None:
|
|
@@ -153,7 +160,10 @@ if sys.version_info >= (3, 14):
|
|
|
153
160
|
for md in args[1:]:
|
|
154
161
|
if hasattr(md, '__get_pydantic_core_schema__'):
|
|
155
162
|
try:
|
|
156
|
-
|
|
163
|
+
# Pass annotation (the full Annotated form) as source_type so
|
|
164
|
+
# handlers needing the element type (_PendingArrayHandler etc.)
|
|
165
|
+
# can extract T from list[T].
|
|
166
|
+
schema = md.__get_pydantic_core_schema__(annotation, None)
|
|
157
167
|
if isinstance(schema, dict) and 'metadata' in schema:
|
|
158
168
|
sa_type = schema['metadata'].get('sa_type')
|
|
159
169
|
if sa_type is not None:
|
sqlmodel_ext/base.py
CHANGED
|
@@ -15,7 +15,7 @@ import re
|
|
|
15
15
|
import sys
|
|
16
16
|
import types
|
|
17
17
|
import typing
|
|
18
|
-
from typing import Any, get_args, get_origin
|
|
18
|
+
from typing import Any, Self, Sequence, get_args, get_origin
|
|
19
19
|
|
|
20
20
|
from pydantic import ConfigDict, model_validator
|
|
21
21
|
from pydantic.fields import FieldInfo
|
|
@@ -102,6 +102,47 @@ def _merge_field_info_attrs(target: SQLModelFieldInfo, source: SQLModelFieldInfo
|
|
|
102
102
|
target.metadata = list(target_meta) + list(source_meta)
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def _find_field_info_in_annotated(annotation: Any) -> SQLModelFieldInfo | None:
|
|
106
|
+
"""
|
|
107
|
+
Extract SQLModel ``FieldInfo`` embedded in ``Annotated[T, Field(...)]`` metadata.
|
|
108
|
+
|
|
109
|
+
Used by the metaclass sa_type injection loop. When a field is declared
|
|
110
|
+
``Annotated[X, Field(default_factory=list, ...)]`` *without* an explicit
|
|
111
|
+
``= ...`` assignment, ``attrs[field_name]`` is ``Undefined``. Replacing
|
|
112
|
+
it with a fresh ``Field(sa_type=sa_type)`` would discard the user's
|
|
113
|
+
Field metadata (``default_factory``, ``max_length``, validators, ...)
|
|
114
|
+
that lives inside the Annotated args. This helper recovers that
|
|
115
|
+
FieldInfo so the caller can attach ``sa_type`` to the user's Field
|
|
116
|
+
instead of clobbering it. The bug only surfaces after 2+ levels of
|
|
117
|
+
inheritance: single-class instantiation goes through Pydantic's native
|
|
118
|
+
Annotated path and works, but child classes rebuild ``model_fields``
|
|
119
|
+
from the clobbered ``attrs`` and the field becomes silently
|
|
120
|
+
``is_required=True``.
|
|
121
|
+
|
|
122
|
+
Multiple FieldInfo args (e.g. ``Annotated[Str64, Field(unique=True)]``
|
|
123
|
+
expands to ``Annotated[str, Field(max_length=64), Field(unique=True)]``)
|
|
124
|
+
are merged into a single shallow copy so the shared Annotated metadata
|
|
125
|
+
singletons are never mutated.
|
|
126
|
+
|
|
127
|
+
:param annotation: Field type annotation
|
|
128
|
+
:returns: Merged SQLModelFieldInfo (shallow copy, safe to mutate), or None
|
|
129
|
+
"""
|
|
130
|
+
if get_origin(annotation) is not typing.Annotated:
|
|
131
|
+
return None
|
|
132
|
+
args = get_args(annotation)
|
|
133
|
+
if len(args) < 2:
|
|
134
|
+
return None
|
|
135
|
+
sqlmodel_fis: list[SQLModelFieldInfo] = [
|
|
136
|
+
arg for arg in args[1:] if isinstance(arg, SQLModelFieldInfo)
|
|
137
|
+
]
|
|
138
|
+
if not sqlmodel_fis:
|
|
139
|
+
return None
|
|
140
|
+
merged = copy.copy(sqlmodel_fis[0])
|
|
141
|
+
for extra_fi in sqlmodel_fis[1:]:
|
|
142
|
+
_merge_field_info_attrs(merged, extra_fi)
|
|
143
|
+
return merged
|
|
144
|
+
|
|
145
|
+
|
|
105
146
|
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
106
147
|
"""
|
|
107
148
|
Convert type annotation to optional: ``T → T | None``
|
|
@@ -179,6 +220,27 @@ def _apply_all_fields_optional(
|
|
|
179
220
|
if original_ann is None:
|
|
180
221
|
continue
|
|
181
222
|
|
|
223
|
+
# Bug fix: when a field is declared as ``field: T = Field(gt=..., le=...)`` (non-Annotated form),
|
|
224
|
+
# MRO ``__annotations__`` only stores the bare ``T``. The ``Field(...)`` constraint metadata
|
|
225
|
+
# lives on the right-hand-side assignment, so all_fields_optional-derived UpdateRequest classes
|
|
226
|
+
# lose those constraints (e.g. UFT-style "missing range" warnings fire on derived fields that
|
|
227
|
+
# had constraints in the source class). Patch: when ``original_ann`` is not already Annotated,
|
|
228
|
+
# pull constraints from the base class's resolved ``model_fields[name].metadata`` (Pydantic
|
|
229
|
+
# stores them there as ``[Gt(0), Le(600), ...]``) and re-wrap into ``Annotated[T, *metadata]``,
|
|
230
|
+
# letting downstream ``_make_annotation_optional`` preserve the constraints when converting
|
|
231
|
+
# to ``T | None``. The root recommended fix is still to declare fields in Annotated form at
|
|
232
|
+
# the source; this patch is defense-in-depth for legacy ``field: T = Field(...)`` declarations.
|
|
233
|
+
if get_origin(original_ann) is not typing.Annotated:
|
|
234
|
+
for base in bases:
|
|
235
|
+
base_model_fields = getattr(base, 'model_fields', None)
|
|
236
|
+
if not base_model_fields:
|
|
237
|
+
continue
|
|
238
|
+
base_field_info = base_model_fields.get(field_name)
|
|
239
|
+
if base_field_info is None or not base_field_info.metadata:
|
|
240
|
+
continue
|
|
241
|
+
original_ann = typing.Annotated[original_ann, *base_field_info.metadata]
|
|
242
|
+
break
|
|
243
|
+
|
|
182
244
|
# Skip Literal type fields (e.g. discriminator):
|
|
183
245
|
# Literal['text'] | None breaks Pydantic discriminated union
|
|
184
246
|
raw_type = original_ann
|
|
@@ -268,10 +330,33 @@ def _recover_annotated_sqlmodel_fields(
|
|
|
268
330
|
all_annotated.update(annotations)
|
|
269
331
|
|
|
270
332
|
for field_name, field_type in all_annotated.items():
|
|
271
|
-
|
|
333
|
+
# Unwrap Optional/Union wrappers: forms like ``Annotated[X, Field(sa_type=BigInteger)] | None``
|
|
334
|
+
# have ``get_origin() == Union`` (or ``types.UnionType`` for PEP 604 ``|``); we must
|
|
335
|
+
# peel back to find the inner Annotated to extract the SQLModel FieldInfo. Otherwise
|
|
336
|
+
# nullable aliases like ``PositiveBigInt | None`` lose ``sa_type`` and the SA column
|
|
337
|
+
# silently degrades (e.g. BigInteger → Integer → asyncpg int32 overflow on large defaults).
|
|
338
|
+
annotated_type = field_type
|
|
339
|
+
union_args: list[typing.Any] | None = None
|
|
340
|
+
union_origin = get_origin(field_type)
|
|
341
|
+
# ``X | None`` (PEP 604) has origin ``types.UnionType``; ``Union[X, None]`` is ``typing.Union``.
|
|
342
|
+
# Match both without depending on ``types`` import inside any version block.
|
|
343
|
+
is_union = union_origin is typing.Union or (
|
|
344
|
+
union_origin is not None and getattr(union_origin, '__name__', '') == 'UnionType'
|
|
345
|
+
)
|
|
346
|
+
if is_union:
|
|
347
|
+
union_args = list(get_args(field_type))
|
|
348
|
+
annotated_in_union = next(
|
|
349
|
+
(arg for arg in union_args if get_origin(arg) is typing.Annotated),
|
|
350
|
+
None,
|
|
351
|
+
)
|
|
352
|
+
if annotated_in_union is None:
|
|
353
|
+
continue
|
|
354
|
+
annotated_type = annotated_in_union
|
|
355
|
+
|
|
356
|
+
if get_origin(annotated_type) is not typing.Annotated:
|
|
272
357
|
continue
|
|
273
358
|
|
|
274
|
-
args = get_args(
|
|
359
|
+
args = get_args(annotated_type)
|
|
275
360
|
if len(args) < 2:
|
|
276
361
|
continue
|
|
277
362
|
|
|
@@ -313,10 +398,24 @@ def _recover_annotated_sqlmodel_fields(
|
|
|
313
398
|
base_type = args[0]
|
|
314
399
|
remaining_metadata = [a for a in args[1:] if not isinstance(a, SQLModelFieldInfo)]
|
|
315
400
|
if remaining_metadata:
|
|
316
|
-
|
|
401
|
+
new_inner: typing.Any = typing.Annotated[tuple([base_type] + remaining_metadata)]
|
|
317
402
|
else:
|
|
318
|
-
|
|
319
|
-
|
|
403
|
+
new_inner = base_type
|
|
404
|
+
|
|
405
|
+
if union_args is not None:
|
|
406
|
+
# Rebuild Union: replace the inner Annotated with the stripped version, keeping
|
|
407
|
+
# the other Union members (e.g. ``| None``) intact. Use PEP 604 ``|`` to accumulate
|
|
408
|
+
# so we don't trip the deprecated-typing.Union warning.
|
|
409
|
+
new_union_args = [
|
|
410
|
+
new_inner if get_origin(arg) is typing.Annotated else arg
|
|
411
|
+
for arg in union_args
|
|
412
|
+
]
|
|
413
|
+
rebuilt: typing.Any = new_union_args[0]
|
|
414
|
+
for extra in new_union_args[1:]:
|
|
415
|
+
rebuilt = rebuilt | extra
|
|
416
|
+
annotations[field_name] = rebuilt
|
|
417
|
+
else:
|
|
418
|
+
annotations[field_name] = new_inner
|
|
320
419
|
|
|
321
420
|
|
|
322
421
|
def _make_sti_fk_resolver(
|
|
@@ -495,7 +594,19 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
495
594
|
field_value = attrs.get(field_name, Undefined)
|
|
496
595
|
|
|
497
596
|
if field_value is Undefined:
|
|
498
|
-
|
|
597
|
+
# No explicit ``= Field(...)`` assignment. Prefer recovering
|
|
598
|
+
# FieldInfo from inside Annotated[X, Field(default_factory=..., ...)]
|
|
599
|
+
# so user-supplied default_factory / max_length / constraints
|
|
600
|
+
# survive — clobbering with a fresh Field(sa_type=sa_type)
|
|
601
|
+
# would silently make the field required after multi-level
|
|
602
|
+
# inheritance.
|
|
603
|
+
annotated_fi = _find_field_info_in_annotated(field_type)
|
|
604
|
+
if annotated_fi is not None:
|
|
605
|
+
if getattr(annotated_fi, 'sa_type', Undefined) is Undefined:
|
|
606
|
+
setattr(annotated_fi, 'sa_type', sa_type)
|
|
607
|
+
attrs[field_name] = annotated_fi
|
|
608
|
+
else:
|
|
609
|
+
attrs[field_name] = Field(sa_type=sa_type)
|
|
499
610
|
elif isinstance(field_value, FieldInfo):
|
|
500
611
|
if getattr(field_value, 'sa_type', Undefined) is Undefined:
|
|
501
612
|
setattr(field_value, 'sa_type', sa_type)
|
|
@@ -975,6 +1086,11 @@ class SQLModelBase(SQLModel, metaclass=__DeclarativeMeta):
|
|
|
975
1086
|
prop['description'] = finfo.description
|
|
976
1087
|
return json_schema
|
|
977
1088
|
|
|
1089
|
+
@classmethod
|
|
1090
|
+
def validate_list(cls, items: Sequence[Any]) -> list[Self]:
|
|
1091
|
+
"""Batch-convert a sequence of ORM instances (or dicts) to this model type."""
|
|
1092
|
+
return [cls.model_validate(item, from_attributes=True) for item in items]
|
|
1093
|
+
|
|
978
1094
|
@classmethod
|
|
979
1095
|
def get_computed_field_names(cls) -> set[str]:
|
|
980
1096
|
"""Get the set of computed_field names for this model class."""
|
|
@@ -108,6 +108,9 @@ Text5K: TypeAlias = Annotated[str, Field(max_length=5000), _NO_NULL_BYTE]
|
|
|
108
108
|
Text10K: TypeAlias = Annotated[str, Field(max_length=10000), _NO_NULL_BYTE]
|
|
109
109
|
"""10000-character text field"""
|
|
110
110
|
|
|
111
|
+
Text16K: TypeAlias = Annotated[str, Field(max_length=16000), _NO_NULL_BYTE]
|
|
112
|
+
"""16000-character text field (long-form descriptions, tool docs)"""
|
|
113
|
+
|
|
111
114
|
Text32K: TypeAlias = Annotated[str, Field(max_length=32000), _NO_NULL_BYTE]
|
|
112
115
|
"""32000-character text field"""
|
|
113
116
|
|
|
@@ -120,9 +123,29 @@ Text64K: TypeAlias = Annotated[str, Field(max_length=65536), _NO_NULL_BYTE]
|
|
|
120
123
|
Text100K: TypeAlias = Annotated[str, Field(max_length=100000), _NO_NULL_BYTE]
|
|
121
124
|
"""100000-character text field"""
|
|
122
125
|
|
|
126
|
+
Text128K: TypeAlias = Annotated[str, Field(max_length=131072), _NO_NULL_BYTE]
|
|
127
|
+
"""131072-character (128 * 1024) text field (large markdown documents etc.)"""
|
|
128
|
+
|
|
123
129
|
Text1M: TypeAlias = Annotated[str, Field(max_length=1000000), _NO_NULL_BYTE]
|
|
124
130
|
"""1000000-character text field (tool call parameters, tool responses, etc.)"""
|
|
125
131
|
|
|
132
|
+
# NonEmptyStr* — same as Str* but also requires ``min_length=1``, rejecting empty
|
|
133
|
+
# ``""`` strings with a 422 ValidationError. Use for naming fields where the empty
|
|
134
|
+
# string is semantically invalid (e.g. UserFolder.name).
|
|
135
|
+
NonEmptyStr64: TypeAlias = Annotated[str, Field(min_length=1, max_length=64), _NO_NULL_BYTE]
|
|
136
|
+
"""1-64 character non-empty string field"""
|
|
137
|
+
|
|
138
|
+
NonEmptyStr128: TypeAlias = Annotated[str, Field(min_length=1, max_length=128), _NO_NULL_BYTE]
|
|
139
|
+
"""1-128 character non-empty string field"""
|
|
140
|
+
|
|
141
|
+
NonEmptyStr256: TypeAlias = Annotated[str, Field(min_length=1, max_length=256), _NO_NULL_BYTE]
|
|
142
|
+
"""1-256 character non-empty string field"""
|
|
143
|
+
|
|
144
|
+
# Sha256Hex — exactly 64 lowercase hex characters, the canonical SHA-256 hex digest form.
|
|
145
|
+
# The strict regex also implicitly rejects NUL bytes, so no separate _NO_NULL_BYTE is needed.
|
|
146
|
+
Sha256Hex: TypeAlias = Annotated[str, StringConstraints(min_length=64, max_length=64, pattern=r'^[0-9a-f]{64}$')]
|
|
147
|
+
"""64-char lowercase-hex SHA-256 digest (e.g. content hashes)"""
|
|
148
|
+
|
|
126
149
|
# Numeric range constraints
|
|
127
150
|
Port: TypeAlias = Annotated[int, Field(ge=1, le=65535)]
|
|
128
151
|
"""Port number (1-65535)"""
|
|
@@ -91,6 +91,7 @@ logger = logging.getLogger(__name__)
|
|
|
91
91
|
|
|
92
92
|
from pydantic import ValidationError
|
|
93
93
|
from sqlalchemy import ColumnElement, event, inspect as sa_inspect, select as sa_select
|
|
94
|
+
from sqlalchemy.exc import NoResultFound
|
|
94
95
|
from sqlalchemy.orm import InstanceState, QueryableAttribute, Session as _SyncSession, make_transient_to_detached
|
|
95
96
|
from sqlalchemy.orm.attributes import set_committed_value
|
|
96
97
|
from sqlalchemy.orm.relationships import MANYTOONE # pyright: ignore[reportPrivateImportUsage]
|
|
@@ -1319,6 +1320,15 @@ class CachedTableBaseMixin(TableBaseMixin):
|
|
|
1319
1320
|
if cached is not _LOAD_CACHE_MISS:
|
|
1320
1321
|
if cls.on_cache_hit is not None:
|
|
1321
1322
|
cls.on_cache_hit(cls.__name__)
|
|
1323
|
+
if cached is None and fetch_mode == 'one':
|
|
1324
|
+
# ``fetch_mode='one'`` type contract guarantees raise (not None);
|
|
1325
|
+
# when the cache hit is None we must raise, matching the SQL
|
|
1326
|
+
# path's ``result.one()`` behavior. Otherwise downstream
|
|
1327
|
+
# ``try/except NoResultFound`` callers (e.g. dep injection that
|
|
1328
|
+
# maps "missing" to 503) would silently see None and fail later.
|
|
1329
|
+
raise NoResultFound(
|
|
1330
|
+
f"No row was found when one was required: {cls.__name__}(id={id_value})"
|
|
1331
|
+
)
|
|
1322
1332
|
return cached
|
|
1323
1333
|
# Redis was actually queried and missed.
|
|
1324
1334
|
|
|
@@ -1394,18 +1404,25 @@ class CachedTableBaseMixin(TableBaseMixin):
|
|
|
1394
1404
|
else:
|
|
1395
1405
|
# Phase 2: merge into the session identity map to preserve
|
|
1396
1406
|
# the same semantics as a DB query.
|
|
1397
|
-
if result is not None:
|
|
1398
|
-
if isinstance(result, list):
|
|
1399
|
-
merged_list: list[Self] = []
|
|
1400
|
-
for item in result:
|
|
1401
|
-
make_transient_to_detached(item)
|
|
1402
|
-
merged_list.append(await session.merge(item, load=False))
|
|
1403
|
-
return merged_list
|
|
1404
|
-
else:
|
|
1405
|
-
make_transient_to_detached(result)
|
|
1406
|
-
result = await session.merge(result, load=False)
|
|
1407
1407
|
if cls.on_cache_hit is not None:
|
|
1408
1408
|
cls.on_cache_hit(cls.__name__)
|
|
1409
|
+
if result is None:
|
|
1410
|
+
if fetch_mode == 'one':
|
|
1411
|
+
# ``fetch_mode='one'`` type contract guarantees raise (not None);
|
|
1412
|
+
# raise on cache hit to mirror the SQL path's ``result.one()`` behavior.
|
|
1413
|
+
raise NoResultFound(
|
|
1414
|
+
f"No row was found when one was required: {cls.__name__}"
|
|
1415
|
+
)
|
|
1416
|
+
return result
|
|
1417
|
+
if isinstance(result, list):
|
|
1418
|
+
merged_list: list[Self] = []
|
|
1419
|
+
for item in result:
|
|
1420
|
+
make_transient_to_detached(item)
|
|
1421
|
+
merged_list.append(await session.merge(item, load=False))
|
|
1422
|
+
return merged_list
|
|
1423
|
+
else:
|
|
1424
|
+
make_transient_to_detached(result)
|
|
1425
|
+
result = await session.merge(result, load=False)
|
|
1409
1426
|
return result
|
|
1410
1427
|
|
|
1411
1428
|
# Cache miss callback (only when Redis was actually queried, not skip_cache path)
|
|
@@ -1709,35 +1726,53 @@ class CachedTableBaseMixin(TableBaseMixin):
|
|
|
1709
1726
|
# SA does not load children and persistent_to_deleted does not fire,
|
|
1710
1727
|
# so we must query child IDs ourselves before DELETE and invalidate
|
|
1711
1728
|
# their caches afterwards.
|
|
1729
|
+
#
|
|
1730
|
+
# The walk is BFS over the entire passive_deletes chain — a single
|
|
1731
|
+
# level is not enough. Example: A -[passive=True]-> B -[passive=True]-> C
|
|
1732
|
+
# all use DB CASCADE, SA never loads B or C, persistent_to_deleted
|
|
1733
|
+
# only fires for A. Stale rows of both B and C linger in Redis until
|
|
1734
|
+
# TTL. The BFS prefetches every cached level before issuing DELETE.
|
|
1712
1735
|
passive_targets: dict[type[CachedTableBaseMixin], list[Any]] = {}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1736
|
+
# Queue items: (current_cls, IDs from the previous level, or None for
|
|
1737
|
+
# full-model propagation when the upstream delete used a condition).
|
|
1738
|
+
passive_queue: list[tuple[type, list[Any] | None]] = [
|
|
1739
|
+
(cls, list(instance_ids) if instance_ids else None)
|
|
1740
|
+
]
|
|
1741
|
+
while passive_queue:
|
|
1742
|
+
current_cls, current_ids = passive_queue.pop(0)
|
|
1743
|
+
try:
|
|
1744
|
+
current_mapper = sa_inspect(current_cls)
|
|
1745
|
+
if current_mapper is None:
|
|
1722
1746
|
continue
|
|
1723
|
-
|
|
1747
|
+
for rel in current_mapper.relationships:
|
|
1748
|
+
if not rel.cascade.delete or not rel.passive_deletes:
|
|
1749
|
+
continue
|
|
1750
|
+
target = rel.mapper.class_
|
|
1751
|
+
if not (isinstance(target, type) and issubclass(target, CachedTableBaseMixin)):
|
|
1752
|
+
continue
|
|
1753
|
+
if current_ids is None:
|
|
1754
|
+
# Condition delete or already-full propagation.
|
|
1755
|
+
if _FULL_MODEL_INVALIDATION not in passive_targets.get(target, []):
|
|
1756
|
+
passive_targets.setdefault(target, []).append(_FULL_MODEL_INVALIDATION)
|
|
1757
|
+
passive_queue.append((target, None))
|
|
1758
|
+
continue
|
|
1724
1759
|
remote_cols = list(rel.remote_side)
|
|
1725
|
-
if len(remote_cols)
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1760
|
+
if len(remote_cols) != 1:
|
|
1761
|
+
# Composite FK is not supported (matches the original
|
|
1762
|
+
# single-level behaviour).
|
|
1763
|
+
continue
|
|
1764
|
+
target_mapper = sa_inspect(target)
|
|
1765
|
+
if target_mapper is None:
|
|
1766
|
+
continue
|
|
1767
|
+
target_pk = target_mapper.primary_key[0]
|
|
1768
|
+
stmt = sa_select(target_pk).where(remote_cols[0].in_(current_ids))
|
|
1769
|
+
rows = await session.execute(stmt)
|
|
1770
|
+
child_ids = [row[0] for row in rows]
|
|
1771
|
+
if child_ids:
|
|
1772
|
+
passive_targets.setdefault(target, []).extend(child_ids)
|
|
1773
|
+
passive_queue.append((target, child_ids))
|
|
1774
|
+
except Exception as e:
|
|
1775
|
+
logger.warning(f"passive_deletes pre-query failed ({current_cls.__name__}): {e}")
|
|
1741
1776
|
|
|
1742
1777
|
# Clear any leftover cascade data from a previous run (defensive;
|
|
1743
1778
|
# normal flows never leave residue).
|
sqlmodel_ext/mixins/table.py
CHANGED
|
@@ -141,34 +141,170 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
141
141
|
default_factory=now
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
+
# ==================== IntegrityError friendly-message registry ====================
|
|
145
|
+
#
|
|
146
|
+
# Each application module declares its own constraint-name → user-facing message
|
|
147
|
+
# at the same site as the ``UniqueConstraint`` / ``ForeignKey`` / ``CheckConstraint``
|
|
148
|
+
# declaration, by calling ``TableBaseMixin.register_*_violation_message(...)``.
|
|
149
|
+
# Both ``sanitize_integrity_error`` and any global FastAPI integrity-error handler
|
|
150
|
+
# look up the registry; on hit they return the registered message, otherwise they
|
|
151
|
+
# fall through to a generic fallback. No code path leaks table/column names or SQL.
|
|
152
|
+
#
|
|
153
|
+
# Design:
|
|
154
|
+
# - The registry hangs off ``TableBaseMixin`` (the root mixin for all table models),
|
|
155
|
+
# so it is shared across subclasses.
|
|
156
|
+
# - ``setdefault`` semantics (first registration wins) protect against duplicate
|
|
157
|
+
# registration from re-imports.
|
|
158
|
+
# - The ``CheckConstraint`` registry only serves ORM-declared CHECKs. Trigger
|
|
159
|
+
# ``RAISE EXCEPTION`` errors typically don't carry ``constraint_name``; those
|
|
160
|
+
# are surfaced via ``extract_trigger_message`` instead.
|
|
161
|
+
|
|
162
|
+
_UNIQUE_VIOLATION_MESSAGES: ClassVar[dict[str, str]] = {}
|
|
163
|
+
"""UNIQUE constraint name -> user-facing message"""
|
|
164
|
+
|
|
165
|
+
_FOREIGN_KEY_VIOLATION_MESSAGES: ClassVar[dict[str, str]] = {}
|
|
166
|
+
"""FK constraint name -> user-facing message"""
|
|
167
|
+
|
|
168
|
+
_CHECK_VIOLATION_MESSAGES: ClassVar[dict[str, str]] = {}
|
|
169
|
+
"""Declared CHECK constraint name -> user-facing message (trigger-raised check_violation does not go through here)"""
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def register_unique_violation_message(constraint_name: str, friendly_message: str) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Declare the user-facing message shown when a UNIQUE constraint is violated.
|
|
175
|
+
|
|
176
|
+
Call this at module top-level next to the matching ``UniqueConstraint(..., name='xxx')``
|
|
177
|
+
declaration. SQLModel modules are import-eager (loaded during FastAPI startup),
|
|
178
|
+
so registration completes before any request runs.
|
|
179
|
+
|
|
180
|
+
:param constraint_name: Constraint name, must exactly match the ``name=`` kwarg
|
|
181
|
+
of the ``UniqueConstraint`` declaration.
|
|
182
|
+
:param friendly_message: Message returned to the user when the constraint fires.
|
|
183
|
+
Should not contain table/column names.
|
|
184
|
+
"""
|
|
185
|
+
_ = TableBaseMixin._UNIQUE_VIOLATION_MESSAGES.setdefault(constraint_name, friendly_message)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def register_foreign_key_violation_message(constraint_name: str, friendly_message: str) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Declare the user-facing message shown when a FK constraint is violated
|
|
191
|
+
(typically for "referenced resource missing" semantics).
|
|
192
|
+
|
|
193
|
+
:param constraint_name: FK constraint name (PostgreSQL default is ``<table>_<col>_fkey``)
|
|
194
|
+
:param friendly_message: User-facing message
|
|
195
|
+
"""
|
|
196
|
+
_ = TableBaseMixin._FOREIGN_KEY_VIOLATION_MESSAGES.setdefault(constraint_name, friendly_message)
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def register_check_violation_message(constraint_name: str, friendly_message: str) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Declare the user-facing message shown when a declared ``CheckConstraint`` is violated.
|
|
202
|
+
|
|
203
|
+
Only applies to ORM-level ``CheckConstraint(..., name='ck_xxx')``. Database trigger
|
|
204
|
+
``RAISE EXCEPTION`` that produces a check_violation typically lacks a
|
|
205
|
+
``constraint_name``; those are surfaced by ``extract_trigger_message`` directly
|
|
206
|
+
and bypass this registry.
|
|
207
|
+
|
|
208
|
+
:param constraint_name: CHECK constraint name
|
|
209
|
+
:param friendly_message: User-facing message
|
|
210
|
+
"""
|
|
211
|
+
_ = TableBaseMixin._CHECK_VIOLATION_MESSAGES.setdefault(constraint_name, friendly_message)
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def lookup_unique_violation_message(constraint_name: str | None) -> str | None:
|
|
215
|
+
"""Look up the friendly message for a UNIQUE constraint; returns None if missing/unregistered."""
|
|
216
|
+
if not constraint_name:
|
|
217
|
+
return None
|
|
218
|
+
return TableBaseMixin._UNIQUE_VIOLATION_MESSAGES.get(constraint_name)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def lookup_foreign_key_violation_message(constraint_name: str | None) -> str | None:
|
|
222
|
+
"""Look up the friendly message for a FK constraint; returns None if missing/unregistered."""
|
|
223
|
+
if not constraint_name:
|
|
224
|
+
return None
|
|
225
|
+
return TableBaseMixin._FOREIGN_KEY_VIOLATION_MESSAGES.get(constraint_name)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def lookup_check_violation_message(constraint_name: str | None) -> str | None:
|
|
229
|
+
"""Look up the friendly message for a declared CHECK constraint; returns None if missing/unregistered."""
|
|
230
|
+
if not constraint_name:
|
|
231
|
+
return None
|
|
232
|
+
return TableBaseMixin._CHECK_VIOLATION_MESSAGES.get(constraint_name)
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def extract_trigger_message(orig: BaseException) -> str:
|
|
236
|
+
"""
|
|
237
|
+
Extract the first-line business message from an asyncpg ``CheckViolationError``
|
|
238
|
+
raised by a trigger ``RAISE EXCEPTION`` (strips ``ERROR:`` prefix and ``DETAIL:`` /
|
|
239
|
+
``CONTEXT:`` trailing lines).
|
|
240
|
+
|
|
241
|
+
Shared between ``sanitize_integrity_error`` and any global integrity-error
|
|
242
|
+
handler (hence exposed). Other callers should not consume raw trigger
|
|
243
|
+
messages directly.
|
|
244
|
+
"""
|
|
245
|
+
message = str(orig)
|
|
246
|
+
if '\n' in message:
|
|
247
|
+
message = message.split('\n')[0]
|
|
248
|
+
if message.startswith('ERROR:'):
|
|
249
|
+
message = message[6:].strip()
|
|
250
|
+
return message
|
|
251
|
+
|
|
144
252
|
@staticmethod
|
|
145
253
|
def sanitize_integrity_error(e: IntegrityError, default_message: str = "Data integrity constraint violation") -> str:
|
|
146
254
|
"""
|
|
147
255
|
Extract a safe, user-friendly error message from an IntegrityError.
|
|
148
256
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
257
|
+
Priority:
|
|
258
|
+
|
|
259
|
+
1. ``UniqueViolationError`` (SQLSTATE 23505) / ``ForeignKeyViolationError``
|
|
260
|
+
(23503) / declared ``CheckConstraint`` (23514 *with* ``constraint_name``):
|
|
261
|
+
look up the registry, return the registered message on hit, else
|
|
262
|
+
``default_message``.
|
|
263
|
+
2. Trigger ``RAISE EXCEPTION`` check_violation (23514 *without* ``constraint_name``):
|
|
264
|
+
the message itself is a developer-authored user-facing string; surface
|
|
265
|
+
it directly via ``extract_trigger_message``.
|
|
266
|
+
3. Fallback: log the raw error and return ``default_message``.
|
|
153
267
|
|
|
154
|
-
Note:
|
|
155
|
-
default_message will be returned for non-trigger constraint errors.
|
|
268
|
+
Note: SQLSTATE values are PostgreSQL-specific. For other databases, only
|
|
269
|
+
``default_message`` will be returned for non-trigger constraint errors.
|
|
156
270
|
|
|
157
|
-
:param e: SQLAlchemy IntegrityError
|
|
158
|
-
:param default_message: Fallback message
|
|
271
|
+
:param e: SQLAlchemy ``IntegrityError``
|
|
272
|
+
:param default_message: Fallback message when registry misses and the error
|
|
273
|
+
is not a trigger-raised check_violation
|
|
159
274
|
:returns: A user-safe error description
|
|
160
275
|
"""
|
|
161
276
|
orig = e.orig
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
277
|
+
if orig is None:
|
|
278
|
+
logger.warning(f"Data integrity constraint error (no orig): {e}")
|
|
279
|
+
return default_message
|
|
280
|
+
|
|
281
|
+
sqlstate = getattr(orig, 'sqlstate', None)
|
|
282
|
+
constraint = getattr(orig, 'constraint_name', None)
|
|
283
|
+
|
|
284
|
+
if sqlstate == '23505': # UniqueViolation
|
|
285
|
+
friendly = TableBaseMixin.lookup_unique_violation_message(constraint)
|
|
286
|
+
if friendly is not None:
|
|
287
|
+
return friendly
|
|
288
|
+
elif sqlstate == '23503': # ForeignKeyViolation
|
|
289
|
+
friendly = TableBaseMixin.lookup_foreign_key_violation_message(constraint)
|
|
290
|
+
if friendly is not None:
|
|
291
|
+
return friendly
|
|
292
|
+
elif sqlstate == '23514': # CheckViolation
|
|
293
|
+
if constraint:
|
|
294
|
+
# Declared CheckConstraint: only return a registered friendly message;
|
|
295
|
+
# do not surface the raw message (CheckConstraint expressions may
|
|
296
|
+
# contain column names).
|
|
297
|
+
friendly = TableBaseMixin.lookup_check_violation_message(constraint)
|
|
298
|
+
if friendly is not None:
|
|
299
|
+
return friendly
|
|
300
|
+
else:
|
|
301
|
+
# Trigger RAISE EXCEPTION: the message is already a user-facing string,
|
|
302
|
+
# surface it directly.
|
|
303
|
+
trigger_msg = TableBaseMixin.extract_trigger_message(orig)
|
|
304
|
+
if trigger_msg:
|
|
305
|
+
return trigger_msg
|
|
306
|
+
|
|
307
|
+
logger.warning(f"Data integrity constraint error: constraint={constraint}, sqlstate={sqlstate}, orig={e}")
|
|
172
308
|
return default_message
|
|
173
309
|
|
|
174
310
|
@classmethod
|
|
@@ -204,16 +340,20 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
204
340
|
# After commit objects expire; use sa_inspect to safely read id
|
|
205
341
|
_insp = cast(InstanceState[Any], inspect(instance))
|
|
206
342
|
_inst_id = _insp.identity[0] if _insp.identity else None
|
|
207
|
-
|
|
343
|
+
if _inst_id is None:
|
|
344
|
+
raise RuntimeError(f"{cls.__name__} id is None after add")
|
|
208
345
|
result = await cls.get(session, cls.id == _inst_id)
|
|
209
|
-
|
|
346
|
+
if result is None:
|
|
347
|
+
raise RuntimeError(f"{cls.__name__} record not found (id={_inst_id})")
|
|
210
348
|
instances[i] = result
|
|
211
349
|
else:
|
|
212
350
|
_insp = cast(InstanceState[Any], inspect(instances))
|
|
213
351
|
_inst_id = _insp.identity[0] if _insp.identity else None
|
|
214
|
-
|
|
352
|
+
if _inst_id is None:
|
|
353
|
+
raise RuntimeError(f"{cls.__name__} id is None after add")
|
|
215
354
|
result = await cls.get(session, cls.id == _inst_id)
|
|
216
|
-
|
|
355
|
+
if result is None:
|
|
356
|
+
raise RuntimeError(f"{cls.__name__} record not found (id={_inst_id})")
|
|
217
357
|
instances = result
|
|
218
358
|
|
|
219
359
|
return instances
|
|
@@ -294,9 +434,11 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
294
434
|
# After commit objects expire; use sa_inspect to safely read id from identity map
|
|
295
435
|
_insp = cast(InstanceState[Any], inspect(instance))
|
|
296
436
|
_instance_id = _insp.identity[0] if _insp.identity else None
|
|
297
|
-
|
|
437
|
+
if _instance_id is None:
|
|
438
|
+
raise RuntimeError(f"{cls.__name__} id is None after save")
|
|
298
439
|
result = await cls.get(session, cls.id == _instance_id, load=load, jti_subclasses=jti_subclasses)
|
|
299
|
-
|
|
440
|
+
if result is None:
|
|
441
|
+
raise RuntimeError(f"{cls.__name__} record not found (id={_instance_id})")
|
|
300
442
|
return result
|
|
301
443
|
|
|
302
444
|
async def update(
|
|
@@ -375,9 +517,11 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
375
517
|
# After commit objects expire; use sa_inspect to safely read id from identity map
|
|
376
518
|
_insp = cast(InstanceState[Any], inspect(instance))
|
|
377
519
|
_instance_id = _insp.identity[0] if _insp.identity else None
|
|
378
|
-
|
|
520
|
+
if _instance_id is None:
|
|
521
|
+
raise RuntimeError(f"{cls.__name__} id is None after update")
|
|
379
522
|
result = await cls.get(session, cls.id == _instance_id, load=load, jti_subclasses=jti_subclasses)
|
|
380
|
-
|
|
523
|
+
if result is None:
|
|
524
|
+
raise RuntimeError(f"{cls.__name__} record not found (id={_instance_id})")
|
|
381
525
|
return result
|
|
382
526
|
|
|
383
527
|
@classmethod
|
|
@@ -1025,14 +1169,14 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
1025
1169
|
|
|
1026
1170
|
@overload
|
|
1027
1171
|
@classmethod
|
|
1028
|
-
async def get_exist_one(cls: type[T], session: AsyncSession, id: int, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None) -> T: ...
|
|
1172
|
+
async def get_exist_one(cls: type[T], session: AsyncSession, id: int, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None, *, detail: str = "Not found") -> T: ...
|
|
1029
1173
|
|
|
1030
1174
|
@overload
|
|
1031
1175
|
@classmethod
|
|
1032
|
-
async def get_exist_one(cls: type[T], session: AsyncSession, id: uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None) -> T: ...
|
|
1176
|
+
async def get_exist_one(cls: type[T], session: AsyncSession, id: uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None, *, detail: str = "Not found") -> T: ...
|
|
1033
1177
|
|
|
1034
1178
|
@classmethod
|
|
1035
|
-
async def get_exist_one(cls: type[T], session: AsyncSession, id: int | uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None) -> T:
|
|
1179
|
+
async def get_exist_one(cls: type[T], session: AsyncSession, id: int | uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None, *, detail: str = "Not found") -> T:
|
|
1036
1180
|
"""
|
|
1037
1181
|
Get a record by primary key ID, raising 404 if not found.
|
|
1038
1182
|
|
|
@@ -1042,6 +1186,10 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
1042
1186
|
:param session: Async database session
|
|
1043
1187
|
:param id: Primary key ID
|
|
1044
1188
|
:param load: Relationship(s) to eagerly load
|
|
1189
|
+
:param detail: 404 response detail text (default ``"Not found"``).
|
|
1190
|
+
Callers may supply a localized / context-specific message
|
|
1191
|
+
(e.g. ``"Bundle not found"``) without falling back to manual
|
|
1192
|
+
``get(...) + null check + raise`` boilerplate.
|
|
1045
1193
|
:returns: The found instance
|
|
1046
1194
|
:raises HTTPException: (FastAPI) If not found
|
|
1047
1195
|
:raises RecordNotFoundError: (no FastAPI) If not found
|
|
@@ -1049,8 +1197,8 @@ class TableBaseMixin(AsyncAttrs):
|
|
|
1049
1197
|
instance = await cls.get(session, col(cls.id) == id, load=load)
|
|
1050
1198
|
if instance is None:
|
|
1051
1199
|
if _HAS_FASTAPI:
|
|
1052
|
-
raise _FastAPIHTTPException(status_code=404, detail=
|
|
1053
|
-
raise RecordNotFoundError(
|
|
1200
|
+
raise _FastAPIHTTPException(status_code=404, detail=detail)
|
|
1201
|
+
raise RecordNotFoundError(detail)
|
|
1054
1202
|
return instance
|
|
1055
1203
|
|
|
1056
1204
|
|
|
@@ -1090,15 +1238,16 @@ class UUIDTableBaseMixin(TableBaseMixin):
|
|
|
1090
1238
|
|
|
1091
1239
|
@override
|
|
1092
1240
|
@classmethod
|
|
1093
|
-
async def get_exist_one(cls: type[T], session: AsyncSession, id: uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None) -> T:
|
|
1241
|
+
async def get_exist_one(cls: type[T], session: AsyncSession, id: uuid.UUID, load: QueryableAttribute[Any] | list[QueryableAttribute[Any]] | None = None, *, detail: str = "Not found") -> T:
|
|
1094
1242
|
"""
|
|
1095
1243
|
Get a record by UUID primary key, raising 404 if not found.
|
|
1096
1244
|
|
|
1097
1245
|
:param session: Async database session
|
|
1098
1246
|
:param id: UUID primary key
|
|
1099
1247
|
:param load: Relationship(s) to eagerly load
|
|
1248
|
+
:param detail: 404 response detail text (default ``"Not found"``)
|
|
1100
1249
|
:returns: The found instance
|
|
1101
1250
|
:raises HTTPException: (FastAPI) If not found
|
|
1102
1251
|
:raises RecordNotFoundError: (no FastAPI) If not found
|
|
1103
1252
|
"""
|
|
1104
|
-
return await super().get_exist_one(session, id, load)
|
|
1253
|
+
return await super().get_exist_one(session, id, load, detail=detail)
|
sqlmodel_ext/pagination.py
CHANGED
|
@@ -97,8 +97,8 @@ class PaginationRequest(SQLModelBase):
|
|
|
97
97
|
offset: int | None = Field(default=0, ge=0)
|
|
98
98
|
"""Offset (skip first N records), must be non-negative"""
|
|
99
99
|
|
|
100
|
-
limit: int | None = Field(default=50, le=100)
|
|
101
|
-
"""Page size (return at most N records), default 50, max 100"""
|
|
100
|
+
limit: int | None = Field(ge=1, default=50, le=100)
|
|
101
|
+
"""Page size (return at most N records), min 1, default 50, max 100"""
|
|
102
102
|
|
|
103
103
|
desc: bool | None = True
|
|
104
104
|
"""Sort descending (True: descending, False: ascending)"""
|
|
@@ -1093,8 +1093,15 @@ class RelationLoadChecker:
|
|
|
1093
1093
|
"""
|
|
1094
1094
|
Check a model method (with self tracking and @requires_relations parsing).
|
|
1095
1095
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1096
|
+
All method parameters (``self`` plus other model-typed arguments) are marked
|
|
1097
|
+
as caller_provided — these objects are passed in by the caller, preloading
|
|
1098
|
+
is the caller's contract (typically declared via a docstring "preload before
|
|
1099
|
+
calling: ..." note), so the method body must not be flagged with RLC003 for
|
|
1100
|
+
"missing load=". This matches ``_check_coroutine``'s policy of marking all
|
|
1101
|
+
params as caller_provided.
|
|
1102
|
+
|
|
1103
|
+
Post-commit relation access still triggers RLC002 / RLC007 / RLC008
|
|
1104
|
+
(caller_provided does not exempt those checks).
|
|
1098
1105
|
"""
|
|
1099
1106
|
# Parse AST to extract @requires_relations
|
|
1100
1107
|
source_file, tree, line_offset = self._parse_function_source(func)
|
|
@@ -1114,17 +1121,19 @@ class RelationLoadChecker:
|
|
|
1114
1121
|
# Detect instance method or classmethod
|
|
1115
1122
|
sig = python_inspect.signature(func)
|
|
1116
1123
|
first_param = next(iter(sig.parameters), None)
|
|
1117
|
-
caller_provided_params: set[str] = set()
|
|
1118
1124
|
|
|
1119
1125
|
# cls -> class alias (classmethod's cls parameter doesn't enter tracked_vars,
|
|
1120
1126
|
# only used for resolving class-level calls)
|
|
1121
1127
|
class_aliases: dict[str, str] = {}
|
|
1122
1128
|
if first_param == 'self':
|
|
1123
1129
|
param_models['self'] = cls_name
|
|
1124
|
-
caller_provided_params.add('self')
|
|
1125
1130
|
elif first_param == 'cls':
|
|
1126
1131
|
class_aliases['cls'] = cls_name
|
|
1127
1132
|
|
|
1133
|
+
# All method params are caller-provided (self + other model-typed args
|
|
1134
|
+
# are the caller's preload responsibility).
|
|
1135
|
+
caller_provided_params: set[str] = set(param_models.keys())
|
|
1136
|
+
|
|
1128
1137
|
# @requires_relations declared rels as self's dep_loads
|
|
1129
1138
|
dep_loads: dict[str, set[str]] = {}
|
|
1130
1139
|
if 'self' in param_models and decorator_loads:
|
|
@@ -1225,7 +1234,15 @@ class RelationLoadChecker:
|
|
|
1225
1234
|
model_rel_targets=self.model_rel_targets,
|
|
1226
1235
|
refreshing_commit_methods=self.refreshing_commit_methods,
|
|
1227
1236
|
)
|
|
1228
|
-
analyzer.visit(func_node)
|
|
1237
|
+
# Manually iterate ``func_node.body`` instead of ``analyzer.visit(func_node)``:
|
|
1238
|
+
# ``visit_FunctionDef`` / ``visit_AsyncFunctionDef`` are no-ops (used to skip
|
|
1239
|
+
# nested function bodies — see their docstrings). Calling ``visit(func_node)``
|
|
1240
|
+
# on the top-level entry would also hit that no-op and swallow the entire
|
|
1241
|
+
# function body. Manual iteration dispatches each top-level statement (Return /
|
|
1242
|
+
# Assign / Expr / ...) to its visit_* handler, while nested ``def`` statements
|
|
1243
|
+
# in the body still hit the no-op and are correctly skipped.
|
|
1244
|
+
for stmt in func_node.body:
|
|
1245
|
+
analyzer.visit(stmt)
|
|
1229
1246
|
|
|
1230
1247
|
return list(analyzer.warnings), analyzer
|
|
1231
1248
|
|
|
@@ -1287,20 +1304,32 @@ class RelationLoadChecker:
|
|
|
1287
1304
|
(e.g. TYPE_CHECKING forward references), falls back to per-parameter
|
|
1288
1305
|
``__annotations__`` parsing to ensure resolvable params aren't missed.
|
|
1289
1306
|
|
|
1307
|
+
TYPE_CHECKING forward reference handling: passes ``self.model_classes``
|
|
1308
|
+
(all mapped ORM classes) as ``localns`` to ``get_type_hints()`` so that
|
|
1309
|
+
string annotations like ``llm: 'LLM'`` (where ``LLM`` is imported only
|
|
1310
|
+
under ``if TYPE_CHECKING:``) resolve to actual classes instead of being
|
|
1311
|
+
silently dropped — silent drops cause RLC007/RLC013 false negatives.
|
|
1312
|
+
|
|
1290
1313
|
:returns: param_name -> model_class_name
|
|
1291
1314
|
"""
|
|
1292
1315
|
param_models: dict[str, str] = {}
|
|
1293
1316
|
|
|
1317
|
+
# Provide model_classes as localns so TYPE_CHECKING forward references resolve.
|
|
1318
|
+
localns: dict[str, Any] = dict(self.model_classes)
|
|
1294
1319
|
try:
|
|
1295
|
-
hints = typing.get_type_hints(func, include_extras=True)
|
|
1320
|
+
hints = typing.get_type_hints(func, include_extras=True, localns=localns)
|
|
1296
1321
|
except Exception:
|
|
1297
|
-
#
|
|
1298
|
-
# Fall back to per-parameter __annotations__
|
|
1322
|
+
# Even with model_classes in scope, get_type_hints may still fail on
|
|
1323
|
+
# nested third-party ForwardRefs. Fall back to per-parameter __annotations__:
|
|
1324
|
+
# try to eval string annotations against globals + model_classes.
|
|
1299
1325
|
hints = {}
|
|
1300
1326
|
annotations: dict[str, Any] = getattr(func, '__annotations__', {})
|
|
1301
1327
|
for param_name, annotation in annotations.items():
|
|
1302
1328
|
if isinstance(annotation, str):
|
|
1303
|
-
|
|
1329
|
+
resolved = self._eval_string_annotation(annotation, func)
|
|
1330
|
+
if resolved is not None:
|
|
1331
|
+
hints[param_name] = resolved
|
|
1332
|
+
continue
|
|
1304
1333
|
hints[param_name] = annotation
|
|
1305
1334
|
|
|
1306
1335
|
for param_name, hint in hints.items():
|
|
@@ -1333,6 +1362,24 @@ class RelationLoadChecker:
|
|
|
1333
1362
|
|
|
1334
1363
|
return param_models
|
|
1335
1364
|
|
|
1365
|
+
def _eval_string_annotation(self, annotation: str, func: Any) -> Any:
|
|
1366
|
+
"""
|
|
1367
|
+
Evaluate a string annotation that ``typing.get_type_hints`` couldn't resolve.
|
|
1368
|
+
|
|
1369
|
+
Typical case: a TYPE_CHECKING-only import is referenced as a forward reference
|
|
1370
|
+
(e.g. ``llm: 'LLM'`` where ``LLM`` is imported only under ``if TYPE_CHECKING:``).
|
|
1371
|
+
Evaluates against ``func.__globals__`` merged with the known ORM model classes.
|
|
1372
|
+
|
|
1373
|
+
Supported forms: ``'LLM'``, ``'LLM | None'``, ``'list[LLM]'`` etc.
|
|
1374
|
+
Returns ``None`` on failure (silent skip, matches the original fallback).
|
|
1375
|
+
"""
|
|
1376
|
+
globalns: dict[str, Any] = getattr(func, '__globals__', {}) or {}
|
|
1377
|
+
localns: dict[str, Any] = dict(self.model_classes)
|
|
1378
|
+
try:
|
|
1379
|
+
return eval(annotation, globalns, localns) # noqa: S307 — source-code literal, scoped namespace
|
|
1380
|
+
except Exception:
|
|
1381
|
+
return None
|
|
1382
|
+
|
|
1336
1383
|
def _extract_model_from_hint(self, hint: Any) -> str | None:
|
|
1337
1384
|
"""
|
|
1338
1385
|
Extract a model class name from a type annotation.
|
|
@@ -2765,6 +2812,20 @@ class _FunctionAnalyzer(ast.NodeVisitor):
|
|
|
2765
2812
|
method_name = self._get_method_name(call)
|
|
2766
2813
|
loaded_rels = self._extract_load_from_call(call)
|
|
2767
2814
|
|
|
2815
|
+
# RLC010: check whether ``return await Call(..., expired_arg)`` carries
|
|
2816
|
+
# post-commit expired objects as call args. Previously visit_Return only
|
|
2817
|
+
# checked relationship preloading (_check_return_loaded), leaving
|
|
2818
|
+
# ``return await callee(..., expired_var)`` paths uncovered. Mirrors the
|
|
2819
|
+
# symmetric checks in visit_Expr and _check_assign.
|
|
2820
|
+
# Example scenario:
|
|
2821
|
+
# user_file = await cls._unchecked(...) # commits → tracked vars expire
|
|
2822
|
+
# return await user_file._moderate(session, config=config)
|
|
2823
|
+
# ^^^^^^^^^^^^^
|
|
2824
|
+
# ``config`` expired; callee's
|
|
2825
|
+
# ``config.attr`` triggers
|
|
2826
|
+
# MissingGreenlet.
|
|
2827
|
+
self._check_expired_call_args(call, node)
|
|
2828
|
+
|
|
2768
2829
|
# return await obj.save/update/create_duplicate/... (commit methods)
|
|
2769
2830
|
if self._is_commit_for_call(call, method_name):
|
|
2770
2831
|
obj_name = self._get_call_object_name(call)
|
|
@@ -3383,6 +3444,33 @@ class _FunctionAnalyzer(ast.NodeVisitor):
|
|
|
3383
3444
|
if self.has_session_param:
|
|
3384
3445
|
self._expire_all_tracked_vars_for_yield()
|
|
3385
3446
|
|
|
3447
|
+
@override
|
|
3448
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
3449
|
+
"""
|
|
3450
|
+
Do not recurse into nested function definitions.
|
|
3451
|
+
|
|
3452
|
+
AST default DFS would descend into nested ``def`` bodies and count
|
|
3453
|
+
their yield/await statements as part of the outer function's flow,
|
|
3454
|
+
producing false positives. Nested function bodies execute in their
|
|
3455
|
+
own scope when invoked, not as part of the outer flow.
|
|
3456
|
+
|
|
3457
|
+
Typical false positive:
|
|
3458
|
+
``async def outer(...):
|
|
3459
|
+
async def _inner():
|
|
3460
|
+
yield chunk``
|
|
3461
|
+
The outer function has no yield, but ``_inner``'s yield was being
|
|
3462
|
+
counted as the outer's, triggering RLC013.
|
|
3463
|
+
|
|
3464
|
+
Trade-off: nested closures are not analyzed under the current
|
|
3465
|
+
scope. Independently scanning them would require a second pass
|
|
3466
|
+
with closure capture analysis; in practice nested closures rarely
|
|
3467
|
+
access ORM directly, so we accept the gap.
|
|
3468
|
+
"""
|
|
3469
|
+
|
|
3470
|
+
@override
|
|
3471
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
3472
|
+
"""Do not recurse into nested async function definitions (same as visit_FunctionDef)."""
|
|
3473
|
+
|
|
3386
3474
|
# ========================= Branch-aware state management =========================
|
|
3387
3475
|
|
|
3388
3476
|
def _snapshot_tracked_vars(self) -> dict[str, _TrackedVar]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlmodel-ext
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
sqlmodel_ext/__init__.py,sha256=
|
|
2
|
-
sqlmodel_ext/_compat.py,sha256=
|
|
1
|
+
sqlmodel_ext/__init__.py,sha256=n0ulFKaNBxk5h8WYaxVDbJdhN3yD_ySR52ZYvRw7FZc,2833
|
|
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=
|
|
7
|
-
sqlmodel_ext/pagination.py,sha256=
|
|
8
|
-
sqlmodel_ext/relation_load_checker.py,sha256=
|
|
9
|
-
sqlmodel_ext/field_types/__init__.py,sha256=
|
|
6
|
+
sqlmodel_ext/base.py,sha256=ADFwrEW5UJBSAVEEqLrWz_tIHpaj5iBCiBFRyd9jjc4,52848
|
|
7
|
+
sqlmodel_ext/pagination.py,sha256=1JOzt51KvIZTyjgYsQTP0v7KDytIkQKlYN4vGkq9RH8,4611
|
|
8
|
+
sqlmodel_ext/relation_load_checker.py,sha256=1W2vSdk_PVTqBRRcSyIffw-ECysJRfg1bJYVTmx9Wxs,172457
|
|
9
|
+
sqlmodel_ext/field_types/__init__.py,sha256=dFnaZxQb6ItO3ZrQ1RyqAceykF_JyFrYnJvdjq7mFPE,7916
|
|
10
10
|
sqlmodel_ext/field_types/_ssrf.py,sha256=QdrtwWMFN6xZkllyHGwvJ0nwL3rSBn2FkEoxv3L8kvs,2627
|
|
11
11
|
sqlmodel_ext/field_types/ip_address.py,sha256=OeyOAVgDV7aGk56z0LUqgYCinthYYusmZSCR1BLkET4,1589
|
|
12
12
|
sqlmodel_ext/field_types/url.py,sha256=1iAdu3A9uyMR3XJMs0b3NJ2_VeBAEAfja-_xbdjph5I,5209
|
|
@@ -21,13 +21,13 @@ sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py,sha256=q9eOCcFyaw4d
|
|
|
21
21
|
sqlmodel_ext/field_types/mixins/__init__.py,sha256=d8L1AA1sBL2zrvJk6X38ArcZ2SoXnVpDbOQc36s4Yug,48
|
|
22
22
|
sqlmodel_ext/field_types/mixins/module_name_mixin.py,sha256=CAn-DFo7LMQpgwuOpaYHo2ib1ehYz7-1NOOTgAAFxOg,1099
|
|
23
23
|
sqlmodel_ext/mixins/__init__.py,sha256=DS9vPiPgcJFuT2PlUIP0EySbpcrxiB9dzJJOtd1caA0,906
|
|
24
|
-
sqlmodel_ext/mixins/cached_table.py,sha256=
|
|
24
|
+
sqlmodel_ext/mixins/cached_table.py,sha256=ugyX8h-WP60gZP-hDR4eh6RUXtBgldPVzpeT9K5DnVo,99216
|
|
25
25
|
sqlmodel_ext/mixins/info_response.py,sha256=Z8akOpN9wlhiq5_MbzEjwGpGDQ99jYvGQt9sItF43ek,1319
|
|
26
26
|
sqlmodel_ext/mixins/optimistic_lock.py,sha256=XVjO6NgKYfpcsGlH1Yj8Ou9NkpCsqVN9AZyWxDBTb_k,2925
|
|
27
27
|
sqlmodel_ext/mixins/polymorphic.py,sha256=rX2XER0s0kRhHnpmwWUUj6wEaImCM2KBzgkh2M1wRIk,27836
|
|
28
28
|
sqlmodel_ext/mixins/relation_preload.py,sha256=SD9Nnc8zIbUqWqI85gr4XRpbPsmJwXQd7JPHj_EVrLs,15967
|
|
29
|
-
sqlmodel_ext/mixins/table.py,sha256=
|
|
30
|
-
sqlmodel_ext-0.3.
|
|
31
|
-
sqlmodel_ext-0.3.
|
|
32
|
-
sqlmodel_ext-0.3.
|
|
33
|
-
sqlmodel_ext-0.3.
|
|
29
|
+
sqlmodel_ext/mixins/table.py,sha256=BsOoimqZwD64EjZpVHffXU_in7G2utS2CprdPXMSQQo,56444
|
|
30
|
+
sqlmodel_ext-0.3.3.dist-info/METADATA,sha256=1S0wju_2F95t-ySh-GBNbH5X1qogPlTS2dkvNc8zujs,49387
|
|
31
|
+
sqlmodel_ext-0.3.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
32
|
+
sqlmodel_ext-0.3.3.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
|
|
33
|
+
sqlmodel_ext-0.3.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|