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.
- sqliter/constants.py +4 -3
- sqliter/exceptions.py +43 -0
- sqliter/model/__init__.py +38 -3
- sqliter/model/foreign_key.py +153 -0
- sqliter/model/model.py +42 -3
- sqliter/model/unique.py +20 -11
- sqliter/orm/__init__.py +16 -0
- sqliter/orm/fields.py +412 -0
- sqliter/orm/foreign_key.py +8 -0
- sqliter/orm/model.py +243 -0
- sqliter/orm/query.py +221 -0
- sqliter/orm/registry.py +169 -0
- sqliter/query/query.py +720 -69
- sqliter/sqliter.py +533 -76
- sqliter/tui/__init__.py +62 -0
- sqliter/tui/__main__.py +6 -0
- sqliter/tui/app.py +179 -0
- sqliter/tui/demos/__init__.py +96 -0
- sqliter/tui/demos/base.py +114 -0
- sqliter/tui/demos/caching.py +283 -0
- sqliter/tui/demos/connection.py +150 -0
- sqliter/tui/demos/constraints.py +211 -0
- sqliter/tui/demos/crud.py +154 -0
- sqliter/tui/demos/errors.py +231 -0
- sqliter/tui/demos/field_selection.py +150 -0
- sqliter/tui/demos/filters.py +389 -0
- sqliter/tui/demos/models.py +248 -0
- sqliter/tui/demos/ordering.py +156 -0
- sqliter/tui/demos/orm.py +460 -0
- sqliter/tui/demos/results.py +241 -0
- sqliter/tui/demos/string_filters.py +210 -0
- sqliter/tui/demos/timestamps.py +126 -0
- sqliter/tui/demos/transactions.py +177 -0
- sqliter/tui/runner.py +116 -0
- sqliter/tui/styles/app.tcss +130 -0
- sqliter/tui/widgets/__init__.py +7 -0
- sqliter/tui/widgets/code_display.py +81 -0
- sqliter/tui/widgets/demo_list.py +65 -0
- sqliter/tui/widgets/output_display.py +92 -0
- {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/METADATA +27 -11
- sqliter_py-0.16.0.dist-info/RECORD +47 -0
- {sqliter_py-0.9.0.dist-info → sqliter_py-0.16.0.dist-info}/WHEEL +2 -2
- sqliter_py-0.16.0.dist-info/entry_points.txt +3 -0
- 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
|
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]
|