sqlmodel-ext 0.2.1__py3-none-any.whl → 0.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlmodel_ext/__init__.py +7 -1
- sqlmodel_ext/_compat.py +48 -0
- sqlmodel_ext/base.py +374 -13
- sqlmodel_ext/field_types/__init__.py +33 -4
- sqlmodel_ext/field_types/dialects/postgresql/array.py +119 -113
- sqlmodel_ext/mixins/__init__.py +1 -0
- sqlmodel_ext/mixins/cached_table.py +886 -519
- sqlmodel_ext/mixins/polymorphic.py +50 -17
- sqlmodel_ext/mixins/table.py +16 -0
- sqlmodel_ext/relation_load_checker.py +1161 -175
- {sqlmodel_ext-0.2.1.dist-info → sqlmodel_ext-0.3.2.dist-info}/METADATA +140 -4
- {sqlmodel_ext-0.2.1.dist-info → sqlmodel_ext-0.3.2.dist-info}/RECORD +14 -14
- {sqlmodel_ext-0.2.1.dist-info → sqlmodel_ext-0.3.2.dist-info}/WHEEL +0 -0
- {sqlmodel_ext-0.2.1.dist-info → sqlmodel_ext-0.3.2.dist-info}/licenses/LICENSE +0 -0
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.
|
|
23
|
+
__version__ = "0.3.0"
|
|
24
24
|
|
|
25
25
|
# Base
|
|
26
26
|
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
|
|
@@ -44,6 +44,7 @@ from sqlmodel_ext.mixins import (
|
|
|
44
44
|
UUIDTableBaseMixin,
|
|
45
45
|
rel,
|
|
46
46
|
cond,
|
|
47
|
+
safe_reset,
|
|
47
48
|
# Polymorphic
|
|
48
49
|
PolymorphicBaseMixin,
|
|
49
50
|
AutoPolymorphicIdentityMixin,
|
|
@@ -73,6 +74,7 @@ from sqlmodel_ext.field_types import (
|
|
|
73
74
|
DirectoryPathType,
|
|
74
75
|
FilePathType,
|
|
75
76
|
# String constraints
|
|
77
|
+
Str16,
|
|
76
78
|
Str24,
|
|
77
79
|
Str32,
|
|
78
80
|
Str36,
|
|
@@ -92,12 +94,15 @@ from sqlmodel_ext.field_types import (
|
|
|
92
94
|
Text3K,
|
|
93
95
|
Text5K,
|
|
94
96
|
Text10K,
|
|
97
|
+
Text32K,
|
|
95
98
|
Text60K,
|
|
96
99
|
Text64K,
|
|
97
100
|
Text100K,
|
|
98
101
|
Text1M,
|
|
99
102
|
# Numeric constraints
|
|
100
103
|
INT32_MAX,
|
|
104
|
+
INT64_MAX,
|
|
105
|
+
JS_MAX_SAFE_INTEGER,
|
|
101
106
|
Port,
|
|
102
107
|
Percentage,
|
|
103
108
|
PositiveInt,
|
|
@@ -105,6 +110,7 @@ from sqlmodel_ext.field_types import (
|
|
|
105
110
|
PositiveBigInt,
|
|
106
111
|
NonNegativeBigInt,
|
|
107
112
|
PositiveFloat,
|
|
113
|
+
NonNegativeFloat,
|
|
108
114
|
# Custom types
|
|
109
115
|
IPAddress,
|
|
110
116
|
Url,
|
sqlmodel_ext/_compat.py
CHANGED
|
@@ -14,6 +14,7 @@ if sys.version_info >= (3, 14):
|
|
|
14
14
|
from enum import StrEnum
|
|
15
15
|
from typing import Literal, Union, get_args, get_origin
|
|
16
16
|
|
|
17
|
+
from pydantic_core import PydanticUndefined
|
|
17
18
|
from sqlalchemy import String
|
|
18
19
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
|
19
20
|
|
|
@@ -27,6 +28,24 @@ if sys.version_info >= (3, 14):
|
|
|
27
28
|
import sqlmodel.main
|
|
28
29
|
_original_get_sqlalchemy_type = sqlmodel.main.get_sqlalchemy_type
|
|
29
30
|
|
|
31
|
+
def _get_explicit_sa_type(field):
|
|
32
|
+
"""
|
|
33
|
+
Extract an explicit ``sa_type`` from a SQLModel/Pydantic FieldInfo.
|
|
34
|
+
|
|
35
|
+
Matches upstream ``sqlmodel.main._get_sqlmodel_field_value`` semantics:
|
|
36
|
+
prefers ``FieldInfoMetadata.sa_type`` then falls back to the direct
|
|
37
|
+
``field.sa_type`` attribute. Returns ``PydanticUndefined`` when unset.
|
|
38
|
+
"""
|
|
39
|
+
# Prefer FieldInfoMetadata.sa_type (set via Field(sa_type=...))
|
|
40
|
+
metadata = getattr(field, 'metadata', None)
|
|
41
|
+
if metadata:
|
|
42
|
+
for item in metadata:
|
|
43
|
+
sa_type = getattr(item, 'sa_type', PydanticUndefined)
|
|
44
|
+
if sa_type is not PydanticUndefined:
|
|
45
|
+
return sa_type
|
|
46
|
+
# Fallback to direct attribute
|
|
47
|
+
return getattr(field, 'sa_type', PydanticUndefined)
|
|
48
|
+
|
|
30
49
|
def _patched_get_sqlalchemy_type(field):
|
|
31
50
|
"""
|
|
32
51
|
Fix SQLModel's get_sqlalchemy_type for Python 3.14 type issues.
|
|
@@ -34,6 +53,17 @@ if sys.version_info >= (3, 14):
|
|
|
34
53
|
Handles ForwardRef, ClassVar, Literal, Mapped, and custom types
|
|
35
54
|
that cause issubclass errors under PEP 649.
|
|
36
55
|
"""
|
|
56
|
+
# Respect explicit sa_type from Field(sa_type=...) — must take
|
|
57
|
+
# precedence over annotation-based auto-detection, matching upstream
|
|
58
|
+
# SQLModel behavior (sqlmodel/main.py::get_sqlalchemy_type checks
|
|
59
|
+
# sa_type before everything else). Without this, fields like
|
|
60
|
+
# ``options: list[str] | None = Field(sa_type=JSON)`` incorrectly
|
|
61
|
+
# get JSONB from the Union[list, None] -> JSONB branch below,
|
|
62
|
+
# breaking non-PostgreSQL dialects (e.g., SQLite in tests).
|
|
63
|
+
explicit_sa_type = _get_explicit_sa_type(field)
|
|
64
|
+
if explicit_sa_type is not PydanticUndefined:
|
|
65
|
+
return explicit_sa_type
|
|
66
|
+
|
|
37
67
|
# Check field.metadata (Pydantic-processed Annotated types)
|
|
38
68
|
metadata = getattr(field, 'metadata', None)
|
|
39
69
|
if metadata:
|
|
@@ -65,7 +95,25 @@ if sys.version_info >= (3, 14):
|
|
|
65
95
|
non_none_args = [a for a in args if a is not type(None)]
|
|
66
96
|
if non_none_args:
|
|
67
97
|
first_arg = non_none_args[0]
|
|
98
|
+
# Check for Annotated SA type metadata first (e.g. Array[T] | None).
|
|
99
|
+
# Array[T] desugars to Annotated[list[T], _ArrayTypeHandler(T)];
|
|
100
|
+
# wrapping with | None produces Union[Annotated[...], None],
|
|
101
|
+
# so the SA type must be extracted from the inner Annotated.
|
|
102
|
+
first_arg_type_name = type(first_arg).__name__
|
|
103
|
+
if first_arg_type_name in ('AnnotatedAlias', '_AnnotatedAlias'):
|
|
104
|
+
inner_args = get_args(first_arg)
|
|
105
|
+
for md in inner_args[1:]:
|
|
106
|
+
if hasattr(md, '__get_pydantic_core_schema__'):
|
|
107
|
+
try:
|
|
108
|
+
schema = md.__get_pydantic_core_schema__(None, None)
|
|
109
|
+
if isinstance(schema, dict) and 'metadata' in schema:
|
|
110
|
+
sa_type = schema['metadata'].get('sa_type')
|
|
111
|
+
if sa_type is not None:
|
|
112
|
+
return sa_type
|
|
113
|
+
except (TypeError, AttributeError, KeyError):
|
|
114
|
+
pass
|
|
68
115
|
first_origin = get_origin(first_arg)
|
|
116
|
+
# Plain list/dict/tuple/set (no Annotated SA type) -> JSONB
|
|
69
117
|
if first_origin in (list, dict, tuple, set):
|
|
70
118
|
return JSONB
|
|
71
119
|
|
sqlmodel_ext/base.py
CHANGED
|
@@ -9,9 +9,11 @@ Provides a smart metaclass that handles:
|
|
|
9
9
|
- Annotated sa_type extraction and injection
|
|
10
10
|
- Python 3.14 (PEP 649) compatibility
|
|
11
11
|
"""
|
|
12
|
+
import copy
|
|
12
13
|
import logging
|
|
13
14
|
import re
|
|
14
15
|
import sys
|
|
16
|
+
import types
|
|
15
17
|
import typing
|
|
16
18
|
from typing import Any, get_args, get_origin
|
|
17
19
|
|
|
@@ -21,7 +23,13 @@ from pydantic_core import PydanticUndefined as Undefined
|
|
|
21
23
|
from sqlalchemy import inspect as sa_inspect
|
|
22
24
|
from sqlalchemy.orm import Mapped, relationship as sa_relationship
|
|
23
25
|
from sqlmodel import Field, SQLModel
|
|
24
|
-
from sqlmodel.main import
|
|
26
|
+
from sqlmodel.main import (
|
|
27
|
+
SQLModelMetaclass,
|
|
28
|
+
is_table_model_class,
|
|
29
|
+
get_relationship_to,
|
|
30
|
+
FieldInfo as SQLModelFieldInfo, # Internal API: stable since sqlmodel 0.0.22
|
|
31
|
+
get_column_from_field, # Internal API: stable since sqlmodel 0.0.22
|
|
32
|
+
)
|
|
25
33
|
|
|
26
34
|
# Import _compat for side effects (Python 3.14 monkey-patches)
|
|
27
35
|
import sqlmodel_ext._compat # noqa: F401
|
|
@@ -41,6 +49,276 @@ else:
|
|
|
41
49
|
logger = logging.getLogger(__name__)
|
|
42
50
|
|
|
43
51
|
|
|
52
|
+
def _merge_field_info_attrs(target: SQLModelFieldInfo, source: SQLModelFieldInfo) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Merge explicitly-set attributes from ``source`` into ``target``.
|
|
55
|
+
|
|
56
|
+
Used when ``Annotated[Str64, Field(unique=True)]`` expands to multiple FieldInfo:
|
|
57
|
+
``Annotated[str, Field(max_length=64), Field(unique=True)]``.
|
|
58
|
+
Merges attributes dynamically (via ``__slots__`` / ``__annotations__`` / ``__dict__``),
|
|
59
|
+
no hardcoded attribute names — upstream additions are handled automatically.
|
|
60
|
+
"""
|
|
61
|
+
attr_names: set[str] = set()
|
|
62
|
+
for klass in type(source).__mro__:
|
|
63
|
+
for slot in getattr(klass, '__slots__', ()):
|
|
64
|
+
if not slot.startswith('_'):
|
|
65
|
+
attr_names.add(slot)
|
|
66
|
+
for ann in getattr(klass, '__annotations__', {}):
|
|
67
|
+
if not ann.startswith('_'):
|
|
68
|
+
attr_names.add(ann)
|
|
69
|
+
for key in (vars(source) if hasattr(source, '__dict__') else ()):
|
|
70
|
+
if not key.startswith('_'):
|
|
71
|
+
attr_names.add(key)
|
|
72
|
+
|
|
73
|
+
# metadata lists should be merged, not replaced
|
|
74
|
+
attr_names.discard('metadata')
|
|
75
|
+
|
|
76
|
+
for attr_name in attr_names:
|
|
77
|
+
try:
|
|
78
|
+
val = getattr(source, attr_name)
|
|
79
|
+
except AttributeError:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if val is None or val is Undefined:
|
|
83
|
+
continue
|
|
84
|
+
if isinstance(val, (list, dict, set)) and not val:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Boolean flags (unique, primary_key, etc.): don't overwrite True with False
|
|
88
|
+
if isinstance(val, bool) and not val:
|
|
89
|
+
current = getattr(target, attr_name, None)
|
|
90
|
+
if isinstance(current, bool) and current:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
setattr(target, attr_name, val)
|
|
95
|
+
except (AttributeError, TypeError):
|
|
96
|
+
continue # read-only slot
|
|
97
|
+
|
|
98
|
+
# Merge metadata lists (Pydantic validator metadata like MaxLen, etc.)
|
|
99
|
+
source_meta = getattr(source, 'metadata', None)
|
|
100
|
+
if source_meta:
|
|
101
|
+
target_meta = getattr(target, 'metadata', None) or []
|
|
102
|
+
target.metadata = list(target_meta) + list(source_meta)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _make_annotation_optional(annotation: typing.Any) -> typing.Any:
|
|
106
|
+
"""
|
|
107
|
+
Convert type annotation to optional: ``T → T | None``
|
|
108
|
+
|
|
109
|
+
Places ``| None`` inside ``Annotated[]`` to preserve Field metadata.
|
|
110
|
+
|
|
111
|
+
Examples::
|
|
112
|
+
|
|
113
|
+
str → str | None
|
|
114
|
+
Annotated[float, Field(ge=0.0)] → Annotated[float | None, Field(ge=0.0)]
|
|
115
|
+
str | None → str | None (already optional, unchanged)
|
|
116
|
+
"""
|
|
117
|
+
origin = get_origin(annotation)
|
|
118
|
+
|
|
119
|
+
# Annotated[T, metadata...] → Annotated[T | None, metadata...]
|
|
120
|
+
if origin is typing.Annotated:
|
|
121
|
+
args = get_args(annotation)
|
|
122
|
+
inner_type = args[0]
|
|
123
|
+
metadata = args[1:]
|
|
124
|
+
optional_inner = _make_annotation_optional(inner_type)
|
|
125
|
+
return typing.Annotated[tuple([optional_inner, *metadata])]
|
|
126
|
+
|
|
127
|
+
# Union / UnionType already contains None → unchanged
|
|
128
|
+
if origin is typing.Union or isinstance(annotation, types.UnionType):
|
|
129
|
+
args = get_args(annotation)
|
|
130
|
+
if type(None) in args:
|
|
131
|
+
return annotation
|
|
132
|
+
|
|
133
|
+
# T → T | None
|
|
134
|
+
return annotation | None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _apply_all_fields_optional(
|
|
138
|
+
annotations: dict[str, typing.Any],
|
|
139
|
+
attrs: dict[str, typing.Any],
|
|
140
|
+
bases: tuple[type, ...],
|
|
141
|
+
) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Automatically convert inherited fields to optional.
|
|
144
|
+
|
|
145
|
+
Two-step strategy (same MRO traversal pattern as ``_recover_annotated_sqlmodel_fields``):
|
|
146
|
+
|
|
147
|
+
1. Collect data field names from base ``model_fields`` (filtering ClassVar/Relationship)
|
|
148
|
+
2. Get original type annotations from base MRO ``__annotations__`` (preserving ``Annotated`` metadata)
|
|
149
|
+
|
|
150
|
+
Ensures ``Annotated[float, Field(ge=0.0)]`` constraints are not lost.
|
|
151
|
+
"""
|
|
152
|
+
# 1. Collect all base class data field names
|
|
153
|
+
field_names: set[str] = set()
|
|
154
|
+
for base in bases:
|
|
155
|
+
base_model_fields = getattr(base, 'model_fields', None)
|
|
156
|
+
if base_model_fields:
|
|
157
|
+
field_names.update(base_model_fields.keys())
|
|
158
|
+
|
|
159
|
+
# 2. For each field, get original annotation from MRO and make optional
|
|
160
|
+
for field_name in field_names:
|
|
161
|
+
if field_name in annotations:
|
|
162
|
+
continue
|
|
163
|
+
# Find original annotation from MRO (preserves Annotated metadata)
|
|
164
|
+
original_ann: typing.Any = None
|
|
165
|
+
for base in bases:
|
|
166
|
+
for cls in base.__mro__:
|
|
167
|
+
if cls is object:
|
|
168
|
+
continue
|
|
169
|
+
cls_ann = getattr(cls, '__annotations__', None)
|
|
170
|
+
if not cls_ann or field_name not in cls_ann:
|
|
171
|
+
continue
|
|
172
|
+
candidate = cls_ann[field_name]
|
|
173
|
+
if isinstance(candidate, str):
|
|
174
|
+
continue
|
|
175
|
+
original_ann = candidate
|
|
176
|
+
break
|
|
177
|
+
if original_ann is not None:
|
|
178
|
+
break
|
|
179
|
+
if original_ann is None:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Skip Literal type fields (e.g. discriminator):
|
|
183
|
+
# Literal['text'] | None breaks Pydantic discriminated union
|
|
184
|
+
raw_type = original_ann
|
|
185
|
+
if get_origin(raw_type) is typing.Annotated:
|
|
186
|
+
raw_type = get_args(raw_type)[0]
|
|
187
|
+
if get_origin(raw_type) is typing.Literal:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
optional_ann = _make_annotation_optional(original_ann)
|
|
191
|
+
|
|
192
|
+
# Replace default_factory with default=None for Annotated[T, Field(default_factory=...)]
|
|
193
|
+
# Unified behavior: all_fields_optional fields all default to None, no factory retained.
|
|
194
|
+
if get_origin(optional_ann) is typing.Annotated:
|
|
195
|
+
ann_args = list(get_args(optional_ann))
|
|
196
|
+
for i, meta in enumerate(ann_args[1:], 1):
|
|
197
|
+
if isinstance(meta, FieldInfo) and meta.default_factory is not None:
|
|
198
|
+
new_fi = meta._copy() if hasattr(meta, '_copy') else copy.copy(meta)
|
|
199
|
+
new_fi.default_factory = None
|
|
200
|
+
new_fi.default = None
|
|
201
|
+
new_fi._attributes_set = dict(new_fi._attributes_set)
|
|
202
|
+
new_fi._attributes_set.pop('default_factory', None)
|
|
203
|
+
new_fi._attributes_set['default'] = None
|
|
204
|
+
ann_args[i] = new_fi
|
|
205
|
+
optional_ann = typing.Annotated[tuple(ann_args)]
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
annotations[field_name] = optional_ann
|
|
209
|
+
if field_name not in attrs:
|
|
210
|
+
attrs[field_name] = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _recover_annotated_sqlmodel_fields(
|
|
214
|
+
annotations: dict[str, typing.Any],
|
|
215
|
+
attrs: dict[str, typing.Any],
|
|
216
|
+
bases: tuple[type, ...],
|
|
217
|
+
is_table: bool,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Recover ``Annotated[T, Field(...)]`` back to ``T = Field(default=..., ...)``.
|
|
221
|
+
|
|
222
|
+
Pydantic v2 replaces sqlmodel.main.FieldInfo with pydantic.fields.FieldInfo when
|
|
223
|
+
processing Annotated metadata. The latter doesn't support SQLModel-specific attributes
|
|
224
|
+
(foreign_key, sa_type, etc.), causing get_column_from_field() to miss DB constraints.
|
|
225
|
+
|
|
226
|
+
This function dynamically discovers all SQLModel FieldInfo in Annotated metadata and
|
|
227
|
+
converts them to ``= Field(...)`` style. Future upstream additions are handled automatically.
|
|
228
|
+
|
|
229
|
+
**Only executes for table classes**: non-table classes (e.g. Base classes) keep their
|
|
230
|
+
original Annotated annotations so child table classes can recover inherited constraints.
|
|
231
|
+
|
|
232
|
+
:param annotations: Class ``__annotations__`` dict (modified in place)
|
|
233
|
+
:param attrs: Class namespace dict (modified in place)
|
|
234
|
+
:param bases: Base class tuple for checking inherited Annotated fields
|
|
235
|
+
:param is_table: Whether this class is a table class
|
|
236
|
+
"""
|
|
237
|
+
# Python 3.14 (PEP 649): when annotations contain unresolvable forward references,
|
|
238
|
+
# get_type_hints() raises NameError and returns empty dict.
|
|
239
|
+
# Recover from __annotate_func__(Format.VALUE) which keeps unresolved refs as str/ForwardRef.
|
|
240
|
+
if not annotations and annotationlib is not None:
|
|
241
|
+
annotate_func = attrs.get('__annotate_func__')
|
|
242
|
+
if annotate_func is not None:
|
|
243
|
+
try:
|
|
244
|
+
annotations.update(annotate_func(annotationlib.Format.VALUE))
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
# Non-table classes: keep original Annotated annotations unchanged
|
|
249
|
+
if not is_table:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# Collect all Annotated fields: current class + inherited from parents
|
|
253
|
+
all_annotated: dict[str, typing.Any] = {}
|
|
254
|
+
|
|
255
|
+
# Inherited Annotated fields (traverse MRO for multi-level inheritance)
|
|
256
|
+
for base in bases:
|
|
257
|
+
for cls in base.__mro__:
|
|
258
|
+
if cls is object:
|
|
259
|
+
continue
|
|
260
|
+
cls_ann = getattr(cls, '__annotations__', None)
|
|
261
|
+
if not cls_ann:
|
|
262
|
+
continue
|
|
263
|
+
for field_name, field_type in cls_ann.items():
|
|
264
|
+
if field_name not in all_annotated and field_name not in annotations:
|
|
265
|
+
all_annotated[field_name] = field_type
|
|
266
|
+
|
|
267
|
+
# Current class fields (higher priority)
|
|
268
|
+
all_annotated.update(annotations)
|
|
269
|
+
|
|
270
|
+
for field_name, field_type in all_annotated.items():
|
|
271
|
+
if get_origin(field_type) is not typing.Annotated:
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
args = get_args(field_type)
|
|
275
|
+
if len(args) < 2:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# Find all SQLModel FieldInfo in Annotated metadata
|
|
279
|
+
sqlmodel_fis: list[SQLModelFieldInfo] = [
|
|
280
|
+
arg for arg in args[1:] if isinstance(arg, SQLModelFieldInfo)
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
if not sqlmodel_fis:
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
# Merge multiple FieldInfo: shallow-copy first then merge to avoid mutating
|
|
287
|
+
# shared Annotated metadata singletons (e.g. Str64 = Annotated[str, Field(max_length=64)])
|
|
288
|
+
sqlmodel_fi = copy.copy(sqlmodel_fis[0])
|
|
289
|
+
for extra_fi in sqlmodel_fis[1:]:
|
|
290
|
+
_merge_field_info_attrs(sqlmodel_fi, extra_fi)
|
|
291
|
+
|
|
292
|
+
# Transfer plain defaults from attrs (e.g. = 0, = None) to FieldInfo
|
|
293
|
+
existing_default = attrs.get(field_name, Undefined)
|
|
294
|
+
if existing_default is not Undefined and not isinstance(existing_default, (FieldInfo, SQLModelFieldInfo)):
|
|
295
|
+
sqlmodel_fi.default = existing_default
|
|
296
|
+
elif existing_default is Undefined:
|
|
297
|
+
# Inherit default from parent model_fields, but only when FieldInfo has no default/factory
|
|
298
|
+
if sqlmodel_fi.default is Undefined and sqlmodel_fi.default_factory is None:
|
|
299
|
+
for base in bases:
|
|
300
|
+
base_fields = getattr(base, 'model_fields', None)
|
|
301
|
+
if base_fields and field_name in base_fields:
|
|
302
|
+
base_fi = base_fields[field_name]
|
|
303
|
+
if base_fi.default is not Undefined:
|
|
304
|
+
sqlmodel_fi.default = base_fi.default
|
|
305
|
+
elif base_fi.default_factory is not None:
|
|
306
|
+
sqlmodel_fi.default_factory = base_fi.default_factory
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
# Inject SQLModel FieldInfo as field default (equivalent to = Field(...) style)
|
|
310
|
+
attrs[field_name] = sqlmodel_fi
|
|
311
|
+
|
|
312
|
+
# Update annotations: remove SQLModel FieldInfo from Annotated
|
|
313
|
+
base_type = args[0]
|
|
314
|
+
remaining_metadata = [a for a in args[1:] if not isinstance(a, SQLModelFieldInfo)]
|
|
315
|
+
if remaining_metadata:
|
|
316
|
+
new_ann = typing.Annotated[tuple([base_type] + remaining_metadata)]
|
|
317
|
+
else:
|
|
318
|
+
new_ann = base_type
|
|
319
|
+
annotations[field_name] = new_ann
|
|
320
|
+
|
|
321
|
+
|
|
44
322
|
def _make_sti_fk_resolver(
|
|
45
323
|
fk_string: str,
|
|
46
324
|
sa_registry: typing.Any,
|
|
@@ -178,6 +456,18 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
178
456
|
# 4. Extract sa_type from Annotated metadata and inject into Field
|
|
179
457
|
annotations, annotation_strings, eval_globals, eval_locals = _resolve_annotations(attrs)
|
|
180
458
|
|
|
459
|
+
# 4.5. Fix Annotated[T, Field(foreign_key=...)] where SQLModel FieldInfo gets replaced
|
|
460
|
+
# by Pydantic FieldInfo. Must run before super().__new__() because SQLModel calls
|
|
461
|
+
# get_column_from_field() during __new__. Only for table classes; non-table classes
|
|
462
|
+
# keep original Annotated annotations for child table classes to inherit.
|
|
463
|
+
_recover_annotated_sqlmodel_fields(annotations, attrs, bases, will_be_table)
|
|
464
|
+
|
|
465
|
+
# 4.6. all_fields_optional: automatically convert inherited fields to optional (T | None = None)
|
|
466
|
+
# Used for UpdateRequest DTOs to avoid manually overriding each field.
|
|
467
|
+
is_all_optional = kwargs.pop('all_fields_optional', False)
|
|
468
|
+
if is_all_optional:
|
|
469
|
+
_apply_all_fields_optional(annotations, attrs, bases)
|
|
470
|
+
|
|
181
471
|
if annotations:
|
|
182
472
|
attrs['__annotations__'] = annotations
|
|
183
473
|
if annotationlib is not None:
|
|
@@ -207,13 +497,41 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
207
497
|
if field_value is Undefined:
|
|
208
498
|
attrs[field_name] = Field(sa_type=sa_type)
|
|
209
499
|
elif isinstance(field_value, FieldInfo):
|
|
210
|
-
if
|
|
211
|
-
field_value
|
|
212
|
-
|
|
213
|
-
# 5.
|
|
500
|
+
if getattr(field_value, 'sa_type', Undefined) is Undefined:
|
|
501
|
+
setattr(field_value, 'sa_type', sa_type)
|
|
502
|
+
|
|
503
|
+
# 5. Save SQLModel FieldInfo from Annotated fields before super().__new__(),
|
|
504
|
+
# because Pydantic rebuilds model_fields with plain FieldInfo that lacks
|
|
505
|
+
# SQLModel-specific attributes (unique, index, foreign_key, sa_type, etc.).
|
|
506
|
+
_saved_sqlmodel_fis: dict[str, SQLModelFieldInfo] = {}
|
|
507
|
+
if will_be_table:
|
|
508
|
+
for _fn in annotations:
|
|
509
|
+
_fv = attrs.get(_fn)
|
|
510
|
+
if isinstance(_fv, SQLModelFieldInfo):
|
|
511
|
+
_saved_sqlmodel_fis[_fn] = _fv
|
|
512
|
+
|
|
513
|
+
# 6. Call parent __new__
|
|
214
514
|
result = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
215
515
|
|
|
216
|
-
#
|
|
516
|
+
# 7. Restore SQLModel FieldInfo attributes discarded by Pydantic and rebuild Columns.
|
|
517
|
+
# Pydantic FieldInfo uses __slots__, so setattr for SQLModel extensions is silently
|
|
518
|
+
# ignored. We replace the Pydantic FieldInfo with our saved SQLModelFieldInfo and
|
|
519
|
+
# rebuild the Column via get_column_from_field().
|
|
520
|
+
if _saved_sqlmodel_fis:
|
|
521
|
+
for _fn, _saved_fi in _saved_sqlmodel_fis.items():
|
|
522
|
+
_current_fi = result.model_fields.get(_fn)
|
|
523
|
+
if _current_fi is None:
|
|
524
|
+
continue
|
|
525
|
+
_merge_field_info_attrs(_saved_fi, _current_fi)
|
|
526
|
+
if _saved_fi.default is Undefined and _current_fi.default is not Undefined:
|
|
527
|
+
_saved_fi.default = _current_fi.default
|
|
528
|
+
if _saved_fi.default_factory is None and _current_fi.default_factory is not None:
|
|
529
|
+
_saved_fi.default_factory = _current_fi.default_factory
|
|
530
|
+
result.model_fields[_fn] = _saved_fi
|
|
531
|
+
_col = get_column_from_field(_saved_fi)
|
|
532
|
+
setattr(result, _fn, _col)
|
|
533
|
+
|
|
534
|
+
# 8. Fix: inherit parent's __sqlmodel_relationships__ for JTI
|
|
217
535
|
if kwargs.get('table', False):
|
|
218
536
|
for base in bases:
|
|
219
537
|
if hasattr(base, '__sqlmodel_relationships__'):
|
|
@@ -224,7 +542,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
224
542
|
base_attr = getattr(base, rel_name)
|
|
225
543
|
setattr(result, rel_name, base_attr)
|
|
226
544
|
|
|
227
|
-
#
|
|
545
|
+
# 9. Forbid redefining parent's Relationship fields
|
|
228
546
|
for base in bases:
|
|
229
547
|
parent_relationships = getattr(base, '__sqlmodel_relationships__', {})
|
|
230
548
|
for rel_name in parent_relationships:
|
|
@@ -235,23 +553,42 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
235
553
|
f"Modify the relationship in the parent class instead."
|
|
236
554
|
)
|
|
237
555
|
|
|
238
|
-
#
|
|
556
|
+
# 10. Inherit parent field descriptions (use_attribute_docstrings fix)
|
|
557
|
+
# Pydantic's use_attribute_docstrings parses docstrings from source AST.
|
|
558
|
+
# When a subclass overrides a field (e.g. UpdateRequest changes `name: str`
|
|
559
|
+
# to `name: str | None = None`) or all_fields_optional programmatically
|
|
560
|
+
# generates annotations, there is no docstring in source → description lost.
|
|
561
|
+
# Fix: inherit missing descriptions from parent's model_fields via MRO.
|
|
562
|
+
needs_rebuild = False
|
|
563
|
+
for fname, finfo in result.model_fields.items():
|
|
564
|
+
if finfo.description is not None:
|
|
565
|
+
continue
|
|
566
|
+
for parent in result.__mro__[1:]:
|
|
567
|
+
parent_fields = getattr(parent, 'model_fields', None)
|
|
568
|
+
if parent_fields and fname in parent_fields:
|
|
569
|
+
parent_desc = parent_fields[fname].description
|
|
570
|
+
if parent_desc is not None:
|
|
571
|
+
finfo.description = parent_desc
|
|
572
|
+
needs_rebuild = True
|
|
573
|
+
break
|
|
574
|
+
|
|
575
|
+
# 11. Fix: remove Relationship fields from model_fields/__pydantic_fields__
|
|
239
576
|
relationships = getattr(result, '__sqlmodel_relationships__', {})
|
|
240
577
|
if relationships:
|
|
241
578
|
model_fields = getattr(result, 'model_fields', {})
|
|
242
579
|
pydantic_fields = getattr(result, '__pydantic_fields__', {})
|
|
243
580
|
|
|
244
|
-
fields_removed = False
|
|
245
581
|
for rel_name in relationships:
|
|
246
582
|
if rel_name in model_fields:
|
|
247
583
|
del model_fields[rel_name]
|
|
248
|
-
|
|
584
|
+
needs_rebuild = True
|
|
249
585
|
if rel_name in pydantic_fields:
|
|
250
586
|
del pydantic_fields[rel_name]
|
|
251
|
-
|
|
587
|
+
needs_rebuild = True
|
|
252
588
|
|
|
253
|
-
|
|
254
|
-
|
|
589
|
+
# Rebuild Pydantic schema (description inheritance or Relationship removal)
|
|
590
|
+
if needs_rebuild and hasattr(result, 'model_rebuild'):
|
|
591
|
+
result.model_rebuild(force=True)
|
|
255
592
|
|
|
256
593
|
return result
|
|
257
594
|
|
|
@@ -614,6 +951,30 @@ class SQLModelBase(SQLModel, metaclass=__DeclarativeMeta):
|
|
|
614
951
|
|
|
615
952
|
model_config = ConfigDict(use_attribute_docstrings=True, validate_by_name=True, extra='forbid')
|
|
616
953
|
|
|
954
|
+
@classmethod
|
|
955
|
+
def __get_pydantic_json_schema__(
|
|
956
|
+
cls,
|
|
957
|
+
core_schema: Any,
|
|
958
|
+
handler: Any,
|
|
959
|
+
) -> dict[str, Any]:
|
|
960
|
+
"""
|
|
961
|
+
Fix Pydantic JSON Schema dropping description for $ref properties.
|
|
962
|
+
|
|
963
|
+
When a field type is an enum or nested model, Pydantic sometimes generates
|
|
964
|
+
a bare ``{"$ref": "..."}`` without the ``description`` (even though
|
|
965
|
+
``model_fields`` has the description correctly set). This method patches
|
|
966
|
+
the generated schema to restore missing descriptions.
|
|
967
|
+
"""
|
|
968
|
+
json_schema = handler(core_schema)
|
|
969
|
+
props = json_schema.get('properties')
|
|
970
|
+
if props:
|
|
971
|
+
for fname, prop in props.items():
|
|
972
|
+
if '$ref' in prop and 'description' not in prop:
|
|
973
|
+
finfo = cls.model_fields.get(fname)
|
|
974
|
+
if finfo and finfo.description:
|
|
975
|
+
prop['description'] = finfo.description
|
|
976
|
+
return json_schema
|
|
977
|
+
|
|
617
978
|
@classmethod
|
|
618
979
|
def get_computed_field_names(cls) -> set[str]:
|
|
619
980
|
"""Get the set of computed_field names for this model class."""
|
|
@@ -48,6 +48,9 @@ _NO_NULL_BYTE = StringConstraints(pattern=r'^[^\x00]*$')
|
|
|
48
48
|
"""PostgreSQL rejects null bytes in text columns. pydantic-core compiles the regex once with zero Python overhead."""
|
|
49
49
|
|
|
50
50
|
# String length constraints
|
|
51
|
+
Str16: TypeAlias = Annotated[str, Field(max_length=16), _NO_NULL_BYTE]
|
|
52
|
+
"""16-character string field (trigger words, short tokens)"""
|
|
53
|
+
|
|
51
54
|
Str24: TypeAlias = Annotated[str, Field(max_length=24), _NO_NULL_BYTE]
|
|
52
55
|
"""24-character string field"""
|
|
53
56
|
|
|
@@ -105,6 +108,9 @@ Text5K: TypeAlias = Annotated[str, Field(max_length=5000), _NO_NULL_BYTE]
|
|
|
105
108
|
Text10K: TypeAlias = Annotated[str, Field(max_length=10000), _NO_NULL_BYTE]
|
|
106
109
|
"""10000-character text field"""
|
|
107
110
|
|
|
111
|
+
Text32K: TypeAlias = Annotated[str, Field(max_length=32000), _NO_NULL_BYTE]
|
|
112
|
+
"""32000-character text field"""
|
|
113
|
+
|
|
108
114
|
Text60K: TypeAlias = Annotated[str, Field(max_length=60000), _NO_NULL_BYTE]
|
|
109
115
|
"""60000-character text field"""
|
|
110
116
|
|
|
@@ -127,17 +133,40 @@ Percentage: TypeAlias = Annotated[int, Field(ge=0, le=100)]
|
|
|
127
133
|
INT32_MAX = 2147483647
|
|
128
134
|
"""Maximum value for PostgreSQL INTEGER column (2^31-1)"""
|
|
129
135
|
|
|
136
|
+
INT64_MAX = 9223372036854775807
|
|
137
|
+
"""Maximum value for PostgreSQL BIGINT column (2^63-1)"""
|
|
138
|
+
|
|
139
|
+
JS_MAX_SAFE_INTEGER = 9007199254740991
|
|
140
|
+
"""JavaScript ``Number.MAX_SAFE_INTEGER`` (2^53-1).
|
|
141
|
+
|
|
142
|
+
Integers larger than this value lose precision when parsed by JavaScript
|
|
143
|
+
clients. Use this as the upper bound for BigInt fields that cross the API
|
|
144
|
+
boundary into a JSON body consumed by browsers.
|
|
145
|
+
"""
|
|
146
|
+
|
|
130
147
|
PositiveInt: TypeAlias = Annotated[int, Field(ge=1, le=INT32_MAX)]
|
|
131
148
|
"""Positive integer (1 to 2147483647, fits PostgreSQL INTEGER)"""
|
|
132
149
|
|
|
133
150
|
NonNegativeInt: TypeAlias = Annotated[int, Field(ge=0, le=INT32_MAX)]
|
|
134
151
|
"""Non-negative integer (0 to 2147483647, fits PostgreSQL INTEGER)"""
|
|
135
152
|
|
|
136
|
-
PositiveBigInt: TypeAlias = Annotated[int, Field(ge=1, sa_type=BigInteger)]
|
|
137
|
-
"""Positive big integer (
|
|
153
|
+
PositiveBigInt: TypeAlias = Annotated[int, Field(ge=1, le=JS_MAX_SAFE_INTEGER, sa_type=BigInteger)]
|
|
154
|
+
"""Positive big integer (1 to JS_MAX_SAFE_INTEGER, stored as PostgreSQL BIGINT).
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
|
|
156
|
+
The upper bound is JS_MAX_SAFE_INTEGER (2^53-1) rather than INT64_MAX so
|
|
157
|
+
values serialized to JSON remain exact in JavaScript clients. Raise the
|
|
158
|
+
bound to ``INT64_MAX`` explicitly in a custom alias if the field is never
|
|
159
|
+
consumed by a browser.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
NonNegativeBigInt: TypeAlias = Annotated[int, Field(ge=0, le=JS_MAX_SAFE_INTEGER, sa_type=BigInteger)]
|
|
163
|
+
"""Non-negative big integer (0 to JS_MAX_SAFE_INTEGER, stored as PostgreSQL BIGINT).
|
|
164
|
+
|
|
165
|
+
See :data:`PositiveBigInt` for the rationale behind the JS_MAX_SAFE_INTEGER bound.
|
|
166
|
+
"""
|
|
141
167
|
|
|
142
168
|
PositiveFloat: TypeAlias = Annotated[float, Field(gt=0.0)]
|
|
143
169
|
"""Positive float (>0)"""
|
|
170
|
+
|
|
171
|
+
NonNegativeFloat: TypeAlias = Annotated[float, Field(ge=0.0)]
|
|
172
|
+
"""Non-negative float (>=0)"""
|