sqlmodel-ext 0.2.1__py3-none-any.whl → 0.3.0__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
@@ -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
@@ -65,7 +65,25 @@ if sys.version_info >= (3, 14):
65
65
  non_none_args = [a for a in args if a is not type(None)]
66
66
  if non_none_args:
67
67
  first_arg = non_none_args[0]
68
+ # Check for Annotated SA type metadata first (e.g. Array[T] | None).
69
+ # Array[T] desugars to Annotated[list[T], _ArrayTypeHandler(T)];
70
+ # wrapping with | None produces Union[Annotated[...], None],
71
+ # so the SA type must be extracted from the inner Annotated.
72
+ first_arg_type_name = type(first_arg).__name__
73
+ if first_arg_type_name in ('AnnotatedAlias', '_AnnotatedAlias'):
74
+ inner_args = get_args(first_arg)
75
+ for md in inner_args[1:]:
76
+ if hasattr(md, '__get_pydantic_core_schema__'):
77
+ try:
78
+ schema = md.__get_pydantic_core_schema__(None, None)
79
+ if isinstance(schema, dict) and 'metadata' in schema:
80
+ sa_type = schema['metadata'].get('sa_type')
81
+ if sa_type is not None:
82
+ return sa_type
83
+ except (TypeError, AttributeError, KeyError):
84
+ pass
68
85
  first_origin = get_origin(first_arg)
86
+ # Plain list/dict/tuple/set (no Annotated SA type) -> JSONB
69
87
  if first_origin in (list, dict, tuple, set):
70
88
  return JSONB
71
89
 
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 SQLModelMetaclass, is_table_model_class, get_relationship_to
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 not hasattr(field_value, 'sa_type') or field_value.sa_type is Undefined:
211
- field_value.sa_type = sa_type
212
-
213
- # 5. Call parent __new__
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
- # 6. Fix: inherit parent's __sqlmodel_relationships__ for JTI
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
- # 7. Forbid redefining parent's Relationship fields
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
- # 8. Fix: remove Relationship fields from model_fields/__pydantic_fields__
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
- fields_removed = True
584
+ needs_rebuild = True
249
585
  if rel_name in pydantic_fields:
250
586
  del pydantic_fields[rel_name]
251
- fields_removed = True
587
+ needs_rebuild = True
252
588
 
253
- if fields_removed and hasattr(result, 'model_rebuild'):
254
- result.model_rebuild(force=True)
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 (>=1, BigInteger storage)"""
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
- NonNegativeBigInt: TypeAlias = Annotated[int, Field(ge=0, sa_type=BigInteger)]
140
- """Non-negative big integer (>=0, BigInteger storage)"""
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)"""