sqlmodel-ext 0.2.0__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlmodel_ext/__init__.py +25 -2
- sqlmodel_ext/_sa_type.py +3 -2
- sqlmodel_ext/_utils.py +3 -3
- sqlmodel_ext/base.py +239 -69
- sqlmodel_ext/field_types/__init__.py +51 -22
- sqlmodel_ext/field_types/dialects/postgresql/array.py +64 -54
- sqlmodel_ext/mixins/__init__.py +7 -0
- sqlmodel_ext/mixins/cached_table.py +1707 -0
- sqlmodel_ext/mixins/polymorphic.py +205 -35
- sqlmodel_ext/mixins/relation_preload.py +85 -35
- sqlmodel_ext/mixins/table.py +416 -106
- sqlmodel_ext/relation_load_checker.py +2751 -0
- {sqlmodel_ext-0.2.0.dist-info → sqlmodel_ext-0.2.1.dist-info}/METADATA +4 -2
- {sqlmodel_ext-0.2.0.dist-info → sqlmodel_ext-0.2.1.dist-info}/RECORD +16 -14
- {sqlmodel_ext-0.2.0.dist-info → sqlmodel_ext-0.2.1.dist-info}/WHEEL +1 -1
- {sqlmodel_ext-0.2.0.dist-info → sqlmodel_ext-0.2.1.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py
CHANGED
|
@@ -20,10 +20,10 @@ Quick start::
|
|
|
20
20
|
user = await user.save(session)
|
|
21
21
|
users = await User.get(session, fetch_mode="all")
|
|
22
22
|
"""
|
|
23
|
-
__version__ = "0.2.
|
|
23
|
+
__version__ = "0.2.1"
|
|
24
24
|
|
|
25
25
|
# Base
|
|
26
|
-
from sqlmodel_ext.base import SQLModelBase
|
|
26
|
+
from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase
|
|
27
27
|
|
|
28
28
|
# Exceptions
|
|
29
29
|
from sqlmodel_ext._exceptions import RecordNotFoundError
|
|
@@ -39,8 +39,11 @@ from sqlmodel_ext.pagination import (
|
|
|
39
39
|
# Mixins
|
|
40
40
|
from sqlmodel_ext.mixins import (
|
|
41
41
|
# Table
|
|
42
|
+
SESSION_FOR_UPDATE_KEY,
|
|
42
43
|
TableBaseMixin,
|
|
43
44
|
UUIDTableBaseMixin,
|
|
45
|
+
rel,
|
|
46
|
+
cond,
|
|
44
47
|
# Polymorphic
|
|
45
48
|
PolymorphicBaseMixin,
|
|
46
49
|
AutoPolymorphicIdentityMixin,
|
|
@@ -53,6 +56,9 @@ from sqlmodel_ext.mixins import (
|
|
|
53
56
|
# Relation Preload
|
|
54
57
|
RelationPreloadMixin,
|
|
55
58
|
requires_relations,
|
|
59
|
+
requires_for_update,
|
|
60
|
+
# Cached Table
|
|
61
|
+
CachedTableBaseMixin,
|
|
56
62
|
# Info Response DTOs
|
|
57
63
|
IntIdInfoMixin,
|
|
58
64
|
UUIDIdInfoMixin,
|
|
@@ -76,20 +82,28 @@ from sqlmodel_ext.field_types import (
|
|
|
76
82
|
Str128,
|
|
77
83
|
Str255,
|
|
78
84
|
Str256,
|
|
85
|
+
Str500,
|
|
86
|
+
Str512,
|
|
87
|
+
Str2048,
|
|
79
88
|
Text1K,
|
|
80
89
|
Text1024,
|
|
81
90
|
Text2K,
|
|
82
91
|
Text2500,
|
|
83
92
|
Text3K,
|
|
93
|
+
Text5K,
|
|
84
94
|
Text10K,
|
|
85
95
|
Text60K,
|
|
86
96
|
Text64K,
|
|
87
97
|
Text100K,
|
|
98
|
+
Text1M,
|
|
88
99
|
# Numeric constraints
|
|
100
|
+
INT32_MAX,
|
|
89
101
|
Port,
|
|
90
102
|
Percentage,
|
|
91
103
|
PositiveInt,
|
|
92
104
|
NonNegativeInt,
|
|
105
|
+
PositiveBigInt,
|
|
106
|
+
NonNegativeBigInt,
|
|
93
107
|
PositiveFloat,
|
|
94
108
|
# Custom types
|
|
95
109
|
IPAddress,
|
|
@@ -101,3 +115,12 @@ from sqlmodel_ext.field_types import (
|
|
|
101
115
|
validate_not_private_host,
|
|
102
116
|
ModuleNameMixin,
|
|
103
117
|
)
|
|
118
|
+
|
|
119
|
+
# Relation Load Checker (static analysis)
|
|
120
|
+
from sqlmodel_ext.relation_load_checker import (
|
|
121
|
+
RelationLoadChecker,
|
|
122
|
+
RelationLoadWarning,
|
|
123
|
+
RelationLoadCheckMiddleware,
|
|
124
|
+
run_model_checks,
|
|
125
|
+
mark_app_check_completed,
|
|
126
|
+
)
|
sqlmodel_ext/_sa_type.py
CHANGED
|
@@ -97,7 +97,8 @@ def _resolve_annotations(attrs: dict[str, Any]) -> tuple[
|
|
|
97
97
|
|
|
98
98
|
try:
|
|
99
99
|
temp_cls = type('AnnotationProxy', (object,), dict(attrs))
|
|
100
|
-
|
|
100
|
+
if isinstance(module_name, str):
|
|
101
|
+
temp_cls.__module__ = module_name
|
|
101
102
|
extras_kw = {'include_extras': True} if sys.version_info >= (3, 10) else {}
|
|
102
103
|
evaluated = get_type_hints(
|
|
103
104
|
temp_cls,
|
|
@@ -132,6 +133,6 @@ def _evaluate_annotation_from_string(
|
|
|
132
133
|
return current_type
|
|
133
134
|
|
|
134
135
|
try:
|
|
135
|
-
return eval(expr, globalns, localns)
|
|
136
|
+
return eval(expr, dict(globalns), dict(localns))
|
|
136
137
|
except (NameError, SyntaxError, AttributeError, TypeError):
|
|
137
138
|
return current_type
|
sqlmodel_ext/_utils.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""Internal utility functions."""
|
|
2
|
-
from datetime import datetime
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
3
|
|
|
4
|
-
now = lambda: datetime.now()
|
|
5
|
-
now_date = lambda: datetime.now().date()
|
|
4
|
+
now = lambda: datetime.now(timezone.utc)
|
|
5
|
+
now_date = lambda: datetime.now(timezone.utc).date()
|
sqlmodel_ext/base.py
CHANGED
|
@@ -9,11 +9,13 @@ 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 logging
|
|
13
|
+
import re
|
|
12
14
|
import sys
|
|
13
15
|
import typing
|
|
14
16
|
from typing import Any, get_args, get_origin
|
|
15
17
|
|
|
16
|
-
from pydantic import ConfigDict
|
|
18
|
+
from pydantic import ConfigDict, model_validator
|
|
17
19
|
from pydantic.fields import FieldInfo
|
|
18
20
|
from pydantic_core import PydanticUndefined as Undefined
|
|
19
21
|
from sqlalchemy import inspect as sa_inspect
|
|
@@ -36,6 +38,61 @@ if sys.version_info >= (3, 14):
|
|
|
36
38
|
else:
|
|
37
39
|
annotationlib = None
|
|
38
40
|
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _make_sti_fk_resolver(
|
|
45
|
+
fk_string: str,
|
|
46
|
+
sa_registry: typing.Any,
|
|
47
|
+
) -> typing.Callable:
|
|
48
|
+
"""
|
|
49
|
+
Convert string-format foreign_keys to a callable for deferred resolution in STI.
|
|
50
|
+
|
|
51
|
+
STI child columns are added to the parent table via _register_sti_columns(),
|
|
52
|
+
but during configure_mappers() they are not yet registered as ColumnProperty.
|
|
53
|
+
SQLAlchemy's string resolution (_GetColumns.__getattr__) looks up columns via
|
|
54
|
+
mapper.all_orm_descriptors, which fails for unregistered STI columns.
|
|
55
|
+
|
|
56
|
+
Solution: convert to callable so configure_mappers() calls it to resolve
|
|
57
|
+
Column objects directly from the table's columns collection (Phase 1 already added them).
|
|
58
|
+
|
|
59
|
+
:param fk_string: String-format foreign_keys, e.g. '[NanoBananaFunction.flash_llm_id]'
|
|
60
|
+
:param sa_registry: SQLAlchemy registry for class-name lookup
|
|
61
|
+
:return: callable returning list of Column objects
|
|
62
|
+
"""
|
|
63
|
+
inner = fk_string.strip('[]')
|
|
64
|
+
specs = [s.strip() for s in inner.split(',')]
|
|
65
|
+
|
|
66
|
+
parsed: list[tuple[str, str]] = []
|
|
67
|
+
for spec in specs:
|
|
68
|
+
m = re.match(r'^(\w+)\.(\w+)$', spec)
|
|
69
|
+
if not m:
|
|
70
|
+
return fk_string # type: ignore # cannot parse, return original
|
|
71
|
+
parsed.append((m.group(1), m.group(2)))
|
|
72
|
+
|
|
73
|
+
_registry = sa_registry
|
|
74
|
+
|
|
75
|
+
def _resolve() -> list:
|
|
76
|
+
columns = []
|
|
77
|
+
for cls_name, col_name in parsed:
|
|
78
|
+
for mapper in _registry.mappers:
|
|
79
|
+
if mapper.class_.__name__ == cls_name:
|
|
80
|
+
table = mapper.local_table
|
|
81
|
+
if col_name not in table.c:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
f"STI FK resolution failed: column '{col_name}' "
|
|
84
|
+
f"not in table '{table.name}' (class {cls_name})"
|
|
85
|
+
)
|
|
86
|
+
columns.append(table.c[col_name])
|
|
87
|
+
break
|
|
88
|
+
else:
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"STI FK resolution failed: class '{cls_name}' not in SA registry"
|
|
91
|
+
)
|
|
92
|
+
return columns
|
|
93
|
+
|
|
94
|
+
return _resolve
|
|
95
|
+
|
|
39
96
|
|
|
40
97
|
class __DeclarativeMeta(SQLModelMetaclass):
|
|
41
98
|
"""
|
|
@@ -61,6 +118,13 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
61
118
|
if is_intended_as_table and 'table' not in kwargs:
|
|
62
119
|
kwargs['table'] = True
|
|
63
120
|
|
|
121
|
+
# 1.5. CachedTableBaseMixin: cache_ttl class keyword -> __cache_ttl__ attribute
|
|
122
|
+
if 'cache_ttl' in kwargs:
|
|
123
|
+
ttl = kwargs.pop('cache_ttl')
|
|
124
|
+
if not isinstance(ttl, int) or ttl <= 0:
|
|
125
|
+
raise ValueError(f"{name}: cache_ttl must be a positive integer, got: {ttl!r}")
|
|
126
|
+
attrs['__cache_ttl__'] = ttl
|
|
127
|
+
|
|
64
128
|
# 2. Detect STI scenario and preprocess
|
|
65
129
|
parent_tablename = None
|
|
66
130
|
for base in bases:
|
|
@@ -248,9 +312,21 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
248
312
|
return False
|
|
249
313
|
|
|
250
314
|
if has_different_tablename and parent_tablename:
|
|
315
|
+
# JTI FK must also be primary_key (created by SubclassIdMixin).
|
|
316
|
+
# A FK pointing to the parent table that is NOT a PK (e.g. self-referential
|
|
317
|
+
# parent_transaction_id) should NOT be identified as JTI inheritance.
|
|
318
|
+
def _is_jti_fk(fi: typing.Any) -> bool:
|
|
319
|
+
fk = getattr(fi, 'foreign_key', None)
|
|
320
|
+
pk = getattr(fi, 'primary_key', None)
|
|
321
|
+
return (
|
|
322
|
+
fk is not None
|
|
323
|
+
and isinstance(fk, str)
|
|
324
|
+
and pk is True # PydanticUndefined is truthy, must compare strictly
|
|
325
|
+
and _fk_matches_parent(fk, parent_tablename)
|
|
326
|
+
)
|
|
327
|
+
|
|
251
328
|
for field_name, field_info in cls.model_fields.items():
|
|
252
|
-
|
|
253
|
-
if fk is not None and isinstance(fk, str) and _fk_matches_parent(fk, parent_tablename):
|
|
329
|
+
if _is_jti_fk(field_info):
|
|
254
330
|
has_fk_to_parent = True
|
|
255
331
|
break
|
|
256
332
|
|
|
@@ -258,8 +334,7 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
258
334
|
for base in bases:
|
|
259
335
|
if hasattr(base, 'model_fields'):
|
|
260
336
|
for field_name, field_info in base.model_fields.items():
|
|
261
|
-
|
|
262
|
-
if fk is not None and isinstance(fk, str) and _fk_matches_parent(fk, parent_tablename):
|
|
337
|
+
if _is_jti_fk(field_info):
|
|
263
338
|
has_fk_to_parent = True
|
|
264
339
|
break
|
|
265
340
|
if has_fk_to_parent:
|
|
@@ -367,73 +442,113 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
367
442
|
mapper_args = getattr(cls, '__mapper_args__', {})
|
|
368
443
|
polymorphic_identity = mapper_args.get('polymorphic_identity')
|
|
369
444
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
445
|
+
# Support both concrete classes (polymorphic_identity set) and
|
|
446
|
+
# abstract intermediate classes (polymorphic_identity=None, polymorphic_abstract=True)
|
|
447
|
+
parent_cls = None
|
|
448
|
+
for base in bases:
|
|
449
|
+
if is_table_model_class(base) and hasattr(base, '__mapper__'):
|
|
450
|
+
parent_cls = base
|
|
451
|
+
break
|
|
376
452
|
|
|
377
|
-
|
|
378
|
-
|
|
453
|
+
if parent_cls is not None:
|
|
454
|
+
registry = parent_cls._sa_registry
|
|
379
455
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
)
|
|
387
|
-
if not is_inherited:
|
|
388
|
-
own_rels[rel_name] = rel_info
|
|
389
|
-
|
|
390
|
-
properties = {}
|
|
391
|
-
if own_rels:
|
|
392
|
-
for rel_name, rel_info in own_rels.items():
|
|
393
|
-
if rel_info.sa_relationship:
|
|
394
|
-
properties[rel_name] = rel_info.sa_relationship
|
|
395
|
-
else:
|
|
396
|
-
raw_ann = cls.__annotations__.get(rel_name)
|
|
397
|
-
if raw_ann:
|
|
398
|
-
origin = get_origin(raw_ann)
|
|
399
|
-
if origin is Mapped:
|
|
400
|
-
ann = raw_ann.__args__[0]
|
|
401
|
-
else:
|
|
402
|
-
ann = raw_ann
|
|
403
|
-
relationship_to = get_relationship_to(
|
|
404
|
-
name=rel_name, rel_info=rel_info, annotation=ann
|
|
405
|
-
)
|
|
406
|
-
rel_kwargs: dict[str, typing.Any] = {}
|
|
407
|
-
if rel_info.back_populates:
|
|
408
|
-
rel_kwargs["back_populates"] = rel_info.back_populates
|
|
409
|
-
if rel_info.cascade_delete:
|
|
410
|
-
rel_kwargs["cascade"] = "all, delete-orphan"
|
|
411
|
-
if rel_info.passive_deletes:
|
|
412
|
-
rel_kwargs["passive_deletes"] = rel_info.passive_deletes
|
|
413
|
-
if rel_info.link_model:
|
|
414
|
-
ins = sa_inspect(rel_info.link_model)
|
|
415
|
-
local_table = getattr(ins, "local_table")
|
|
416
|
-
if local_table is None:
|
|
417
|
-
raise RuntimeError(
|
|
418
|
-
f"Could not find secondary table for {rel_name}: {rel_info.link_model}"
|
|
419
|
-
)
|
|
420
|
-
rel_kwargs["secondary"] = local_table
|
|
421
|
-
|
|
422
|
-
rel_args: list[typing.Any] = []
|
|
423
|
-
if rel_info.sa_relationship_args:
|
|
424
|
-
rel_args.extend(rel_info.sa_relationship_args)
|
|
425
|
-
if rel_info.sa_relationship_kwargs:
|
|
426
|
-
rel_kwargs.update(rel_info.sa_relationship_kwargs)
|
|
427
|
-
|
|
428
|
-
properties[rel_name] = sa_relationship(relationship_to, *rel_args, **rel_kwargs)
|
|
429
|
-
|
|
430
|
-
registry.map_imperatively(
|
|
431
|
-
cls,
|
|
432
|
-
parent_cls.__table__,
|
|
433
|
-
inherits=parent_cls,
|
|
434
|
-
polymorphic_identity=polymorphic_identity,
|
|
435
|
-
properties=properties if properties else None,
|
|
456
|
+
rels = getattr(cls, '__sqlmodel_relationships__', {})
|
|
457
|
+
own_rels = {}
|
|
458
|
+
for rel_name, rel_info in rels.items():
|
|
459
|
+
is_inherited = any(
|
|
460
|
+
hasattr(base, '__sqlmodel_relationships__') and rel_name in base.__sqlmodel_relationships__
|
|
461
|
+
for base in bases
|
|
436
462
|
)
|
|
463
|
+
if not is_inherited:
|
|
464
|
+
own_rels[rel_name] = rel_info
|
|
465
|
+
|
|
466
|
+
properties = {}
|
|
467
|
+
if own_rels:
|
|
468
|
+
for rel_name, rel_info in own_rels.items():
|
|
469
|
+
if rel_info.sa_relationship:
|
|
470
|
+
properties[rel_name] = rel_info.sa_relationship
|
|
471
|
+
else:
|
|
472
|
+
raw_ann = cls.__annotations__.get(rel_name)
|
|
473
|
+
if raw_ann:
|
|
474
|
+
origin = get_origin(raw_ann)
|
|
475
|
+
if origin is Mapped:
|
|
476
|
+
ann = raw_ann.__args__[0]
|
|
477
|
+
else:
|
|
478
|
+
ann = raw_ann
|
|
479
|
+
relationship_to = get_relationship_to(
|
|
480
|
+
name=rel_name, rel_info=rel_info, annotation=ann
|
|
481
|
+
)
|
|
482
|
+
rel_kwargs: dict[str, typing.Any] = {}
|
|
483
|
+
if rel_info.back_populates:
|
|
484
|
+
rel_kwargs["back_populates"] = rel_info.back_populates
|
|
485
|
+
if rel_info.cascade_delete:
|
|
486
|
+
rel_kwargs["cascade"] = "all, delete-orphan"
|
|
487
|
+
if rel_info.passive_deletes:
|
|
488
|
+
rel_kwargs["passive_deletes"] = rel_info.passive_deletes
|
|
489
|
+
if rel_info.link_model:
|
|
490
|
+
ins = sa_inspect(rel_info.link_model)
|
|
491
|
+
local_table = getattr(ins, "local_table")
|
|
492
|
+
if local_table is None:
|
|
493
|
+
raise RuntimeError(
|
|
494
|
+
f"Could not find secondary table for {rel_name}: {rel_info.link_model}"
|
|
495
|
+
)
|
|
496
|
+
rel_kwargs["secondary"] = local_table
|
|
497
|
+
|
|
498
|
+
rel_args: list[typing.Any] = []
|
|
499
|
+
if rel_info.sa_relationship_args:
|
|
500
|
+
rel_args.extend(rel_info.sa_relationship_args)
|
|
501
|
+
if rel_info.sa_relationship_kwargs:
|
|
502
|
+
rel_kwargs.update(rel_info.sa_relationship_kwargs)
|
|
503
|
+
|
|
504
|
+
# Default lazy='raise_on_sql' for async safety:
|
|
505
|
+
# prevents accidental lazy-loading which causes
|
|
506
|
+
# MissingGreenlet errors in async environments.
|
|
507
|
+
if 'lazy' not in rel_kwargs:
|
|
508
|
+
rel_kwargs['lazy'] = 'raise_on_sql'
|
|
509
|
+
|
|
510
|
+
# STI foreign_keys deferred resolution:
|
|
511
|
+
# STI child columns are not yet registered as ColumnProperty
|
|
512
|
+
# during configure_mappers(), so string foreign_keys fail.
|
|
513
|
+
# Convert to callable for lazy resolution from table columns.
|
|
514
|
+
if 'foreign_keys' in rel_kwargs:
|
|
515
|
+
_fk_val = rel_kwargs['foreign_keys']
|
|
516
|
+
if isinstance(_fk_val, str):
|
|
517
|
+
rel_kwargs['foreign_keys'] = _make_sti_fk_resolver(
|
|
518
|
+
_fk_val, registry
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
# Auto-detect FK ambiguity: when the "many" side STI child
|
|
522
|
+
# has a {rel_name}_id FK field but foreign_keys is not
|
|
523
|
+
# explicitly specified, add a callable to disambiguate.
|
|
524
|
+
_fk_field = f'{rel_name}_id'
|
|
525
|
+
_model_fields = getattr(cls, 'model_fields', None) or {}
|
|
526
|
+
if _fk_field in _model_fields:
|
|
527
|
+
_tbl = parent_cls.__table__
|
|
528
|
+
_fn = _fk_field
|
|
529
|
+
rel_kwargs['foreign_keys'] = (
|
|
530
|
+
lambda _t=_tbl, _f=_fn: [_t.c[_f]]
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
properties[rel_name] = sa_relationship(relationship_to, *rel_args, **rel_kwargs)
|
|
534
|
+
|
|
535
|
+
# Build map_imperatively kwargs conditionally
|
|
536
|
+
map_kwargs: dict[str, typing.Any] = {
|
|
537
|
+
'inherits': parent_cls,
|
|
538
|
+
'properties': properties if properties else None,
|
|
539
|
+
}
|
|
540
|
+
if polymorphic_identity is not None:
|
|
541
|
+
map_kwargs['polymorphic_identity'] = polymorphic_identity
|
|
542
|
+
# Abstract intermediate classes (e.g. TencentCompatibleLLM)
|
|
543
|
+
# need polymorphic_abstract=True forwarded to map_imperatively
|
|
544
|
+
if mapper_args.get('polymorphic_abstract'):
|
|
545
|
+
map_kwargs['polymorphic_abstract'] = True
|
|
546
|
+
|
|
547
|
+
registry.map_imperatively(
|
|
548
|
+
cls,
|
|
549
|
+
parent_cls.__table__,
|
|
550
|
+
**map_kwargs,
|
|
551
|
+
)
|
|
437
552
|
|
|
438
553
|
def _setup_relationships(cls, only_these: set[str] | None = None) -> None:
|
|
439
554
|
"""
|
|
@@ -481,6 +596,11 @@ class __DeclarativeMeta(SQLModelMetaclass):
|
|
|
481
596
|
if rel_info.sa_relationship_kwargs:
|
|
482
597
|
rel_kwargs.update(rel_info.sa_relationship_kwargs)
|
|
483
598
|
|
|
599
|
+
# Default lazy='raise_on_sql' for async safety: prevents accidental
|
|
600
|
+
# lazy-loading which causes MissingGreenlet errors in async environments.
|
|
601
|
+
if 'lazy' not in rel_kwargs:
|
|
602
|
+
rel_kwargs['lazy'] = 'raise_on_sql'
|
|
603
|
+
|
|
484
604
|
rel_value = sa_relationship(relationship_to, *rel_args, **rel_kwargs)
|
|
485
605
|
setattr(cls, rel_name, rel_value)
|
|
486
606
|
|
|
@@ -499,3 +619,53 @@ class SQLModelBase(SQLModel, metaclass=__DeclarativeMeta):
|
|
|
499
619
|
"""Get the set of computed_field names for this model class."""
|
|
500
620
|
fields = cls.model_computed_fields
|
|
501
621
|
return set(fields.keys()) if fields else set()
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class ExtraIgnoreModelBase(SQLModelBase):
|
|
625
|
+
"""
|
|
626
|
+
Model base class that ignores unknown fields (extra='ignore').
|
|
627
|
+
|
|
628
|
+
Unlike SQLModelBase (extra='forbid'), this class silently ignores undeclared
|
|
629
|
+
fields and logs a WARNING for discoverability.
|
|
630
|
+
|
|
631
|
+
Use for:
|
|
632
|
+
- Third-party API responses (where the schema may change without notice)
|
|
633
|
+
- Client WebSocket message envelopes (protocol-level field validation)
|
|
634
|
+
- Any model parsing external JSON input (including nested sub-models)
|
|
635
|
+
|
|
636
|
+
Do NOT use for: request models that we construct and send to external services
|
|
637
|
+
(those should keep 'forbid' to catch mistakes).
|
|
638
|
+
"""
|
|
639
|
+
|
|
640
|
+
model_config = ConfigDict(
|
|
641
|
+
use_attribute_docstrings=True, validate_by_name=True, extra='ignore',
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
@model_validator(mode='before')
|
|
645
|
+
@classmethod
|
|
646
|
+
def _warn_unknown_fields(cls, data: Any) -> Any:
|
|
647
|
+
"""
|
|
648
|
+
Detect and warn about unknown fields in incoming data.
|
|
649
|
+
|
|
650
|
+
Logs a WARNING before Pydantic's extra='ignore' discards unknown fields,
|
|
651
|
+
helping developers notice third-party API changes and add field definitions.
|
|
652
|
+
"""
|
|
653
|
+
if not isinstance(data, dict):
|
|
654
|
+
return data
|
|
655
|
+
accepted: set[str] = set()
|
|
656
|
+
for name, field_info in cls.model_fields.items():
|
|
657
|
+
accepted.add(name)
|
|
658
|
+
if field_info.alias:
|
|
659
|
+
accepted.add(field_info.alias)
|
|
660
|
+
if field_info.validation_alias and isinstance(field_info.validation_alias, str):
|
|
661
|
+
accepted.add(field_info.validation_alias)
|
|
662
|
+
unknown = set(data.keys()) - accepted
|
|
663
|
+
if unknown:
|
|
664
|
+
total = len(unknown)
|
|
665
|
+
sample = [name[:64] for name in sorted(unknown)[:5]]
|
|
666
|
+
logger.warning(
|
|
667
|
+
"External input contains unknown fields | model=%s "
|
|
668
|
+
"unknown_count=%d sample_fields=%s",
|
|
669
|
+
cls.__name__, total, sample,
|
|
670
|
+
)
|
|
671
|
+
return data
|
|
@@ -7,6 +7,8 @@ all compatible with Pydantic validation and SQLAlchemy column mapping.
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Annotated, TypeAlias
|
|
9
9
|
|
|
10
|
+
from pydantic import StringConstraints
|
|
11
|
+
from sqlalchemy import BigInteger
|
|
10
12
|
from sqlmodel import Field
|
|
11
13
|
|
|
12
14
|
from ._internal.path import _DirectoryPathHandler, _FilePathHandler
|
|
@@ -42,61 +44,79 @@ while behaving as a ``pathlib.Path`` in Python code.
|
|
|
42
44
|
# Field Constraint Type Aliases (Annotated Style)
|
|
43
45
|
# ---------------------------------------------------------------------------
|
|
44
46
|
|
|
47
|
+
_NO_NULL_BYTE = StringConstraints(pattern=r'^[^\x00]*$')
|
|
48
|
+
"""PostgreSQL rejects null bytes in text columns. pydantic-core compiles the regex once with zero Python overhead."""
|
|
49
|
+
|
|
45
50
|
# String length constraints
|
|
46
|
-
Str24: TypeAlias = Annotated[str, Field(max_length=24)]
|
|
51
|
+
Str24: TypeAlias = Annotated[str, Field(max_length=24), _NO_NULL_BYTE]
|
|
47
52
|
"""24-character string field"""
|
|
48
53
|
|
|
49
|
-
Str32: TypeAlias = Annotated[str, Field(max_length=32)]
|
|
54
|
+
Str32: TypeAlias = Annotated[str, Field(max_length=32), _NO_NULL_BYTE]
|
|
50
55
|
"""32-character string field"""
|
|
51
56
|
|
|
52
|
-
Str36: TypeAlias = Annotated[str, Field(max_length=36)]
|
|
57
|
+
Str36: TypeAlias = Annotated[str, Field(max_length=36), _NO_NULL_BYTE]
|
|
53
58
|
"""36-character string field (UUID standard format length)"""
|
|
54
59
|
|
|
55
|
-
Str48: TypeAlias = Annotated[str, Field(max_length=48)]
|
|
60
|
+
Str48: TypeAlias = Annotated[str, Field(max_length=48), _NO_NULL_BYTE]
|
|
56
61
|
"""48-character string field"""
|
|
57
62
|
|
|
58
|
-
Str64: TypeAlias = Annotated[str, Field(max_length=64)]
|
|
63
|
+
Str64: TypeAlias = Annotated[str, Field(max_length=64), _NO_NULL_BYTE]
|
|
59
64
|
"""64-character string field"""
|
|
60
65
|
|
|
61
|
-
Str100: TypeAlias = Annotated[str, Field(max_length=100)]
|
|
66
|
+
Str100: TypeAlias = Annotated[str, Field(max_length=100), _NO_NULL_BYTE]
|
|
62
67
|
"""100-character string field"""
|
|
63
68
|
|
|
64
|
-
Str128: TypeAlias = Annotated[str, Field(max_length=128)]
|
|
69
|
+
Str128: TypeAlias = Annotated[str, Field(max_length=128), _NO_NULL_BYTE]
|
|
65
70
|
"""128-character string field"""
|
|
66
71
|
|
|
67
|
-
Str255: TypeAlias = Annotated[str, Field(max_length=255)]
|
|
72
|
+
Str255: TypeAlias = Annotated[str, Field(max_length=255), _NO_NULL_BYTE]
|
|
68
73
|
"""255-character string field"""
|
|
69
74
|
|
|
70
|
-
Str256: TypeAlias = Annotated[str, Field(max_length=256)]
|
|
75
|
+
Str256: TypeAlias = Annotated[str, Field(max_length=256), _NO_NULL_BYTE]
|
|
71
76
|
"""256-character string field"""
|
|
72
77
|
|
|
73
|
-
|
|
78
|
+
Str500: TypeAlias = Annotated[str, Field(max_length=500), _NO_NULL_BYTE]
|
|
79
|
+
"""500-character string field"""
|
|
80
|
+
|
|
81
|
+
Str512: TypeAlias = Annotated[str, Field(max_length=512), _NO_NULL_BYTE]
|
|
82
|
+
"""512-character string field"""
|
|
83
|
+
|
|
84
|
+
Str2048: TypeAlias = Annotated[str, Field(max_length=2048), _NO_NULL_BYTE]
|
|
85
|
+
"""2048-character string field (URLs etc.)"""
|
|
86
|
+
|
|
87
|
+
Text1K: TypeAlias = Annotated[str, Field(max_length=1000), _NO_NULL_BYTE]
|
|
74
88
|
"""1000-character text field"""
|
|
75
89
|
|
|
76
|
-
Text1024: TypeAlias = Annotated[str, Field(max_length=1024)]
|
|
90
|
+
Text1024: TypeAlias = Annotated[str, Field(max_length=1024), _NO_NULL_BYTE]
|
|
77
91
|
"""1024-character text field"""
|
|
78
92
|
|
|
79
|
-
Text2K: TypeAlias = Annotated[str, Field(max_length=2000)]
|
|
93
|
+
Text2K: TypeAlias = Annotated[str, Field(max_length=2000), _NO_NULL_BYTE]
|
|
80
94
|
"""2000-character text field"""
|
|
81
95
|
|
|
82
|
-
Text2500: TypeAlias = Annotated[str, Field(max_length=2500)]
|
|
96
|
+
Text2500: TypeAlias = Annotated[str, Field(max_length=2500), _NO_NULL_BYTE]
|
|
83
97
|
"""2500-character text field"""
|
|
84
98
|
|
|
85
|
-
Text3K: TypeAlias = Annotated[str, Field(max_length=3000)]
|
|
99
|
+
Text3K: TypeAlias = Annotated[str, Field(max_length=3000), _NO_NULL_BYTE]
|
|
86
100
|
"""3000-character text field"""
|
|
87
101
|
|
|
88
|
-
|
|
102
|
+
Text5K: TypeAlias = Annotated[str, Field(max_length=5000), _NO_NULL_BYTE]
|
|
103
|
+
"""5000-character text field"""
|
|
104
|
+
|
|
105
|
+
Text10K: TypeAlias = Annotated[str, Field(max_length=10000), _NO_NULL_BYTE]
|
|
89
106
|
"""10000-character text field"""
|
|
90
107
|
|
|
91
|
-
Text60K: TypeAlias = Annotated[str, Field(max_length=60000)]
|
|
108
|
+
Text60K: TypeAlias = Annotated[str, Field(max_length=60000), _NO_NULL_BYTE]
|
|
92
109
|
"""60000-character text field"""
|
|
93
110
|
|
|
94
|
-
Text64K: TypeAlias = Annotated[str, Field(max_length=65536)]
|
|
111
|
+
Text64K: TypeAlias = Annotated[str, Field(max_length=65536), _NO_NULL_BYTE]
|
|
95
112
|
"""65536-character text field"""
|
|
96
113
|
|
|
97
|
-
Text100K: TypeAlias = Annotated[str, Field(max_length=100000)]
|
|
114
|
+
Text100K: TypeAlias = Annotated[str, Field(max_length=100000), _NO_NULL_BYTE]
|
|
98
115
|
"""100000-character text field"""
|
|
99
116
|
|
|
117
|
+
Text1M: TypeAlias = Annotated[str, Field(max_length=1000000), _NO_NULL_BYTE]
|
|
118
|
+
"""1000000-character text field (tool call parameters, tool responses, etc.)"""
|
|
119
|
+
|
|
100
120
|
# Numeric range constraints
|
|
101
121
|
Port: TypeAlias = Annotated[int, Field(ge=1, le=65535)]
|
|
102
122
|
"""Port number (1-65535)"""
|
|
@@ -104,11 +124,20 @@ Port: TypeAlias = Annotated[int, Field(ge=1, le=65535)]
|
|
|
104
124
|
Percentage: TypeAlias = Annotated[int, Field(ge=0, le=100)]
|
|
105
125
|
"""Percentage (0-100)"""
|
|
106
126
|
|
|
107
|
-
|
|
108
|
-
"""
|
|
127
|
+
INT32_MAX = 2147483647
|
|
128
|
+
"""Maximum value for PostgreSQL INTEGER column (2^31-1)"""
|
|
129
|
+
|
|
130
|
+
PositiveInt: TypeAlias = Annotated[int, Field(ge=1, le=INT32_MAX)]
|
|
131
|
+
"""Positive integer (1 to 2147483647, fits PostgreSQL INTEGER)"""
|
|
132
|
+
|
|
133
|
+
NonNegativeInt: TypeAlias = Annotated[int, Field(ge=0, le=INT32_MAX)]
|
|
134
|
+
"""Non-negative integer (0 to 2147483647, fits PostgreSQL INTEGER)"""
|
|
135
|
+
|
|
136
|
+
PositiveBigInt: TypeAlias = Annotated[int, Field(ge=1, sa_type=BigInteger)]
|
|
137
|
+
"""Positive big integer (>=1, BigInteger storage)"""
|
|
109
138
|
|
|
110
|
-
|
|
111
|
-
"""Non-negative integer (>=0)"""
|
|
139
|
+
NonNegativeBigInt: TypeAlias = Annotated[int, Field(ge=0, sa_type=BigInteger)]
|
|
140
|
+
"""Non-negative big integer (>=0, BigInteger storage)"""
|
|
112
141
|
|
|
113
142
|
PositiveFloat: TypeAlias = Annotated[float, Field(gt=0.0)]
|
|
114
143
|
"""Positive float (>0)"""
|