sqliter-py 0.16.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/exceptions.py +16 -0
- sqliter/helpers.py +27 -0
- sqliter/model/model.py +7 -29
- sqliter/orm/__init__.py +2 -1
- sqliter/orm/m2m.py +784 -0
- sqliter/orm/model.py +70 -5
- sqliter/orm/registry.py +273 -2
- sqliter/sqliter.py +41 -0
- sqliter/tui/demos/orm.py +79 -2
- {sqliter_py-0.16.0.dist-info → sqliter_py-0.17.0.dist-info}/METADATA +6 -8
- {sqliter_py-0.16.0.dist-info → sqliter_py-0.17.0.dist-info}/RECORD +13 -12
- {sqliter_py-0.16.0.dist-info → sqliter_py-0.17.0.dist-info}/WHEEL +0 -0
- {sqliter_py-0.16.0.dist-info → sqliter_py-0.17.0.dist-info}/entry_points.txt +0 -0
sqliter/orm/model.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""ORM model with lazy loading
|
|
1
|
+
"""ORM model with lazy loading, reverse relationships, and M2M."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -8,6 +8,7 @@ from pydantic import Field
|
|
|
8
8
|
|
|
9
9
|
from sqliter.model.model import BaseDBModel as _BaseDBModel
|
|
10
10
|
from sqliter.orm.fields import ForeignKey, HasPK, LazyLoader
|
|
11
|
+
from sqliter.orm.m2m import ManyToMany
|
|
11
12
|
from sqliter.orm.registry import ModelRegistry
|
|
12
13
|
|
|
13
14
|
__all__ = ["BaseDBModel"]
|
|
@@ -19,12 +20,16 @@ class BaseDBModel(_BaseDBModel):
|
|
|
19
20
|
Adds:
|
|
20
21
|
- Lazy loading of foreign key relationships
|
|
21
22
|
- Automatic reverse relationship setup
|
|
23
|
+
- Many-to-many relationships
|
|
22
24
|
- db_context for query execution
|
|
23
25
|
"""
|
|
24
26
|
|
|
25
27
|
# Store FK descriptors per class (not inherited)
|
|
26
28
|
fk_descriptors: ClassVar[dict[str, ForeignKey[Any]]] = {}
|
|
27
29
|
|
|
30
|
+
# Store M2M descriptors per class (not inherited)
|
|
31
|
+
m2m_descriptors: ClassVar[dict[str, ManyToMany[Any]]] = {}
|
|
32
|
+
|
|
28
33
|
# Database context for lazy loading and reverse queries
|
|
29
34
|
# Using Any since SqliterDB would cause circular import issues with Pydantic
|
|
30
35
|
db_context: Optional[Any] = Field(default=None, exclude=True)
|
|
@@ -51,15 +56,19 @@ class BaseDBModel(_BaseDBModel):
|
|
|
51
56
|
super().__init__(**kwargs)
|
|
52
57
|
|
|
53
58
|
def model_dump(self, **kwargs: Any) -> dict[str, Any]: # noqa: ANN401
|
|
54
|
-
"""Dump model, excluding FK descriptor fields.
|
|
59
|
+
"""Dump model, excluding FK and M2M descriptor fields.
|
|
55
60
|
|
|
56
|
-
FK descriptor fields (like 'author') are excluded from
|
|
57
|
-
Only the _id fields (like 'author_id') are
|
|
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.
|
|
58
64
|
"""
|
|
59
65
|
data = super().model_dump(**kwargs)
|
|
60
66
|
# Remove FK descriptor fields from the dump
|
|
61
67
|
for fk_field in self.fk_descriptors:
|
|
62
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)
|
|
63
72
|
return data
|
|
64
73
|
|
|
65
74
|
def __getattribute__(self, name: str) -> object:
|
|
@@ -98,8 +107,48 @@ class BaseDBModel(_BaseDBModel):
|
|
|
98
107
|
# For non-FK fields, use normal attribute access
|
|
99
108
|
return object.__getattribute__(self, name)
|
|
100
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
|
+
|
|
101
136
|
def __setattr__(self, name: str, value: object) -> None:
|
|
102
|
-
"""Intercept FK
|
|
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
|
+
|
|
103
152
|
# Check if this is a FK field assignment
|
|
104
153
|
fk_descs = getattr(self, "fk_descriptors", {})
|
|
105
154
|
if name in fk_descs:
|
|
@@ -143,6 +192,10 @@ class BaseDBModel(_BaseDBModel):
|
|
|
143
192
|
if "fk_descriptors" not in cls.__dict__:
|
|
144
193
|
cls.fk_descriptors = {}
|
|
145
194
|
|
|
195
|
+
# Collect M2M descriptors from class dict
|
|
196
|
+
if "m2m_descriptors" not in cls.__dict__:
|
|
197
|
+
cls.m2m_descriptors = {}
|
|
198
|
+
|
|
146
199
|
# Find all ForeignKeys in the class and add _id field annotations
|
|
147
200
|
# Make a copy of items to avoid modifying dict during iteration
|
|
148
201
|
class_items = list(cls.__dict__.items())
|
|
@@ -165,6 +218,13 @@ class BaseDBModel(_BaseDBModel):
|
|
|
165
218
|
if name in cls.__annotations__:
|
|
166
219
|
del cls.__annotations__[name]
|
|
167
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
|
+
|
|
168
228
|
@classmethod
|
|
169
229
|
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: # noqa: ANN401
|
|
170
230
|
"""Set up ORM FK metadata after Pydantic has created model_fields.
|
|
@@ -241,3 +301,8 @@ class BaseDBModel(_BaseDBModel):
|
|
|
241
301
|
# validate it
|
|
242
302
|
if field_name in cls.model_fields:
|
|
243
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/registry.py
CHANGED
|
@@ -4,23 +4,89 @@ Central registry for:
|
|
|
4
4
|
- Model classes by table name
|
|
5
5
|
- Foreign key relationships
|
|
6
6
|
- Pending reverse relationships
|
|
7
|
+
- Many-to-many relationships
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
10
11
|
|
|
11
|
-
from
|
|
12
|
+
from copy import deepcopy
|
|
13
|
+
from typing import Any, ClassVar, Optional, Protocol, TypedDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _M2MForwardRef(Protocol):
|
|
17
|
+
def resolve_forward_ref(self, model_class: type[Any]) -> None: ...
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def junction_table(self) -> Optional[str]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _RegistryState(TypedDict):
|
|
24
|
+
models: dict[str, type[Any]]
|
|
25
|
+
models_by_name: dict[str, type[Any]]
|
|
26
|
+
foreign_keys: dict[str, list[dict[str, Any]]]
|
|
27
|
+
pending_reverses: dict[str, list[dict[str, Any]]]
|
|
28
|
+
m2m_relationships: dict[str, list[dict[str, Any]]]
|
|
29
|
+
pending_m2m_reverses: dict[str, list[dict[str, Any]]]
|
|
30
|
+
pending_m2m_targets: dict[str, list[dict[str, Any]]]
|
|
12
31
|
|
|
13
32
|
|
|
14
33
|
class ModelRegistry:
|
|
15
|
-
"""Registry for ORM models and
|
|
34
|
+
"""Registry for ORM models, FK, and M2M relationships.
|
|
16
35
|
|
|
17
36
|
Uses automatic setup via descriptor __set_name__ hook - no manual setup
|
|
18
37
|
required.
|
|
19
38
|
"""
|
|
20
39
|
|
|
21
40
|
_models: ClassVar[dict[str, type]] = {}
|
|
41
|
+
_models_by_name: ClassVar[dict[str, type[Any]]] = {}
|
|
22
42
|
_foreign_keys: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
23
43
|
_pending_reverses: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
44
|
+
_m2m_relationships: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
45
|
+
_pending_m2m_reverses: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
46
|
+
_pending_m2m_targets: ClassVar[dict[str, list[dict[str, Any]]]] = {}
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def reset(cls) -> None:
|
|
50
|
+
"""Clear all registry state.
|
|
51
|
+
|
|
52
|
+
Intended for tests that need isolation between model definitions.
|
|
53
|
+
"""
|
|
54
|
+
cls._models.clear()
|
|
55
|
+
cls._models_by_name.clear()
|
|
56
|
+
cls._foreign_keys.clear()
|
|
57
|
+
cls._pending_reverses.clear()
|
|
58
|
+
cls._m2m_relationships.clear()
|
|
59
|
+
cls._pending_m2m_reverses.clear()
|
|
60
|
+
cls._pending_m2m_targets.clear()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def snapshot(cls) -> _RegistryState:
|
|
64
|
+
"""Return a deep copy of the registry state.
|
|
65
|
+
|
|
66
|
+
Useful for temporarily isolating tests and restoring state afterward.
|
|
67
|
+
"""
|
|
68
|
+
# Model class references are immutable, so shallow copies are enough
|
|
69
|
+
# for model maps; relationship dicts are deep-copied for isolation.
|
|
70
|
+
return {
|
|
71
|
+
"models": cls._models.copy(),
|
|
72
|
+
"models_by_name": cls._models_by_name.copy(),
|
|
73
|
+
"foreign_keys": deepcopy(cls._foreign_keys),
|
|
74
|
+
"pending_reverses": deepcopy(cls._pending_reverses),
|
|
75
|
+
"m2m_relationships": deepcopy(cls._m2m_relationships),
|
|
76
|
+
"pending_m2m_reverses": deepcopy(cls._pending_m2m_reverses),
|
|
77
|
+
"pending_m2m_targets": deepcopy(cls._pending_m2m_targets),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def restore(cls, state: _RegistryState) -> None:
|
|
82
|
+
"""Restore registry state from a snapshot."""
|
|
83
|
+
cls._models = state["models"]
|
|
84
|
+
cls._models_by_name = state["models_by_name"]
|
|
85
|
+
cls._foreign_keys = state["foreign_keys"]
|
|
86
|
+
cls._pending_reverses = state["pending_reverses"]
|
|
87
|
+
cls._m2m_relationships = state["m2m_relationships"]
|
|
88
|
+
cls._pending_m2m_reverses = state["pending_m2m_reverses"]
|
|
89
|
+
cls._pending_m2m_targets = state["pending_m2m_targets"]
|
|
24
90
|
|
|
25
91
|
@classmethod
|
|
26
92
|
def register_model(cls, model_class: type[Any]) -> None:
|
|
@@ -31,6 +97,7 @@ class ModelRegistry:
|
|
|
31
97
|
"""
|
|
32
98
|
table_name = model_class.get_table_name()
|
|
33
99
|
cls._models[table_name] = model_class
|
|
100
|
+
cls._models_by_name[model_class.__name__] = model_class
|
|
34
101
|
|
|
35
102
|
# Process any pending reverse relationships for this model
|
|
36
103
|
if table_name in cls._pending_reverses:
|
|
@@ -38,6 +105,38 @@ class ModelRegistry:
|
|
|
38
105
|
cls._add_reverse_relationship_now(**pending)
|
|
39
106
|
del cls._pending_reverses[table_name]
|
|
40
107
|
|
|
108
|
+
# Process any pending M2M reverse relationships
|
|
109
|
+
if table_name in cls._pending_m2m_reverses:
|
|
110
|
+
for pending in cls._pending_m2m_reverses[table_name]:
|
|
111
|
+
cls._add_m2m_reverse_now(
|
|
112
|
+
from_model=pending["from_model"],
|
|
113
|
+
to_model=pending["to_model"],
|
|
114
|
+
m2m_field=pending["m2m_field"],
|
|
115
|
+
junction_table=pending["junction_table"],
|
|
116
|
+
related_name=pending["related_name"],
|
|
117
|
+
symmetrical=pending["symmetrical"],
|
|
118
|
+
)
|
|
119
|
+
del cls._pending_m2m_reverses[table_name]
|
|
120
|
+
|
|
121
|
+
class_name = model_class.__name__
|
|
122
|
+
if class_name in cls._pending_m2m_targets:
|
|
123
|
+
for pending in cls._pending_m2m_targets[class_name]:
|
|
124
|
+
descriptor = pending["descriptor"]
|
|
125
|
+
descriptor.resolve_forward_ref(model_class)
|
|
126
|
+
junction_table = descriptor.junction_table
|
|
127
|
+
if junction_table is None:
|
|
128
|
+
msg = "ManyToMany junction table could not be resolved."
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
cls.add_m2m_relationship(
|
|
131
|
+
from_model=pending["from_model"],
|
|
132
|
+
to_model=model_class,
|
|
133
|
+
m2m_field=pending["m2m_field"],
|
|
134
|
+
junction_table=junction_table,
|
|
135
|
+
related_name=pending["related_name"],
|
|
136
|
+
symmetrical=pending["symmetrical"],
|
|
137
|
+
)
|
|
138
|
+
del cls._pending_m2m_targets[class_name]
|
|
139
|
+
|
|
41
140
|
@classmethod
|
|
42
141
|
def register_foreign_key(
|
|
43
142
|
cls,
|
|
@@ -82,6 +181,11 @@ class ModelRegistry:
|
|
|
82
181
|
"""
|
|
83
182
|
return cls._models.get(table_name)
|
|
84
183
|
|
|
184
|
+
@classmethod
|
|
185
|
+
def get_model_by_name(cls, class_name: str) -> Optional[type[Any]]:
|
|
186
|
+
"""Get model by class name."""
|
|
187
|
+
return cls._models_by_name.get(class_name)
|
|
188
|
+
|
|
85
189
|
@classmethod
|
|
86
190
|
def get_foreign_keys(cls, table_name: str) -> list[dict[str, Any]]:
|
|
87
191
|
"""Get FK relationships for a model.
|
|
@@ -167,3 +271,170 @@ class ModelRegistry:
|
|
|
167
271
|
related_name,
|
|
168
272
|
ReverseRelationship(from_model, fk_field, related_name),
|
|
169
273
|
)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def add_m2m_relationship(
|
|
277
|
+
cls,
|
|
278
|
+
from_model: type[Any],
|
|
279
|
+
to_model: type[Any],
|
|
280
|
+
m2m_field: str,
|
|
281
|
+
junction_table: str,
|
|
282
|
+
related_name: Optional[str],
|
|
283
|
+
*,
|
|
284
|
+
symmetrical: bool = False,
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Register a M2M relationship and set up reverse accessor.
|
|
287
|
+
|
|
288
|
+
Called by ManyToMany.__set_name__ during class creation. If the
|
|
289
|
+
target model hasn't been registered yet, the reverse accessor
|
|
290
|
+
is stored as pending.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
from_model: The model defining the ManyToMany field.
|
|
294
|
+
to_model: The target model class.
|
|
295
|
+
m2m_field: Name of the M2M field.
|
|
296
|
+
junction_table: Name of the junction table.
|
|
297
|
+
related_name: Name for the reverse accessor.
|
|
298
|
+
symmetrical: Whether self-referential relationships are symmetric.
|
|
299
|
+
"""
|
|
300
|
+
from_table = from_model.get_table_name()
|
|
301
|
+
|
|
302
|
+
if from_table not in cls._m2m_relationships:
|
|
303
|
+
cls._m2m_relationships[from_table] = []
|
|
304
|
+
|
|
305
|
+
cls._m2m_relationships[from_table].append(
|
|
306
|
+
{
|
|
307
|
+
"to_model": to_model,
|
|
308
|
+
"m2m_field": m2m_field,
|
|
309
|
+
"junction_table": junction_table,
|
|
310
|
+
"related_name": related_name,
|
|
311
|
+
"symmetrical": symmetrical,
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if related_name is None:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
if from_model is to_model and symmetrical:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
to_table = to_model.get_table_name()
|
|
322
|
+
pending_info = {
|
|
323
|
+
"from_model": from_model,
|
|
324
|
+
"to_model": to_model,
|
|
325
|
+
"m2m_field": m2m_field,
|
|
326
|
+
"junction_table": junction_table,
|
|
327
|
+
"related_name": related_name,
|
|
328
|
+
"symmetrical": symmetrical,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if to_table in cls._models:
|
|
332
|
+
cls._add_m2m_reverse_now(
|
|
333
|
+
from_model=from_model,
|
|
334
|
+
to_model=to_model,
|
|
335
|
+
m2m_field=m2m_field,
|
|
336
|
+
junction_table=junction_table,
|
|
337
|
+
related_name=related_name,
|
|
338
|
+
symmetrical=symmetrical,
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
if to_table not in cls._pending_m2m_reverses:
|
|
342
|
+
cls._pending_m2m_reverses[to_table] = []
|
|
343
|
+
cls._pending_m2m_reverses[to_table].append(pending_info)
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def add_pending_m2m_relationship(
|
|
347
|
+
cls,
|
|
348
|
+
*,
|
|
349
|
+
from_model: type[Any],
|
|
350
|
+
to_model_name: str,
|
|
351
|
+
m2m_field: str,
|
|
352
|
+
related_name: Optional[str],
|
|
353
|
+
symmetrical: bool,
|
|
354
|
+
descriptor: _M2MForwardRef,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Store a pending M2M relationship targeting a string forward ref."""
|
|
357
|
+
existing = cls.get_model_by_name(to_model_name)
|
|
358
|
+
if existing is not None:
|
|
359
|
+
descriptor.resolve_forward_ref(existing)
|
|
360
|
+
junction_table = descriptor.junction_table
|
|
361
|
+
if junction_table is None:
|
|
362
|
+
msg = "ManyToMany junction table could not be resolved."
|
|
363
|
+
raise ValueError(msg)
|
|
364
|
+
cls.add_m2m_relationship(
|
|
365
|
+
from_model=from_model,
|
|
366
|
+
to_model=existing,
|
|
367
|
+
m2m_field=m2m_field,
|
|
368
|
+
junction_table=junction_table,
|
|
369
|
+
related_name=related_name,
|
|
370
|
+
symmetrical=symmetrical,
|
|
371
|
+
)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
if to_model_name not in cls._pending_m2m_targets:
|
|
375
|
+
cls._pending_m2m_targets[to_model_name] = []
|
|
376
|
+
cls._pending_m2m_targets[to_model_name].append(
|
|
377
|
+
{
|
|
378
|
+
"from_model": from_model,
|
|
379
|
+
"m2m_field": m2m_field,
|
|
380
|
+
"related_name": related_name,
|
|
381
|
+
"symmetrical": symmetrical,
|
|
382
|
+
"descriptor": descriptor,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
@classmethod
|
|
387
|
+
def _add_m2m_reverse_now(
|
|
388
|
+
cls,
|
|
389
|
+
from_model: type[Any],
|
|
390
|
+
to_model: type[Any],
|
|
391
|
+
m2m_field: str,
|
|
392
|
+
junction_table: str,
|
|
393
|
+
related_name: str,
|
|
394
|
+
*,
|
|
395
|
+
symmetrical: bool = False,
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Add reverse M2M descriptor to the target model.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
from_model: The model defining the ManyToMany field.
|
|
401
|
+
to_model: The target model (receives the descriptor).
|
|
402
|
+
m2m_field: Name of the M2M field.
|
|
403
|
+
junction_table: Name of the junction table.
|
|
404
|
+
related_name: Name for the reverse accessor.
|
|
405
|
+
symmetrical: Whether self-referential relationships are symmetric.
|
|
406
|
+
"""
|
|
407
|
+
from sqliter.orm.m2m import ReverseManyToMany # noqa: PLC0415
|
|
408
|
+
|
|
409
|
+
_ = m2m_field # kept for signature consistency / future use
|
|
410
|
+
|
|
411
|
+
if hasattr(to_model, related_name):
|
|
412
|
+
msg = (
|
|
413
|
+
f"Reverse M2M accessor '{related_name}' already "
|
|
414
|
+
f"exists on {to_model.__name__}"
|
|
415
|
+
)
|
|
416
|
+
raise AttributeError(msg)
|
|
417
|
+
|
|
418
|
+
setattr(
|
|
419
|
+
to_model,
|
|
420
|
+
related_name,
|
|
421
|
+
ReverseManyToMany(
|
|
422
|
+
from_model=from_model,
|
|
423
|
+
to_model=to_model,
|
|
424
|
+
junction_table=junction_table,
|
|
425
|
+
related_name=related_name,
|
|
426
|
+
symmetrical=symmetrical,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
@classmethod
|
|
431
|
+
def get_m2m_relationships(cls, table_name: str) -> list[dict[str, Any]]:
|
|
432
|
+
"""Get M2M relationships for a model.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
table_name: The table name to look up.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
List of M2M relationship dictionaries.
|
|
439
|
+
"""
|
|
440
|
+
return cls._m2m_relationships.get(table_name, [])
|
sqliter/sqliter.py
CHANGED
|
@@ -678,6 +678,47 @@ class SqliterDB:
|
|
|
678
678
|
model_class, model_class.Meta.unique_indexes, unique=True
|
|
679
679
|
)
|
|
680
680
|
|
|
681
|
+
# Create junction tables for M2M relationships
|
|
682
|
+
self._create_m2m_junction_tables(model_class)
|
|
683
|
+
|
|
684
|
+
def _create_m2m_junction_tables(
|
|
685
|
+
self, model_class: type[BaseDBModel]
|
|
686
|
+
) -> None:
|
|
687
|
+
"""Create junction tables for M2M relationships on a model.
|
|
688
|
+
|
|
689
|
+
Queries the ModelRegistry for M2M relationships registered for
|
|
690
|
+
this model and creates the corresponding junction tables.
|
|
691
|
+
|
|
692
|
+
Args:
|
|
693
|
+
model_class: The model class to check for M2M relationships.
|
|
694
|
+
"""
|
|
695
|
+
try:
|
|
696
|
+
from sqliter.orm.m2m import ( # noqa: PLC0415
|
|
697
|
+
create_junction_table,
|
|
698
|
+
)
|
|
699
|
+
from sqliter.orm.registry import ( # noqa: PLC0415
|
|
700
|
+
ModelRegistry,
|
|
701
|
+
)
|
|
702
|
+
except ImportError:
|
|
703
|
+
return
|
|
704
|
+
|
|
705
|
+
table_name = model_class.get_table_name()
|
|
706
|
+
m2m_rels = ModelRegistry.get_m2m_relationships(table_name)
|
|
707
|
+
|
|
708
|
+
for rel in m2m_rels:
|
|
709
|
+
junction_table = rel["junction_table"]
|
|
710
|
+
to_model = rel["to_model"]
|
|
711
|
+
to_table = to_model.get_table_name()
|
|
712
|
+
|
|
713
|
+
# Sort alphabetically for consistent column naming
|
|
714
|
+
sorted_tables = sorted([table_name, to_table])
|
|
715
|
+
create_junction_table(
|
|
716
|
+
self,
|
|
717
|
+
junction_table,
|
|
718
|
+
sorted_tables[0],
|
|
719
|
+
sorted_tables[1],
|
|
720
|
+
)
|
|
721
|
+
|
|
681
722
|
def _create_indexes(
|
|
682
723
|
self,
|
|
683
724
|
model_class: type[BaseDBModel],
|
sqliter/tui/demos/orm.py
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import io
|
|
6
|
-
from typing import Optional
|
|
6
|
+
from typing import Any, Optional, cast
|
|
7
7
|
|
|
8
8
|
from sqliter import SqliterDB
|
|
9
|
-
from sqliter.orm import BaseDBModel, ForeignKey
|
|
9
|
+
from sqliter.orm import BaseDBModel, ForeignKey, ManyToMany
|
|
10
10
|
from sqliter.tui.demos.base import Demo, DemoCategory, extract_demo_code
|
|
11
11
|
|
|
12
12
|
|
|
@@ -203,6 +203,67 @@ def _run_reverse_relationships() -> str:
|
|
|
203
203
|
return output.getvalue()
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
def _run_many_to_many_basic() -> str:
|
|
207
|
+
"""Show basic many-to-many usage with reverse access."""
|
|
208
|
+
output = io.StringIO()
|
|
209
|
+
|
|
210
|
+
class Tag(BaseDBModel):
|
|
211
|
+
name: str
|
|
212
|
+
|
|
213
|
+
class Article(BaseDBModel):
|
|
214
|
+
title: str
|
|
215
|
+
tags: ManyToMany[Tag] = ManyToMany(Tag, related_name="articles")
|
|
216
|
+
|
|
217
|
+
db = SqliterDB(memory=True)
|
|
218
|
+
db.create_table(Tag)
|
|
219
|
+
db.create_table(Article)
|
|
220
|
+
|
|
221
|
+
article = db.insert(Article(title="ORM Guide"))
|
|
222
|
+
python = db.insert(Tag(name="python"))
|
|
223
|
+
orm = db.insert(Tag(name="orm"))
|
|
224
|
+
|
|
225
|
+
article.tags.add(python, orm)
|
|
226
|
+
output.write("Article tags:\n")
|
|
227
|
+
for tag in article.tags.fetch_all():
|
|
228
|
+
output.write(f" - {tag.name}\n")
|
|
229
|
+
|
|
230
|
+
output.write("\nReverse access (tag.articles):\n")
|
|
231
|
+
entries = cast("Any", python.articles).fetch_all()
|
|
232
|
+
for entry in entries:
|
|
233
|
+
output.write(f" - {entry.title}\n")
|
|
234
|
+
|
|
235
|
+
db.close()
|
|
236
|
+
return output.getvalue()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _run_many_to_many_symmetrical() -> str:
|
|
240
|
+
"""Show symmetrical self-referential many-to-many."""
|
|
241
|
+
output = io.StringIO()
|
|
242
|
+
|
|
243
|
+
class User(BaseDBModel):
|
|
244
|
+
name: str
|
|
245
|
+
friends: ManyToMany[User] = ManyToMany("User", symmetrical=True)
|
|
246
|
+
|
|
247
|
+
db = SqliterDB(memory=True)
|
|
248
|
+
db.create_table(User)
|
|
249
|
+
|
|
250
|
+
alice = db.insert(User(name="Alice"))
|
|
251
|
+
bob = db.insert(User(name="Bob"))
|
|
252
|
+
|
|
253
|
+
alice.friends.add(bob)
|
|
254
|
+
|
|
255
|
+
output.write("Alice's friends:\n")
|
|
256
|
+
for friend in alice.friends.fetch_all():
|
|
257
|
+
output.write(f" - {friend.name}\n")
|
|
258
|
+
|
|
259
|
+
output.write("\nBob's friends (symmetrical):\n")
|
|
260
|
+
for friend in bob.friends.fetch_all():
|
|
261
|
+
output.write(f" - {friend.name}\n")
|
|
262
|
+
|
|
263
|
+
db.close()
|
|
264
|
+
return output.getvalue()
|
|
265
|
+
|
|
266
|
+
|
|
206
267
|
def _run_select_related_basic() -> str:
|
|
207
268
|
"""Demonstrate eager loading with select_related().
|
|
208
269
|
|
|
@@ -424,6 +485,22 @@ def get_category() -> DemoCategory:
|
|
|
424
485
|
code=extract_demo_code(_run_reverse_relationships),
|
|
425
486
|
execute=_run_reverse_relationships,
|
|
426
487
|
),
|
|
488
|
+
Demo(
|
|
489
|
+
id="orm_m2m_basic",
|
|
490
|
+
title="Many-to-Many Basics",
|
|
491
|
+
description="Relate records with a junction table",
|
|
492
|
+
category="orm",
|
|
493
|
+
code=extract_demo_code(_run_many_to_many_basic),
|
|
494
|
+
execute=_run_many_to_many_basic,
|
|
495
|
+
),
|
|
496
|
+
Demo(
|
|
497
|
+
id="orm_m2m_symmetrical",
|
|
498
|
+
title="Many-to-Many Symmetry",
|
|
499
|
+
description="Self-referential symmetrical relationships",
|
|
500
|
+
category="orm",
|
|
501
|
+
code=extract_demo_code(_run_many_to_many_symmetrical),
|
|
502
|
+
execute=_run_many_to_many_symmetrical,
|
|
503
|
+
),
|
|
427
504
|
Demo(
|
|
428
505
|
id="orm_select_related",
|
|
429
506
|
title="Eager Loading with select_related()",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqliter-py
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.17.0
|
|
4
4
|
Summary: Interact with SQLite databases using Python and Pydantic
|
|
5
5
|
Author: Grant Ramsay
|
|
6
6
|
Author-email: Grant Ramsay <grant@gnramsay.com>
|
|
@@ -50,8 +50,7 @@ is Pydantic itself.
|
|
|
50
50
|
|
|
51
51
|
It does not aim to be a full-fledged ORM like SQLAlchemy, but rather a simple
|
|
52
52
|
and easy-to-use library for basic database operations, especially for small
|
|
53
|
-
projects. It is NOT asynchronous
|
|
54
|
-
time).
|
|
53
|
+
projects. It is NOT asynchronous (at this time, though that is planned).
|
|
55
54
|
|
|
56
55
|
The ideal use case is more for Python CLI tools that need to store data in a
|
|
57
56
|
database-like format without needing to learn SQL or use a full ORM.
|
|
@@ -60,11 +59,10 @@ Full documentation is available on the [Website](https://sqliter.grantramsay.dev
|
|
|
60
59
|
|
|
61
60
|
> [!CAUTION]
|
|
62
61
|
>
|
|
63
|
-
> This project is still in
|
|
64
|
-
>
|
|
65
|
-
>
|
|
66
|
-
>
|
|
67
|
-
> breaking changes.
|
|
62
|
+
> This project is still in development and is lacking some planned
|
|
63
|
+
> functionality. Please use with caution - Classes and methods may change until
|
|
64
|
+
> a stable release is made. I'll try to keep this to an absolute minimum and the
|
|
65
|
+
> releases and documentation will be very clear about any breaking changes.
|
|
68
66
|
>
|
|
69
67
|
> See the [TODO](TODO.md) for planned features and improvements.
|
|
70
68
|
|