sqliter-py 0.12.0__py3-none-any.whl → 0.17.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 +29 -0
- sqliter/helpers.py +27 -0
- sqliter/model/model.py +21 -4
- sqliter/orm/__init__.py +17 -0
- sqliter/orm/fields.py +412 -0
- sqliter/orm/foreign_key.py +8 -0
- sqliter/orm/m2m.py +784 -0
- sqliter/orm/model.py +308 -0
- sqliter/orm/query.py +221 -0
- sqliter/orm/registry.py +440 -0
- sqliter/query/query.py +573 -51
- sqliter/sqliter.py +182 -47
- 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 +537 -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.12.0.dist-info → sqliter_py-0.17.0.dist-info}/METADATA +28 -14
- sqliter_py-0.17.0.dist-info/RECORD +48 -0
- {sqliter_py-0.12.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +2 -2
- sqliter_py-0.17.0.dist-info/entry_points.txt +3 -0
- sqliter_py-0.12.0.dist-info/RECORD +0 -15
sqliter/orm/model.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""ORM model with lazy loading, reverse relationships, and M2M."""
|
|
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.m2m import ManyToMany
|
|
12
|
+
from sqliter.orm.registry import ModelRegistry
|
|
13
|
+
|
|
14
|
+
__all__ = ["BaseDBModel"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseDBModel(_BaseDBModel):
|
|
18
|
+
"""Extends BaseDBModel with ORM features.
|
|
19
|
+
|
|
20
|
+
Adds:
|
|
21
|
+
- Lazy loading of foreign key relationships
|
|
22
|
+
- Automatic reverse relationship setup
|
|
23
|
+
- Many-to-many relationships
|
|
24
|
+
- db_context for query execution
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Store FK descriptors per class (not inherited)
|
|
28
|
+
fk_descriptors: ClassVar[dict[str, ForeignKey[Any]]] = {}
|
|
29
|
+
|
|
30
|
+
# Store M2M descriptors per class (not inherited)
|
|
31
|
+
m2m_descriptors: ClassVar[dict[str, ManyToMany[Any]]] = {}
|
|
32
|
+
|
|
33
|
+
# Database context for lazy loading and reverse queries
|
|
34
|
+
# Using Any since SqliterDB would cause circular import issues with Pydantic
|
|
35
|
+
db_context: Optional[Any] = Field(default=None, exclude=True)
|
|
36
|
+
|
|
37
|
+
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401
|
|
38
|
+
"""Initialize model, converting FK fields to _id fields."""
|
|
39
|
+
# Convert FK field values to _id fields before validation
|
|
40
|
+
for fk_field in self.fk_descriptors:
|
|
41
|
+
if fk_field in kwargs:
|
|
42
|
+
value = kwargs[fk_field]
|
|
43
|
+
if isinstance(value, HasPK):
|
|
44
|
+
# Duck typing via Protocol: extract pk from model
|
|
45
|
+
kwargs[f"{fk_field}_id"] = value.pk
|
|
46
|
+
del kwargs[fk_field]
|
|
47
|
+
elif isinstance(value, int):
|
|
48
|
+
# Already an ID, just move to _id field
|
|
49
|
+
kwargs[f"{fk_field}_id"] = value
|
|
50
|
+
del kwargs[fk_field]
|
|
51
|
+
elif value is None:
|
|
52
|
+
# Keep None for nullable FKs
|
|
53
|
+
kwargs[f"{fk_field}_id"] = None
|
|
54
|
+
del kwargs[fk_field]
|
|
55
|
+
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
|
|
58
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
|
|
59
|
+
"""Dump model, excluding FK and M2M descriptor fields.
|
|
60
|
+
|
|
61
|
+
FK descriptor fields (like 'author') are excluded from
|
|
62
|
+
serialization. Only the _id fields (like 'author_id') are
|
|
63
|
+
included. M2M descriptor fields are also excluded.
|
|
64
|
+
"""
|
|
65
|
+
data = super().model_dump(**kwargs)
|
|
66
|
+
# Remove FK descriptor fields from the dump
|
|
67
|
+
for fk_field in self.fk_descriptors:
|
|
68
|
+
data.pop(fk_field, None)
|
|
69
|
+
# Remove M2M descriptor fields from the dump
|
|
70
|
+
for m2m_field in self.m2m_descriptors:
|
|
71
|
+
data.pop(m2m_field, None)
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
def __getattribute__(self, name: str) -> object:
|
|
75
|
+
"""Intercept FK field access to provide lazy loading."""
|
|
76
|
+
# Check if this is a FK field
|
|
77
|
+
if name in object.__getattribute__(self, "fk_descriptors"):
|
|
78
|
+
# Get FK ID
|
|
79
|
+
fk_id = object.__getattribute__(self, f"{name}_id")
|
|
80
|
+
|
|
81
|
+
# Null FK returns None directly (standard ORM behavior)
|
|
82
|
+
if fk_id is None:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Check instance cache for identity (same object on repeated access)
|
|
86
|
+
instance_dict = object.__getattribute__(self, "__dict__")
|
|
87
|
+
cache = instance_dict.setdefault("_fk_cache", {})
|
|
88
|
+
db_context = object.__getattribute__(self, "db_context")
|
|
89
|
+
|
|
90
|
+
# Check if we need to create or refresh the cached loader
|
|
91
|
+
cached_loader = cache.get(name)
|
|
92
|
+
needs_refresh = cached_loader is None or (
|
|
93
|
+
cached_loader.db_context is None and db_context is not None
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if needs_refresh:
|
|
97
|
+
# Get the descriptor and create LazyLoader
|
|
98
|
+
fk_descs = object.__getattribute__(self, "fk_descriptors")
|
|
99
|
+
descriptor = fk_descs[name]
|
|
100
|
+
cache[name] = LazyLoader(
|
|
101
|
+
instance=self,
|
|
102
|
+
to_model=descriptor.to_model,
|
|
103
|
+
fk_id=fk_id,
|
|
104
|
+
db_context=db_context,
|
|
105
|
+
)
|
|
106
|
+
return cache[name]
|
|
107
|
+
# For non-FK fields, use normal attribute access
|
|
108
|
+
return object.__getattribute__(self, name)
|
|
109
|
+
|
|
110
|
+
def _handle_reverse_m2m_set(self, name: str, value: object) -> bool:
|
|
111
|
+
"""Check and handle reverse M2M descriptor assignment.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: Attribute name being set.
|
|
115
|
+
value: Value being assigned.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if handled (caller should return), False otherwise.
|
|
119
|
+
"""
|
|
120
|
+
cls_attr = type(self).__dict__.get(name)
|
|
121
|
+
if cls_attr is None:
|
|
122
|
+
for klass in type(self).__mro__:
|
|
123
|
+
if name in klass.__dict__:
|
|
124
|
+
cls_attr = klass.__dict__[name]
|
|
125
|
+
break
|
|
126
|
+
if cls_attr is not None:
|
|
127
|
+
from sqliter.orm.m2m import ( # noqa: PLC0415
|
|
128
|
+
ReverseManyToMany,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if isinstance(cls_attr, ReverseManyToMany):
|
|
132
|
+
cls_attr.__set__(self, value)
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
137
|
+
"""Intercept FK, M2M, and reverse M2M field assignment."""
|
|
138
|
+
# Guard against M2M field assignment
|
|
139
|
+
m2m_descs = getattr(self, "m2m_descriptors", {})
|
|
140
|
+
if name in m2m_descs:
|
|
141
|
+
msg = (
|
|
142
|
+
f"Cannot assign to ManyToMany field '{name}'. "
|
|
143
|
+
f"Use .add(), .remove(), .clear(), or .set() "
|
|
144
|
+
f"instead."
|
|
145
|
+
)
|
|
146
|
+
raise AttributeError(msg)
|
|
147
|
+
|
|
148
|
+
# Guard against reverse M2M assignment (dynamic descriptors)
|
|
149
|
+
if self._handle_reverse_m2m_set(name, value):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Check if this is a FK field assignment
|
|
153
|
+
fk_descs = getattr(self, "fk_descriptors", {})
|
|
154
|
+
if name in fk_descs:
|
|
155
|
+
# Convert FK assignment to _id field assignment
|
|
156
|
+
# This bypasses Pydantic's validation for the FK field (which is
|
|
157
|
+
# not in model_fields) and uses the _id field instead
|
|
158
|
+
id_field_name = f"{name}_id"
|
|
159
|
+
if value is None:
|
|
160
|
+
setattr(self, id_field_name, None)
|
|
161
|
+
elif isinstance(value, int):
|
|
162
|
+
setattr(self, id_field_name, value)
|
|
163
|
+
elif isinstance(value, HasPK):
|
|
164
|
+
setattr(self, id_field_name, value.pk)
|
|
165
|
+
else:
|
|
166
|
+
msg = (
|
|
167
|
+
f"FK value must be BaseModel, int, or None, "
|
|
168
|
+
f"got {type(value)}"
|
|
169
|
+
)
|
|
170
|
+
raise TypeError(msg)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# If setting an _id field, clear corresponding FK cache
|
|
174
|
+
if name.endswith("_id"):
|
|
175
|
+
fk_name = name[:-3] # Remove "_id" suffix
|
|
176
|
+
if fk_name in fk_descs:
|
|
177
|
+
cache = self.__dict__.get("_fk_cache")
|
|
178
|
+
if cache and fk_name in cache:
|
|
179
|
+
del cache[fk_name]
|
|
180
|
+
super().__setattr__(name, value)
|
|
181
|
+
|
|
182
|
+
def __init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
|
|
183
|
+
"""Set up ORM field annotations before Pydantic processes the class.
|
|
184
|
+
|
|
185
|
+
This runs BEFORE Pydantic populates model_fields, so we add the _id
|
|
186
|
+
field annotations here so Pydantic creates proper FieldInfo for them.
|
|
187
|
+
"""
|
|
188
|
+
# Call parent __init_subclass__ FIRST
|
|
189
|
+
super().__init_subclass__(**kwargs)
|
|
190
|
+
|
|
191
|
+
# Collect FK descriptors from class dict
|
|
192
|
+
if "fk_descriptors" not in cls.__dict__:
|
|
193
|
+
cls.fk_descriptors = {}
|
|
194
|
+
|
|
195
|
+
# Collect M2M descriptors from class dict
|
|
196
|
+
if "m2m_descriptors" not in cls.__dict__:
|
|
197
|
+
cls.m2m_descriptors = {}
|
|
198
|
+
|
|
199
|
+
# Find all ForeignKeys in the class and add _id field annotations
|
|
200
|
+
# Make a copy of items to avoid modifying dict during iteration
|
|
201
|
+
class_items = list(cls.__dict__.items())
|
|
202
|
+
for name, value in class_items:
|
|
203
|
+
if isinstance(value, ForeignKey):
|
|
204
|
+
cls.fk_descriptors[name] = value
|
|
205
|
+
# Add _id field annotation so Pydantic creates a field for it
|
|
206
|
+
id_field_name = f"{name}_id"
|
|
207
|
+
if id_field_name not in cls.__annotations__:
|
|
208
|
+
if value.fk_info.null:
|
|
209
|
+
cls.__annotations__[id_field_name] = Optional[int]
|
|
210
|
+
# Nullable FKs default to None so they can be omitted
|
|
211
|
+
setattr(cls, id_field_name, None)
|
|
212
|
+
else:
|
|
213
|
+
cls.__annotations__[id_field_name] = int
|
|
214
|
+
|
|
215
|
+
# Remove FK field annotation so Pydantic doesn't treat it as
|
|
216
|
+
# a field to be copied to instance __dict__ (which breaks
|
|
217
|
+
# the descriptor protocol)
|
|
218
|
+
if name in cls.__annotations__:
|
|
219
|
+
del cls.__annotations__[name]
|
|
220
|
+
|
|
221
|
+
elif isinstance(value, ManyToMany):
|
|
222
|
+
cls.m2m_descriptors[name] = value
|
|
223
|
+
# Remove M2M annotation so Pydantic doesn't create a
|
|
224
|
+
# DB column for it
|
|
225
|
+
if name in cls.__annotations__:
|
|
226
|
+
del cls.__annotations__[name]
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
|
|
230
|
+
"""Set up ORM FK metadata after Pydantic has created model_fields.
|
|
231
|
+
|
|
232
|
+
This runs AFTER Pydantic populates model_fields, so we can add FK
|
|
233
|
+
metadata to the _id fields that Pydantic created.
|
|
234
|
+
"""
|
|
235
|
+
# Call parent __pydantic_init_subclass__ FIRST
|
|
236
|
+
super().__pydantic_init_subclass__(**kwargs)
|
|
237
|
+
|
|
238
|
+
# Process FK descriptors - add FK metadata, register relationships
|
|
239
|
+
cls._setup_orm_fields()
|
|
240
|
+
|
|
241
|
+
# Register model in global registry
|
|
242
|
+
ModelRegistry.register_model(cls)
|
|
243
|
+
|
|
244
|
+
@classmethod
|
|
245
|
+
def _setup_orm_fields(cls) -> None:
|
|
246
|
+
"""Add FK metadata to _id fields and register FK relationships.
|
|
247
|
+
|
|
248
|
+
Called during class creation (after Pydantic setup) to:
|
|
249
|
+
1. Add FK metadata to _id fields for constraint generation
|
|
250
|
+
2. Register FK relationships in ModelRegistry
|
|
251
|
+
3. Remove descriptor from model_fields so Pydantic doesn't validate it
|
|
252
|
+
"""
|
|
253
|
+
# Get FK descriptors for this class
|
|
254
|
+
fk_descriptors_copy = cls.fk_descriptors.copy()
|
|
255
|
+
|
|
256
|
+
for field_name in fk_descriptors_copy:
|
|
257
|
+
descriptor = cls.fk_descriptors[field_name]
|
|
258
|
+
|
|
259
|
+
# Create _id field name
|
|
260
|
+
id_field_name = f"{field_name}_id"
|
|
261
|
+
|
|
262
|
+
# Get ForeignKeyInfo from descriptor
|
|
263
|
+
fk_info = descriptor.fk_info
|
|
264
|
+
|
|
265
|
+
# The _id field should exist (created by Pydantic from annotation)
|
|
266
|
+
# We need to add FK metadata for constraint generation
|
|
267
|
+
if id_field_name in cls.model_fields:
|
|
268
|
+
existing_field = cls.model_fields[id_field_name]
|
|
269
|
+
|
|
270
|
+
# Create ForeignKeyInfo with proper db_column
|
|
271
|
+
fk_info_for_field = type(fk_info)(
|
|
272
|
+
to_model=fk_info.to_model,
|
|
273
|
+
on_delete=fk_info.on_delete,
|
|
274
|
+
on_update=fk_info.on_update,
|
|
275
|
+
null=fk_info.null,
|
|
276
|
+
unique=fk_info.unique,
|
|
277
|
+
related_name=fk_info.related_name,
|
|
278
|
+
db_column=fk_info.db_column or id_field_name,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
# Add FK metadata to existing field's json_schema_extra
|
|
282
|
+
# The ForeignKeyInfo is stored for _build_field_definitions()
|
|
283
|
+
if existing_field.json_schema_extra is None:
|
|
284
|
+
existing_field.json_schema_extra = {}
|
|
285
|
+
if isinstance(existing_field.json_schema_extra, dict):
|
|
286
|
+
# ForeignKeyInfo stored for _build_field_definitions
|
|
287
|
+
existing_field.json_schema_extra["foreign_key"] = (
|
|
288
|
+
fk_info_for_field # type: ignore[assignment]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Register FK relationship
|
|
292
|
+
ModelRegistry.register_foreign_key(
|
|
293
|
+
from_model=cls,
|
|
294
|
+
to_model=fk_info.to_model,
|
|
295
|
+
fk_field=field_name,
|
|
296
|
+
on_delete=fk_info.on_delete,
|
|
297
|
+
related_name=descriptor.related_name,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Remove descriptor from model_fields so Pydantic doesn't
|
|
301
|
+
# validate it
|
|
302
|
+
if field_name in cls.model_fields:
|
|
303
|
+
del cls.model_fields[field_name]
|
|
304
|
+
|
|
305
|
+
# Remove M2M descriptor fields from model_fields
|
|
306
|
+
for m2m_name in cls.m2m_descriptors:
|
|
307
|
+
if m2m_name in cls.model_fields:
|
|
308
|
+
del cls.model_fields[m2m_name]
|
sqliter/orm/query.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Query builders for reverse relationships."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import (
|
|
6
|
+
TYPE_CHECKING,
|
|
7
|
+
Any,
|
|
8
|
+
Optional,
|
|
9
|
+
Protocol,
|
|
10
|
+
overload,
|
|
11
|
+
runtime_checkable,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
15
|
+
from sqliter.model.model import BaseDBModel
|
|
16
|
+
from sqliter.sqliter import SqliterDB
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class HasPKAndContext(Protocol):
|
|
21
|
+
"""Protocol for model instances with pk and db_context."""
|
|
22
|
+
|
|
23
|
+
pk: Optional[int]
|
|
24
|
+
db_context: Optional[SqliterDB]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReverseQuery:
|
|
28
|
+
"""Query builder for reverse relationships.
|
|
29
|
+
|
|
30
|
+
Delegates to QueryBuilder for actual SQL execution.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
instance: HasPKAndContext,
|
|
36
|
+
to_model: type[BaseDBModel],
|
|
37
|
+
fk_field: str,
|
|
38
|
+
db_context: Optional[SqliterDB],
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize reverse query.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
instance: The model instance (e.g., Author)
|
|
44
|
+
to_model: The related model class (e.g., Book)
|
|
45
|
+
fk_field: The FK field name (e.g., "author")
|
|
46
|
+
db_context: Database connection for queries
|
|
47
|
+
"""
|
|
48
|
+
self.instance = instance
|
|
49
|
+
self.to_model = to_model
|
|
50
|
+
self.fk_field = fk_field
|
|
51
|
+
self._db = db_context
|
|
52
|
+
self._filters: dict[str, Any] = {}
|
|
53
|
+
self._limit: Optional[int] = None
|
|
54
|
+
self._offset: Optional[int] = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def fk_value(self) -> Optional[int]:
|
|
58
|
+
"""Get the FK ID value from the instance."""
|
|
59
|
+
return self.instance.pk
|
|
60
|
+
|
|
61
|
+
def filter(self, **kwargs: Any) -> ReverseQuery: # noqa: ANN401
|
|
62
|
+
"""Store filters for later execution.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
**kwargs: Filter criteria
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Self for chaining
|
|
69
|
+
"""
|
|
70
|
+
self._filters.update(kwargs)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def limit(self, count: int) -> ReverseQuery:
|
|
74
|
+
"""Set limit on query results.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
count: Maximum number of results
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Self for chaining
|
|
81
|
+
"""
|
|
82
|
+
self._limit = count
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
def offset(self, count: int) -> ReverseQuery:
|
|
86
|
+
"""Set offset on query results.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
count: Number of results to skip
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Self for chaining
|
|
93
|
+
"""
|
|
94
|
+
self._offset = count
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def fetch_all(self) -> list[BaseDBModel]:
|
|
98
|
+
"""Execute query using stored db_context.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of related model instances
|
|
102
|
+
"""
|
|
103
|
+
fk_id = self.fk_value
|
|
104
|
+
if fk_id is None or self._db is None:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
# Build query with FK filter and additional filters
|
|
108
|
+
query = self._db.select(self.to_model).filter(
|
|
109
|
+
**{f"{self.fk_field}_id": fk_id}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Apply additional filters
|
|
113
|
+
if self._filters:
|
|
114
|
+
query = query.filter(**self._filters)
|
|
115
|
+
|
|
116
|
+
# Apply limit and offset
|
|
117
|
+
if self._limit is not None:
|
|
118
|
+
query = query.limit(self._limit)
|
|
119
|
+
if self._offset is not None:
|
|
120
|
+
query = query.offset(self._offset)
|
|
121
|
+
|
|
122
|
+
return query.fetch_all()
|
|
123
|
+
|
|
124
|
+
def fetch_one(self) -> Optional[BaseDBModel]:
|
|
125
|
+
"""Execute query and return single result.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Related model instance or None
|
|
129
|
+
"""
|
|
130
|
+
results = self.limit(1).fetch_all()
|
|
131
|
+
return results[0] if results else None
|
|
132
|
+
|
|
133
|
+
def count(self) -> int:
|
|
134
|
+
"""Count related objects.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Number of related objects
|
|
138
|
+
"""
|
|
139
|
+
fk_id = self.fk_value
|
|
140
|
+
if fk_id is None or self._db is None:
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
# Build query with FK filter and additional filters
|
|
144
|
+
query = self._db.select(self.to_model).filter(
|
|
145
|
+
**{f"{self.fk_field}_id": fk_id}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Apply additional filters
|
|
149
|
+
if self._filters:
|
|
150
|
+
query = query.filter(**self._filters)
|
|
151
|
+
|
|
152
|
+
return query.count()
|
|
153
|
+
|
|
154
|
+
def exists(self) -> bool:
|
|
155
|
+
"""Check if any related objects exist.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if at least one related object exists
|
|
159
|
+
"""
|
|
160
|
+
return self.count() > 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ReverseRelationship:
|
|
164
|
+
"""Descriptor that returns ReverseQuery when accessed.
|
|
165
|
+
|
|
166
|
+
Added automatically to models during class creation by ForeignKeyDescriptor.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self, from_model: type[BaseDBModel], fk_field: str, related_name: str
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Initialize reverse relationship descriptor.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
from_model: The model with the FK field (e.g., Book)
|
|
176
|
+
fk_field: The FK field name (e.g., "author")
|
|
177
|
+
related_name: The name of this reverse relationship (e.g., "books")
|
|
178
|
+
"""
|
|
179
|
+
self.from_model = from_model
|
|
180
|
+
self.fk_field = fk_field
|
|
181
|
+
self.related_name = related_name
|
|
182
|
+
|
|
183
|
+
@overload
|
|
184
|
+
def __get__(
|
|
185
|
+
self, instance: None, owner: type[object]
|
|
186
|
+
) -> ReverseRelationship: ...
|
|
187
|
+
|
|
188
|
+
@overload
|
|
189
|
+
def __get__(
|
|
190
|
+
self, instance: HasPKAndContext, owner: type[object]
|
|
191
|
+
) -> ReverseQuery: ...
|
|
192
|
+
|
|
193
|
+
def __get__(
|
|
194
|
+
self, instance: Optional[HasPKAndContext], owner: type[object]
|
|
195
|
+
) -> ReverseRelationship | ReverseQuery:
|
|
196
|
+
"""Return ReverseQuery when accessed on instance.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
instance: Model instance (e.g., Author)
|
|
200
|
+
owner: Model class
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
ReverseQuery for fetching related objects
|
|
204
|
+
"""
|
|
205
|
+
if instance is None:
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
return ReverseQuery(
|
|
209
|
+
instance=instance,
|
|
210
|
+
to_model=self.from_model,
|
|
211
|
+
fk_field=self.fk_field,
|
|
212
|
+
db_context=instance.db_context,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def __set__(self, instance: object, value: object) -> None:
|
|
216
|
+
"""Prevent setting reverse relationships."""
|
|
217
|
+
msg = (
|
|
218
|
+
f"Cannot set reverse relationship '{self.related_name}'. "
|
|
219
|
+
f"Use the ForeignKey field on {self.from_model.__name__} instead."
|
|
220
|
+
)
|
|
221
|
+
raise AttributeError(msg)
|