sqliter-py 0.9.0__py3-none-any.whl → 0.16.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.
Files changed (44) hide show
  1. sqliter/constants.py +4 -3
  2. sqliter/exceptions.py +43 -0
  3. sqliter/model/__init__.py +38 -3
  4. sqliter/model/foreign_key.py +153 -0
  5. sqliter/model/model.py +42 -3
  6. sqliter/model/unique.py +20 -11
  7. sqliter/orm/__init__.py +16 -0
  8. sqliter/orm/fields.py +412 -0
  9. sqliter/orm/foreign_key.py +8 -0
  10. sqliter/orm/model.py +243 -0
  11. sqliter/orm/query.py +221 -0
  12. sqliter/orm/registry.py +169 -0
  13. sqliter/query/query.py +720 -69
  14. sqliter/sqliter.py +533 -76
  15. sqliter/tui/__init__.py +62 -0
  16. sqliter/tui/__main__.py +6 -0
  17. sqliter/tui/app.py +179 -0
  18. sqliter/tui/demos/__init__.py +96 -0
  19. sqliter/tui/demos/base.py +114 -0
  20. sqliter/tui/demos/caching.py +283 -0
  21. sqliter/tui/demos/connection.py +150 -0
  22. sqliter/tui/demos/constraints.py +211 -0
  23. sqliter/tui/demos/crud.py +154 -0
  24. sqliter/tui/demos/errors.py +231 -0
  25. sqliter/tui/demos/field_selection.py +150 -0
  26. sqliter/tui/demos/filters.py +389 -0
  27. sqliter/tui/demos/models.py +248 -0
  28. sqliter/tui/demos/ordering.py +156 -0
  29. sqliter/tui/demos/orm.py +460 -0
  30. sqliter/tui/demos/results.py +241 -0
  31. sqliter/tui/demos/string_filters.py +210 -0
  32. sqliter/tui/demos/timestamps.py +126 -0
  33. sqliter/tui/demos/transactions.py +177 -0
  34. sqliter/tui/runner.py +116 -0
  35. sqliter/tui/styles/app.tcss +130 -0
  36. sqliter/tui/widgets/__init__.py +7 -0
  37. sqliter/tui/widgets/code_display.py +81 -0
  38. sqliter/tui/widgets/demo_list.py +65 -0
  39. sqliter/tui/widgets/output_display.py +92 -0
  40. {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +27 -11
  41. sqliter_py-0.16.0.dist-info/RECORD +47 -0
  42. {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
  43. sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
  44. sqliter_py-0.9.0.dist-info/RECORD +0 -14
sqliter/orm/fields.py ADDED
@@ -0,0 +1,412 @@
1
+ """Field descriptors for ORM relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import types
7
+ from typing import (
8
+ TYPE_CHECKING,
9
+ Any,
10
+ Generic,
11
+ Optional,
12
+ Protocol,
13
+ TypeVar,
14
+ Union,
15
+ cast,
16
+ get_args,
17
+ get_origin,
18
+ get_type_hints,
19
+ overload,
20
+ runtime_checkable,
21
+ )
22
+
23
+ from pydantic_core import core_schema
24
+
25
+ from sqliter.model.foreign_key import ForeignKeyInfo
26
+
27
+ if TYPE_CHECKING: # pragma: no cover
28
+ from pydantic import GetCoreSchemaHandler
29
+
30
+ from sqliter.model.foreign_key import FKAction
31
+ from sqliter.model.model import BaseDBModel
32
+ from sqliter.sqliter import SqliterDB
33
+
34
+ T = TypeVar("T")
35
+
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _split_top_level(text: str, sep: str) -> list[str]:
41
+ parts: list[str] = []
42
+ depth = 0
43
+ buf: list[str] = []
44
+ for ch in text:
45
+ if ch in "[(":
46
+ depth += 1
47
+ elif ch in "])":
48
+ depth -= 1
49
+ if ch == sep and depth == 0:
50
+ parts.append("".join(buf).strip())
51
+ buf = []
52
+ else:
53
+ buf.append(ch)
54
+ parts.append("".join(buf).strip())
55
+ return parts
56
+
57
+
58
+ def _annotation_is_nullable(raw: str) -> bool:
59
+ """Best-effort check for Optional or | None at top level."""
60
+ s = raw.replace("typing.", "").replace("sqliter.orm.fields.", "").strip()
61
+
62
+ if "[" not in s or "]" not in s:
63
+ return False
64
+
65
+ inner = s[s.find("[") + 1 : s.rfind("]")].strip()
66
+
67
+ if inner.startswith("Optional["):
68
+ return True
69
+
70
+ if "|" in inner and any(
71
+ part == "None" for part in _split_top_level(inner, "|")
72
+ ):
73
+ return True
74
+
75
+ if inner.startswith("Union[") and inner.endswith("]"):
76
+ union_inner = inner[len("Union[") : -1]
77
+ if any(part == "None" for part in _split_top_level(union_inner, ",")):
78
+ return True
79
+
80
+ return False
81
+
82
+
83
+ @runtime_checkable
84
+ class HasPK(Protocol):
85
+ """Protocol for objects that have a pk attribute."""
86
+
87
+ pk: Optional[int]
88
+
89
+
90
+ class LazyLoader(Generic[T]):
91
+ """Proxy object that lazy loads a related object when accessed.
92
+
93
+ When a FK field is accessed, returns a LazyLoader that queries the database
94
+ on first access and caches the result.
95
+
96
+ Note: This class is an implementation detail. For type checking purposes,
97
+ ForeignKey fields are typed as returning T (the type parameter), not
98
+ LazyLoader[T]. This follows the standard ORM pattern used by SQLAlchemy,
99
+ where the proxy is transparent to users. Use ForeignKey[Optional[Model]]
100
+ for nullable foreign keys.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ instance: object,
106
+ to_model: type[T],
107
+ fk_id: Optional[int],
108
+ db_context: Optional[SqliterDB],
109
+ ) -> None:
110
+ """Initialize lazy loader.
111
+
112
+ Args:
113
+ instance: The model instance with the FK
114
+ to_model: The related model class to load
115
+ fk_id: The foreign key ID value
116
+ db_context: Database connection for queries
117
+ """
118
+ self._instance = instance
119
+ self._to_model = to_model
120
+ self._fk_id = fk_id
121
+ self._db = db_context
122
+ self._cached: Optional[T] = None
123
+
124
+ @property
125
+ def db_context(self) -> object:
126
+ """Return the database context (for checking if loader is valid)."""
127
+ return self._db
128
+
129
+ def __getattr__(self, name: str) -> object:
130
+ """Load related object and delegate attribute access."""
131
+ if self._cached is None:
132
+ self._load()
133
+ if self._cached is None:
134
+ msg = (
135
+ f"Cannot access {name} on None (FK is null or object not found)"
136
+ )
137
+ raise AttributeError(msg)
138
+ return getattr(self._cached, name)
139
+
140
+ def _load(self) -> None:
141
+ """Load related object from database if not already cached."""
142
+ if self._fk_id is None:
143
+ self._cached = None
144
+ return
145
+
146
+ if self._cached is None and self._db is not None:
147
+ # Use db_context to fetch the related object
148
+ # Catch DB errors (missing table, connection issues, etc.)
149
+ # and treat as "not found" - AttributeError will be raised
150
+ # by __getattr__ when accessing attributes on None
151
+ from sqliter.exceptions import SqliterError # noqa: PLC0415
152
+
153
+ try:
154
+ # Cast to type[BaseDBModel] for SqliterDB.get() - T is always
155
+ # a BaseDBModel subclass in practice
156
+ result = self._db.get(
157
+ cast("type[BaseDBModel]", self._to_model), self._fk_id
158
+ )
159
+ self._cached = cast("Optional[T]", result)
160
+ except SqliterError as e:
161
+ # DB errors (missing table, fetch errors) → treat as not found
162
+ logger.debug(
163
+ "LazyLoader failed to fetch %s with pk=%s: %s",
164
+ self._to_model.__name__,
165
+ self._fk_id,
166
+ e,
167
+ )
168
+ self._cached = None
169
+
170
+ def __repr__(self) -> str:
171
+ """Representation showing lazy state."""
172
+ if self._cached is None:
173
+ return (
174
+ f"<LazyLoader unloaded for {self._to_model.__name__} "
175
+ f"id={self._fk_id}>"
176
+ )
177
+ return f"<LazyLoader loaded: {self._cached!r}>"
178
+
179
+ def __eq__(self, other: object) -> bool:
180
+ """Compare based on loaded object."""
181
+ if self._cached is None:
182
+ self._load()
183
+ if self._cached is None:
184
+ return other is None
185
+ return self._cached == other
186
+
187
+ # Unhashable due to mutable equality (based on cached object)
188
+ __hash__ = None # type: ignore[assignment]
189
+
190
+
191
+ class ForeignKey(Generic[T]):
192
+ """Generic descriptor for FK fields providing lazy loading.
193
+
194
+ When a FK field is accessed on a model instance, returns a LazyLoader
195
+ that queries the database for the related object.
196
+
197
+ Usage:
198
+ class Book(BaseDBModel):
199
+ title: str
200
+ author: ForeignKey[Author] = ForeignKey(Author, on_delete="CASCADE")
201
+
202
+ The generic parameter T represents the related model type, ensuring
203
+ proper type checking when accessing the relationship.
204
+ """
205
+
206
+ def __init__( # noqa: PLR0913
207
+ self,
208
+ to_model: type[T],
209
+ *,
210
+ on_delete: FKAction = "RESTRICT",
211
+ on_update: FKAction = "RESTRICT",
212
+ null: bool = False,
213
+ unique: bool = False,
214
+ related_name: Optional[str] = None,
215
+ db_column: Optional[str] = None,
216
+ ) -> None:
217
+ """Initialize FK descriptor.
218
+
219
+ Args:
220
+ to_model: The related model class
221
+ on_delete: Action when related object is deleted
222
+ on_update: Action when related object's PK is updated
223
+ null: Whether FK can be null
224
+ unique: Whether FK must be unique
225
+ related_name: Name for reverse relationship (auto-generated if None)
226
+ db_column: Custom column name for _id field
227
+ """
228
+ self.to_model = to_model
229
+ self.fk_info = ForeignKeyInfo(
230
+ to_model=cast("type[BaseDBModel]", to_model),
231
+ on_delete=on_delete,
232
+ on_update=on_update,
233
+ null=null,
234
+ unique=unique,
235
+ related_name=related_name,
236
+ # Let _setup_orm_fields set default from actual field name
237
+ db_column=db_column,
238
+ )
239
+ self.related_name = related_name
240
+ self.name: Optional[str] = None # Set by __set_name__
241
+ self.owner: Optional[type] = None # Set by __set_name__
242
+
243
+ @classmethod
244
+ def __get_pydantic_core_schema__(
245
+ cls,
246
+ source_type: type[Any],
247
+ handler: GetCoreSchemaHandler,
248
+ ) -> core_schema.CoreSchema:
249
+ """Tell Pydantic how to handle ForeignKey[T] type annotations.
250
+
251
+ Uses no_info_plain_validator_function to prevent the descriptor from
252
+ being stored in instance __dict__, which would break the descriptor
253
+ protocol. The ForeignKey descriptor must remain at class level only.
254
+ """
255
+ # Return a validator that doesn't store anything in __dict__
256
+ # This prevents Pydantic from copying the descriptor to instances
257
+ return core_schema.no_info_plain_validator_function(
258
+ function=lambda _: None # Value is ignored
259
+ )
260
+
261
+ def _detect_nullable_from_annotation(self, owner: type, name: str) -> None:
262
+ """Detect if FK is nullable from type annotation.
263
+
264
+ If the annotation is ForeignKey[Optional[T]], automatically set
265
+ null=True on the FK info. This allows users to declare nullability
266
+ via the type annotation alone.
267
+ """
268
+ try:
269
+ hints = get_type_hints(owner)
270
+ except Exception: # noqa: BLE001
271
+ # Can fail with forward refs, NameError, etc. - fallback to raw
272
+ raw = owner.__annotations__.get(name)
273
+ if isinstance(raw, str) and _annotation_is_nullable(raw):
274
+ self.fk_info.null = True
275
+ return
276
+
277
+ if name not in hints:
278
+ return
279
+
280
+ annotation = hints[name] # e.g., ForeignKey[Optional[Author]]
281
+ fk_args = get_args(annotation) # e.g., (Optional[Author],)
282
+
283
+ if not fk_args:
284
+ return
285
+
286
+ inner_type = fk_args[0] # e.g., Optional[Author] or Author
287
+
288
+ # Check if inner_type is Optional (Union with None)
289
+ # Handle both typing.Union (Optional[T]) and types.UnionType
290
+ # (T | None on Python 3.10+)
291
+ origin = get_origin(inner_type)
292
+ is_union = origin is Union
293
+ if not is_union and hasattr(types, "UnionType"):
294
+ is_union = isinstance(inner_type, types.UnionType)
295
+ if is_union:
296
+ args = get_args(inner_type)
297
+ if type(None) in args:
298
+ self.fk_info.null = True
299
+
300
+ def __set_name__(self, owner: type, name: str) -> None:
301
+ """Called automatically during class creation.
302
+
303
+ Sets up reverse relationship on the related model immediately.
304
+ If related model doesn't exist yet, stores as pending in ModelRegistry.
305
+
306
+ If no `related_name` is provided, one is auto-generated by pluralizing
307
+ the owner class name. If the `inflect` library is installed, it provides
308
+ grammatically correct pluralization (e.g., "Person" becomes "people").
309
+ Otherwise, a simple "s" suffix is added.
310
+
311
+ Auto-detects nullable FKs from the type annotation: if the type is
312
+ ForeignKey[Optional[T]], sets null=True automatically.
313
+ """
314
+ self.name = name
315
+ self.owner = owner
316
+
317
+ # Auto-detect nullable from type annotation
318
+ # If user writes ForeignKey[Optional[Model]], set null=True
319
+ self._detect_nullable_from_annotation(owner, name)
320
+
321
+ # Store descriptor in class's OWN fk_descriptors dict (not inherited)
322
+ # Check __dict__ to avoid getting inherited dict from parent class
323
+ if "fk_descriptors" not in owner.__dict__:
324
+ owner.fk_descriptors = {} # type: ignore[attr-defined]
325
+ owner.fk_descriptors[name] = self # type: ignore[attr-defined]
326
+
327
+ # Auto-generate related_name if not provided
328
+ if self.related_name is None:
329
+ # Generate pluralized name from owner class name
330
+ base_name = owner.__name__.lower()
331
+ try:
332
+ import inflect # noqa: PLC0415
333
+
334
+ p = inflect.engine()
335
+ self.related_name = p.plural(base_name)
336
+ except ImportError:
337
+ # Fallback to simple pluralization by adding 's'
338
+ self.related_name = (
339
+ base_name if base_name.endswith("s") else base_name + "s"
340
+ )
341
+
342
+ # Set up reverse relationship on related model
343
+ from sqliter.orm.registry import ModelRegistry # noqa: PLC0415
344
+
345
+ ModelRegistry.add_reverse_relationship(
346
+ from_model=owner,
347
+ to_model=self.to_model,
348
+ fk_field=name,
349
+ related_name=self.related_name,
350
+ )
351
+
352
+ @overload
353
+ def __get__(self, instance: None, owner: type[object]) -> ForeignKey[T]: ...
354
+
355
+ @overload
356
+ def __get__(self, instance: object, owner: type[object]) -> T: ...
357
+
358
+ def __get__(
359
+ self, instance: Optional[object], owner: type[object]
360
+ ) -> Union[ForeignKey[T], T]:
361
+ """Return LazyLoader that loads related object on attribute access.
362
+
363
+ If accessed on class (not instance), return the descriptor itself.
364
+
365
+ Note: The return type is T (the type parameter). For nullable FKs,
366
+ use ForeignKey[Optional[Model]] and T will be Optional[Model].
367
+ The actual runtime return is a LazyLoader[T] proxy, but type checkers
368
+ see T for proper attribute access inference.
369
+ """
370
+ if instance is None:
371
+ return self
372
+
373
+ # Get FK ID from instance
374
+ fk_id = getattr(instance, f"{self.name}_id", None)
375
+
376
+ # Return LazyLoader for lazy loading
377
+ # Cast to T for type checking - LazyLoader is a transparent proxy
378
+ # that behaves like T. For nullable FKs, T is Optional[Model].
379
+ return cast(
380
+ "T",
381
+ LazyLoader(
382
+ instance=instance,
383
+ to_model=self.to_model,
384
+ fk_id=fk_id,
385
+ db_context=getattr(instance, "db_context", None),
386
+ ),
387
+ )
388
+
389
+ def __set__(self, instance: object, value: object) -> None:
390
+ """Set FK value - handles model instances, ints, or None.
391
+
392
+ Args:
393
+ instance: Model instance
394
+ value: New FK value (model instance, int ID, or None)
395
+ """
396
+ if value is None:
397
+ # Set to None
398
+ setattr(instance, f"{self.name}_id", None)
399
+ elif isinstance(value, int):
400
+ # Set ID directly
401
+ setattr(instance, f"{self.name}_id", value)
402
+ elif isinstance(value, HasPK):
403
+ # Duck typing via Protocol: extract pk from model instance
404
+ setattr(instance, f"{self.name}_id", value.pk)
405
+ else:
406
+ msg = f"FK value must be BaseModel, int, or None, got {type(value)}"
407
+ raise TypeError(msg)
408
+ # Note: FK cache is cleared by BaseDBModel.__setattr__ when _id changes
409
+
410
+
411
+ # Backwards compatibility alias
412
+ ForeignKeyDescriptor = ForeignKey
@@ -0,0 +1,8 @@
1
+ """ForeignKey for ORM mode.
2
+
3
+ Re-exports the ForeignKey class from fields module.
4
+ """
5
+
6
+ from sqliter.orm.fields import ForeignKey
7
+
8
+ __all__ = ["ForeignKey"]
sqliter/orm/model.py ADDED
@@ -0,0 +1,243 @@
1
+ """ORM model with lazy loading and reverse relationships."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar, Optional
6
+
7
+ from pydantic import Field
8
+
9
+ from sqliter.model.model import BaseDBModel as _BaseDBModel
10
+ from sqliter.orm.fields import ForeignKey, HasPK, LazyLoader
11
+ from sqliter.orm.registry import ModelRegistry
12
+
13
+ __all__ = ["BaseDBModel"]
14
+
15
+
16
+ class BaseDBModel(_BaseDBModel):
17
+ """Extends BaseDBModel with ORM features.
18
+
19
+ Adds:
20
+ - Lazy loading of foreign key relationships
21
+ - Automatic reverse relationship setup
22
+ - db_context for query execution
23
+ """
24
+
25
+ # Store FK descriptors per class (not inherited)
26
+ fk_descriptors: ClassVar[dict[str, ForeignKey[Any]]] = {}
27
+
28
+ # Database context for lazy loading and reverse queries
29
+ # Using Any since SqliterDB would cause circular import issues with Pydantic
30
+ db_context: Optional[Any] = Field(default=None, exclude=True)
31
+
32
+ def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
33
+ """Initialize model, converting FK fields to _id fields."""
34
+ # Convert FK field values to _id fields before validation
35
+ for fk_field in self.fk_descriptors:
36
+ if fk_field in kwargs:
37
+ value = kwargs[fk_field]
38
+ if isinstance(value, HasPK):
39
+ # Duck typing via Protocol: extract pk from model
40
+ kwargs[f"{fk_field}_id"] = value.pk
41
+ del kwargs[fk_field]
42
+ elif isinstance(value, int):
43
+ # Already an ID, just move to _id field
44
+ kwargs[f"{fk_field}_id"] = value
45
+ del kwargs[fk_field]
46
+ elif value is None:
47
+ # Keep None for nullable FKs
48
+ kwargs[f"{fk_field}_id"] = None
49
+ del kwargs[fk_field]
50
+
51
+ super().__init__(**kwargs)
52
+
53
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
54
+ """Dump model, excluding FK descriptor fields.
55
+
56
+ FK descriptor fields (like 'author') are excluded from serialization.
57
+ Only the _id fields (like 'author_id') are included.
58
+ """
59
+ data = super().model_dump(**kwargs)
60
+ # Remove FK descriptor fields from the dump
61
+ for fk_field in self.fk_descriptors:
62
+ data.pop(fk_field, None)
63
+ return data
64
+
65
+ def __getattribute__(self, name: str) -> object:
66
+ """Intercept FK field access to provide lazy loading."""
67
+ # Check if this is a FK field
68
+ if name in object.__getattribute__(self, "fk_descriptors"):
69
+ # Get FK ID
70
+ fk_id = object.__getattribute__(self, f"{name}_id")
71
+
72
+ # Null FK returns None directly (standard ORM behavior)
73
+ if fk_id is None:
74
+ return None
75
+
76
+ # Check instance cache for identity (same object on repeated access)
77
+ instance_dict = object.__getattribute__(self, "__dict__")
78
+ cache = instance_dict.setdefault("_fk_cache", {})
79
+ db_context = object.__getattribute__(self, "db_context")
80
+
81
+ # Check if we need to create or refresh the cached loader
82
+ cached_loader = cache.get(name)
83
+ needs_refresh = cached_loader is None or (
84
+ cached_loader.db_context is None and db_context is not None
85
+ )
86
+
87
+ if needs_refresh:
88
+ # Get the descriptor and create LazyLoader
89
+ fk_descs = object.__getattribute__(self, "fk_descriptors")
90
+ descriptor = fk_descs[name]
91
+ cache[name] = LazyLoader(
92
+ instance=self,
93
+ to_model=descriptor.to_model,
94
+ fk_id=fk_id,
95
+ db_context=db_context,
96
+ )
97
+ return cache[name]
98
+ # For non-FK fields, use normal attribute access
99
+ return object.__getattribute__(self, name)
100
+
101
+ def __setattr__(self, name: str, value: object) -> None:
102
+ """Intercept FK field assignment to convert to _id field."""
103
+ # Check if this is a FK field assignment
104
+ fk_descs = getattr(self, "fk_descriptors", {})
105
+ if name in fk_descs:
106
+ # Convert FK assignment to _id field assignment
107
+ # This bypasses Pydantic's validation for the FK field (which is
108
+ # not in model_fields) and uses the _id field instead
109
+ id_field_name = f"{name}_id"
110
+ if value is None:
111
+ setattr(self, id_field_name, None)
112
+ elif isinstance(value, int):
113
+ setattr(self, id_field_name, value)
114
+ elif isinstance(value, HasPK):
115
+ setattr(self, id_field_name, value.pk)
116
+ else:
117
+ msg = (
118
+ f"FK value must be BaseModel, int, or None, "
119
+ f"got {type(value)}"
120
+ )
121
+ raise TypeError(msg)
122
+ return
123
+
124
+ # If setting an _id field, clear corresponding FK cache
125
+ if name.endswith("_id"):
126
+ fk_name = name[:-3] # Remove "_id" suffix
127
+ if fk_name in fk_descs:
128
+ cache = self.__dict__.get("_fk_cache")
129
+ if cache and fk_name in cache:
130
+ del cache[fk_name]
131
+ super().__setattr__(name, value)
132
+
133
+ def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
134
+ """Set up ORM field annotations before Pydantic processes the class.
135
+
136
+ This runs BEFORE Pydantic populates model_fields, so we add the _id
137
+ field annotations here so Pydantic creates proper FieldInfo for them.
138
+ """
139
+ # Call parent __init_subclass__ FIRST
140
+ super().__init_subclass__(**kwargs)
141
+
142
+ # Collect FK descriptors from class dict
143
+ if "fk_descriptors" not in cls.__dict__:
144
+ cls.fk_descriptors = {}
145
+
146
+ # Find all ForeignKeys in the class and add _id field annotations
147
+ # Make a copy of items to avoid modifying dict during iteration
148
+ class_items = list(cls.__dict__.items())
149
+ for name, value in class_items:
150
+ if isinstance(value, ForeignKey):
151
+ cls.fk_descriptors[name] = value
152
+ # Add _id field annotation so Pydantic creates a field for it
153
+ id_field_name = f"{name}_id"
154
+ if id_field_name not in cls.__annotations__:
155
+ if value.fk_info.null:
156
+ cls.__annotations__[id_field_name] = Optional[int]
157
+ # Nullable FKs default to None so they can be omitted
158
+ setattr(cls, id_field_name, None)
159
+ else:
160
+ cls.__annotations__[id_field_name] = int
161
+
162
+ # Remove FK field annotation so Pydantic doesn't treat it as
163
+ # a field to be copied to instance __dict__ (which breaks
164
+ # the descriptor protocol)
165
+ if name in cls.__annotations__:
166
+ del cls.__annotations__[name]
167
+
168
+ @classmethod
169
+ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
170
+ """Set up ORM FK metadata after Pydantic has created model_fields.
171
+
172
+ This runs AFTER Pydantic populates model_fields, so we can add FK
173
+ metadata to the _id fields that Pydantic created.
174
+ """
175
+ # Call parent __pydantic_init_subclass__ FIRST
176
+ super().__pydantic_init_subclass__(**kwargs)
177
+
178
+ # Process FK descriptors - add FK metadata, register relationships
179
+ cls._setup_orm_fields()
180
+
181
+ # Register model in global registry
182
+ ModelRegistry.register_model(cls)
183
+
184
+ @classmethod
185
+ def _setup_orm_fields(cls) -> None:
186
+ """Add FK metadata to _id fields and register FK relationships.
187
+
188
+ Called during class creation (after Pydantic setup) to:
189
+ 1. Add FK metadata to _id fields for constraint generation
190
+ 2. Register FK relationships in ModelRegistry
191
+ 3. Remove descriptor from model_fields so Pydantic doesn't validate it
192
+ """
193
+ # Get FK descriptors for this class
194
+ fk_descriptors_copy = cls.fk_descriptors.copy()
195
+
196
+ for field_name in fk_descriptors_copy:
197
+ descriptor = cls.fk_descriptors[field_name]
198
+
199
+ # Create _id field name
200
+ id_field_name = f"{field_name}_id"
201
+
202
+ # Get ForeignKeyInfo from descriptor
203
+ fk_info = descriptor.fk_info
204
+
205
+ # The _id field should exist (created by Pydantic from annotation)
206
+ # We need to add FK metadata for constraint generation
207
+ if id_field_name in cls.model_fields:
208
+ existing_field = cls.model_fields[id_field_name]
209
+
210
+ # Create ForeignKeyInfo with proper db_column
211
+ fk_info_for_field = type(fk_info)(
212
+ to_model=fk_info.to_model,
213
+ on_delete=fk_info.on_delete,
214
+ on_update=fk_info.on_update,
215
+ null=fk_info.null,
216
+ unique=fk_info.unique,
217
+ related_name=fk_info.related_name,
218
+ db_column=fk_info.db_column or id_field_name,
219
+ )
220
+
221
+ # Add FK metadata to existing field's json_schema_extra
222
+ # The ForeignKeyInfo is stored for _build_field_definitions()
223
+ if existing_field.json_schema_extra is None:
224
+ existing_field.json_schema_extra = {}
225
+ if isinstance(existing_field.json_schema_extra, dict):
226
+ # ForeignKeyInfo stored for _build_field_definitions
227
+ existing_field.json_schema_extra["foreign_key"] = (
228
+ fk_info_for_field # type: ignore[assignment]
229
+ )
230
+
231
+ # Register FK relationship
232
+ ModelRegistry.register_foreign_key(
233
+ from_model=cls,
234
+ to_model=fk_info.to_model,
235
+ fk_field=field_name,
236
+ on_delete=fk_info.on_delete,
237
+ related_name=descriptor.related_name,
238
+ )
239
+
240
+ # Remove descriptor from model_fields so Pydantic doesn't
241
+ # validate it
242
+ if field_name in cls.model_fields:
243
+ del cls.model_fields[field_name]