sqlmodel-ext 0.3.2__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlmodel_ext/__init__.py CHANGED
@@ -20,7 +20,7 @@ Quick start::
20
20
  user = await user.save(session)
21
21
  users = await User.get(session, fetch_mode="all")
22
22
  """
23
- __version__ = "0.3.0"
23
+ __version__ = "0.3.4"
24
24
 
25
25
  # Base
26
26
  from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
@@ -94,10 +94,12 @@ from sqlmodel_ext.field_types import (
94
94
  Text3K,
95
95
  Text5K,
96
96
  Text10K,
97
+ Text16K,
97
98
  Text32K,
98
99
  Text60K,
99
100
  Text64K,
100
101
  Text100K,
102
+ Text128K,
101
103
  Text1M,
102
104
  # Numeric constraints
103
105
  INT32_MAX,
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__(None, None)
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
- schema = md.__get_pydantic_core_schema__(None, None)
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
- schema = md.__get_pydantic_core_schema__(None, None)
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
- if get_origin(field_type) is not typing.Annotated:
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(field_type)
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
- new_ann = typing.Annotated[tuple([base_type] + remaining_metadata)]
401
+ new_inner: typing.Any = typing.Annotated[tuple([base_type] + remaining_metadata)]
317
402
  else:
318
- new_ann = base_type
319
- annotations[field_name] = new_ann
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
- attrs[field_name] = Field(sa_type=sa_type)
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
- try:
1714
- mapper = sa_inspect(cls)
1715
- if mapper is None:
1716
- raise TypeError(f"sa_inspect({cls.__name__}) returned None")
1717
- for rel in mapper.relationships:
1718
- if not rel.cascade.delete or not rel.passive_deletes:
1719
- continue
1720
- target = rel.mapper.class_
1721
- if not (isinstance(target, type) and issubclass(target, CachedTableBaseMixin)):
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
- if instance_ids:
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) == 1:
1726
- target_mapper = sa_inspect(target)
1727
- if target_mapper is None:
1728
- continue
1729
- target_pk = target_mapper.primary_key[0]
1730
- stmt = sa_select(target_pk).where(remote_cols[0].in_(instance_ids))
1731
- rows = await session.execute(stmt)
1732
- child_ids = [row[0] for row in rows]
1733
- if child_ids:
1734
- passive_targets[target] = child_ids
1735
- else:
1736
- # Condition deletes cannot enumerate parent IDs; use the
1737
- # sentinel to request a model-level full invalidation.
1738
- passive_targets[target] = [_FULL_MODEL_INVALIDATION]
1739
- except Exception as e:
1740
- logger.warning(f"passive_deletes pre-query failed ({cls.__name__}): {e}")
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).
@@ -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
- PostgreSQL triggers (``RAISE EXCEPTION ... USING ERRCODE = 'check_violation'``)
150
- produce SQLSTATE 23514 errors with business-semantic messages that can be
151
- shown directly to users. Other constraint errors (FK, unique, etc.) may leak
152
- table structure information and need sanitizing.
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: This method is PostgreSQL-specific. For other databases, only the
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 for non-trigger constraint errors
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
- # check_violation (SQLSTATE 23514): produced by trigger RAISE EXCEPTION
163
- if orig is not None and getattr(orig, 'sqlstate', None) == '23514':
164
- error_msg = str(orig)
165
- # PostgreSQL format: "ERROR: message\nDETAIL: ...\nCONTEXT: ..."
166
- if '\n' in error_msg:
167
- error_msg = error_msg.split('\n')[0]
168
- if error_msg.startswith('ERROR:'):
169
- error_msg = error_msg[6:].strip()
170
- return error_msg
171
- logger.warning(f"Data integrity constraint error: {e}")
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
- assert _inst_id is not None, f"{cls.__name__} id is None after add"
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
- assert result is not None, f"{cls.__name__} record not found (id={_inst_id})"
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
- assert _inst_id is not None, f"{cls.__name__} id is None after add"
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
- assert result is not None, f"{cls.__name__} record not found (id={_inst_id})"
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
- assert _instance_id is not None, f"{cls.__name__} id is None after save"
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
- assert result is not None, f"{cls.__name__} record not found (id={_instance_id})"
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
- assert _instance_id is not None, f"{cls.__name__} id is None after update"
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
- assert result is not None, f"{cls.__name__} record not found (id={_instance_id})"
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="Not found")
1053
- raise RecordNotFoundError("Not found")
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)
@@ -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
- self is marked as caller_provided; pre-commit access skips RLC003.
1097
- But self.save() followed by relationship access still triggers RLC002.
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
- # get_type_hints may fail due to TYPE_CHECKING forward references.
1298
- # Fall back to per-parameter __annotations__ (skip unresolvable string 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
- continue # Skip unresolvable string annotations
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.2
3
+ Version: 0.3.4
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=elrg3UrCZ52B3WNO8uwDD8kff7TyIuxjZsVLKKjOzhU,2833
2
- sqlmodel_ext/_compat.py,sha256=keZBO_ZYEG1qpvzDAzGNaKXaxY9QUY4hx7JIaas9EDY,11302
1
+ sqlmodel_ext/__init__.py,sha256=PbFopnDRXl6y3Raas4VuePuQPpY5TQMGAyqe4P7AZiM,2862
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=GuEcsFOUrDyKvXvAZdi0-KWI9Os66Qt0Cd4fCpzJ4FY,46255
7
- sqlmodel_ext/pagination.py,sha256=b9i0y3GwUfqQPDugI7gx4Y9IMhYAOtkQBEgCaJJDrmM,4598
8
- sqlmodel_ext/relation_load_checker.py,sha256=MI9Breb7_qwElDT8E0K09D7VPcF3TGwoWtxKZiEhMmA,167328
9
- sqlmodel_ext/field_types/__init__.py,sha256=vN7dnPIE5wXUqme38iD2n56bTZrjkeFPQIMW1CSA8_4,6579
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=tmXcwgx9C2ifc4xsG5EecKY5-shp6r6jlzZcEOVI18c,96838
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=hJPeQX1GDhLs9dTUnHVPhPbfZX6Pk3wQX49KIpDAJ9o,48826
30
- sqlmodel_ext-0.3.2.dist-info/METADATA,sha256=JOsJ_iUaija-jMpUPRP-LcELshEvtw0U7Ygm6E7_WqM,49387
31
- sqlmodel_ext-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
32
- sqlmodel_ext-0.3.2.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
33
- sqlmodel_ext-0.3.2.dist-info/RECORD,,
29
+ sqlmodel_ext/mixins/table.py,sha256=BsOoimqZwD64EjZpVHffXU_in7G2utS2CprdPXMSQQo,56444
30
+ sqlmodel_ext-0.3.4.dist-info/METADATA,sha256=D13ATIqAPkZ3qbzI4nBTtuCPx9oPdMjIH_D_OeP1gm4,49387
31
+ sqlmodel_ext-0.3.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
32
+ sqlmodel_ext-0.3.4.dist-info/licenses/LICENSE,sha256=8ek0ENxpcXcIhCC7MXetxiYLKflHqz2JbA-nwDqTsyI,1088
33
+ sqlmodel_ext-0.3.4.dist-info/RECORD,,