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/m2m.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"""Many-to-many relationship support for ORM mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import (
|
|
8
|
+
TYPE_CHECKING,
|
|
9
|
+
Any,
|
|
10
|
+
Generic,
|
|
11
|
+
Optional,
|
|
12
|
+
Protocol,
|
|
13
|
+
TypeVar,
|
|
14
|
+
Union,
|
|
15
|
+
cast,
|
|
16
|
+
overload,
|
|
17
|
+
runtime_checkable,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from pydantic_core import core_schema
|
|
21
|
+
|
|
22
|
+
from sqliter.exceptions import ManyToManyIntegrityError, TableCreationError
|
|
23
|
+
from sqliter.helpers import validate_table_name
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
|
+
from pydantic import GetCoreSchemaHandler
|
|
27
|
+
|
|
28
|
+
from sqliter.model.model import BaseDBModel
|
|
29
|
+
from sqliter.query.query import QueryBuilder
|
|
30
|
+
from sqliter.sqliter import SqliterDB
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class HasPKAndContext(Protocol):
|
|
37
|
+
"""Protocol for model instances with pk and db_context."""
|
|
38
|
+
|
|
39
|
+
pk: Optional[int]
|
|
40
|
+
db_context: Optional[Any]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ManyToManyInfo:
|
|
45
|
+
"""Metadata for a many-to-many relationship.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
to_model: The target model class.
|
|
49
|
+
through: Custom junction table name (auto-generated if None).
|
|
50
|
+
related_name: Name for the reverse accessor on the target model.
|
|
51
|
+
symmetrical: Whether self-referential relationships are symmetric.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
to_model: type[Any] | str
|
|
55
|
+
through: Optional[str] = None
|
|
56
|
+
related_name: Optional[str] = field(default=None)
|
|
57
|
+
symmetrical: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ManyToManyOptions:
|
|
62
|
+
"""Options for M2M manager behavior."""
|
|
63
|
+
|
|
64
|
+
symmetrical: bool = False
|
|
65
|
+
swap_columns: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _m2m_column_names(table_a: str, table_b: str) -> tuple[str, str]:
|
|
69
|
+
"""Return junction table column names.
|
|
70
|
+
|
|
71
|
+
Uses left/right suffixes for self-referential relationships to avoid
|
|
72
|
+
duplicate column names.
|
|
73
|
+
"""
|
|
74
|
+
if table_a == table_b:
|
|
75
|
+
return (f"{table_a}_pk_left", f"{table_b}_pk_right")
|
|
76
|
+
return (f"{table_a}_pk", f"{table_b}_pk")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ManyToManyManager(Generic[T]):
|
|
80
|
+
"""Manager for M2M relationships on a model instance.
|
|
81
|
+
|
|
82
|
+
Provides methods to add, remove, clear, set, and query related
|
|
83
|
+
objects through a junction table.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
instance: HasPKAndContext,
|
|
89
|
+
to_model: type[T],
|
|
90
|
+
from_model: type[Any],
|
|
91
|
+
junction_table: str,
|
|
92
|
+
db_context: Optional[SqliterDB],
|
|
93
|
+
options: Optional[ManyToManyOptions] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Initialize M2M manager.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
instance: The model instance owning this relationship.
|
|
99
|
+
to_model: The target model class.
|
|
100
|
+
from_model: The source model class.
|
|
101
|
+
junction_table: Name of the junction table.
|
|
102
|
+
db_context: Database connection for queries.
|
|
103
|
+
options: M2M manager options (symmetry, column swapping).
|
|
104
|
+
"""
|
|
105
|
+
manager_options = options or ManyToManyOptions()
|
|
106
|
+
self._instance = instance
|
|
107
|
+
self._to_model = to_model
|
|
108
|
+
self._from_model = from_model
|
|
109
|
+
self._junction_table = junction_table
|
|
110
|
+
self._db = db_context
|
|
111
|
+
|
|
112
|
+
# Column names based on alphabetically sorted table names
|
|
113
|
+
from_table = cast("type[BaseDBModel]", from_model).get_table_name()
|
|
114
|
+
to_table = cast("type[BaseDBModel]", to_model).get_table_name()
|
|
115
|
+
self._self_ref = from_table == to_table
|
|
116
|
+
self._symmetrical = bool(manager_options.symmetrical and self._self_ref)
|
|
117
|
+
self._from_col, self._to_col = _m2m_column_names(from_table, to_table)
|
|
118
|
+
if manager_options.swap_columns:
|
|
119
|
+
self._from_col, self._to_col = self._to_col, self._from_col
|
|
120
|
+
|
|
121
|
+
def _check_context(self) -> SqliterDB:
|
|
122
|
+
"""Verify db_context and pk are available.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The database context.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ManyToManyIntegrityError: If no db_context or no pk.
|
|
129
|
+
"""
|
|
130
|
+
if self._db is None:
|
|
131
|
+
msg = (
|
|
132
|
+
"No database context available. "
|
|
133
|
+
"Insert the instance first or use within a db context."
|
|
134
|
+
)
|
|
135
|
+
raise ManyToManyIntegrityError(msg)
|
|
136
|
+
pk = getattr(self._instance, "pk", None)
|
|
137
|
+
if not pk:
|
|
138
|
+
msg = (
|
|
139
|
+
"Instance has no primary key. "
|
|
140
|
+
"Insert the instance before managing relationships."
|
|
141
|
+
)
|
|
142
|
+
raise ManyToManyIntegrityError(msg)
|
|
143
|
+
return self._db
|
|
144
|
+
|
|
145
|
+
def _rollback_if_needed(self, db: SqliterDB) -> None:
|
|
146
|
+
"""Rollback implicit transaction when not in a user-managed one."""
|
|
147
|
+
if not db._in_transaction and db.conn: # noqa: SLF001
|
|
148
|
+
db.conn.rollback()
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _raise_missing_pk() -> None:
|
|
152
|
+
"""Raise a consistent missing-pk error for related instances."""
|
|
153
|
+
msg = (
|
|
154
|
+
"Related instance has no primary key. "
|
|
155
|
+
"Insert it before adding to a relationship."
|
|
156
|
+
)
|
|
157
|
+
raise ManyToManyIntegrityError(msg)
|
|
158
|
+
|
|
159
|
+
def _get_instance_pk(self) -> int:
|
|
160
|
+
"""Get the primary key of the owning instance.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The primary key value.
|
|
164
|
+
"""
|
|
165
|
+
# pk is guaranteed non-None after _check_context()
|
|
166
|
+
return int(self._instance.pk) # type: ignore[arg-type]
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _as_filter_list(
|
|
170
|
+
pks: list[int],
|
|
171
|
+
) -> list[Union[str, int, float, bool]]:
|
|
172
|
+
"""Cast pk list for QueryBuilder.filter() compatibility.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
pks: List of integer primary keys.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The same list cast to the FilterValue list type.
|
|
179
|
+
"""
|
|
180
|
+
return cast("list[Union[str, int, float, bool]]", pks)
|
|
181
|
+
|
|
182
|
+
def _fetch_related_pks(self) -> list[int]:
|
|
183
|
+
"""Fetch PKs of related objects from the junction table.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of related object primary keys.
|
|
187
|
+
"""
|
|
188
|
+
if self._db is None:
|
|
189
|
+
return []
|
|
190
|
+
pk = getattr(self._instance, "pk", None)
|
|
191
|
+
if not pk:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
conn = self._db.connect()
|
|
195
|
+
cursor = conn.cursor()
|
|
196
|
+
if self._symmetrical:
|
|
197
|
+
sql = (
|
|
198
|
+
f'SELECT CASE WHEN "{self._from_col}" = ? ' # noqa: S608
|
|
199
|
+
f'THEN "{self._to_col}" ELSE "{self._from_col}" END '
|
|
200
|
+
f'FROM "{self._junction_table}" '
|
|
201
|
+
f'WHERE "{self._from_col}" = ? OR "{self._to_col}" = ?'
|
|
202
|
+
)
|
|
203
|
+
cursor.execute(sql, (pk, pk, pk))
|
|
204
|
+
else:
|
|
205
|
+
sql = (
|
|
206
|
+
f'SELECT "{self._to_col}" FROM "{self._junction_table}" ' # noqa: S608
|
|
207
|
+
f'WHERE "{self._from_col}" = ?'
|
|
208
|
+
)
|
|
209
|
+
cursor.execute(sql, (pk,))
|
|
210
|
+
return [row[0] for row in cursor.fetchall()]
|
|
211
|
+
|
|
212
|
+
def add(self, *instances: T) -> None:
|
|
213
|
+
"""Add one or more related objects.
|
|
214
|
+
|
|
215
|
+
Duplicates are silently ignored (INSERT OR IGNORE).
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
*instances: Model instances to relate.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ManyToManyIntegrityError: If no db_context, no pk, or
|
|
222
|
+
a target instance has no pk.
|
|
223
|
+
"""
|
|
224
|
+
db = self._check_context()
|
|
225
|
+
from_pk = self._get_instance_pk()
|
|
226
|
+
|
|
227
|
+
sql = (
|
|
228
|
+
f'INSERT OR IGNORE INTO "{self._junction_table}" ' # noqa: S608
|
|
229
|
+
f'("{self._from_col}", "{self._to_col}") VALUES (?, ?)'
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
conn = db.connect()
|
|
233
|
+
cursor = conn.cursor()
|
|
234
|
+
try:
|
|
235
|
+
for inst in instances:
|
|
236
|
+
to_pk = getattr(inst, "pk", None)
|
|
237
|
+
if not to_pk:
|
|
238
|
+
self._raise_missing_pk()
|
|
239
|
+
to_pk = cast("int", to_pk)
|
|
240
|
+
if self._symmetrical:
|
|
241
|
+
left_pk, right_pk = sorted([from_pk, to_pk])
|
|
242
|
+
cursor.execute(sql, (left_pk, right_pk))
|
|
243
|
+
else:
|
|
244
|
+
cursor.execute(sql, (from_pk, to_pk))
|
|
245
|
+
except Exception:
|
|
246
|
+
self._rollback_if_needed(db)
|
|
247
|
+
raise
|
|
248
|
+
|
|
249
|
+
db._maybe_commit() # noqa: SLF001
|
|
250
|
+
|
|
251
|
+
def remove(self, *instances: T) -> None:
|
|
252
|
+
"""Remove one or more related objects.
|
|
253
|
+
|
|
254
|
+
Nonexistent relationships are silently ignored.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
*instances: Model instances to unrelate.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
ManyToManyIntegrityError: If no db_context or no pk.
|
|
261
|
+
"""
|
|
262
|
+
db = self._check_context()
|
|
263
|
+
from_pk = self._get_instance_pk()
|
|
264
|
+
|
|
265
|
+
sql = (
|
|
266
|
+
f'DELETE FROM "{self._junction_table}" ' # noqa: S608
|
|
267
|
+
f'WHERE "{self._from_col}" = ? AND "{self._to_col}" = ?'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
conn = db.connect()
|
|
271
|
+
cursor = conn.cursor()
|
|
272
|
+
try:
|
|
273
|
+
for inst in instances:
|
|
274
|
+
to_pk = getattr(inst, "pk", None)
|
|
275
|
+
if to_pk:
|
|
276
|
+
to_pk = cast("int", to_pk)
|
|
277
|
+
if self._symmetrical:
|
|
278
|
+
left_pk, right_pk = sorted([from_pk, to_pk])
|
|
279
|
+
cursor.execute(sql, (left_pk, right_pk))
|
|
280
|
+
else:
|
|
281
|
+
cursor.execute(sql, (from_pk, to_pk))
|
|
282
|
+
except Exception:
|
|
283
|
+
self._rollback_if_needed(db)
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
db._maybe_commit() # noqa: SLF001
|
|
287
|
+
|
|
288
|
+
def clear(self) -> None:
|
|
289
|
+
"""Remove all relationships for this instance.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ManyToManyIntegrityError: If no db_context or no pk.
|
|
293
|
+
"""
|
|
294
|
+
db = self._check_context()
|
|
295
|
+
from_pk = self._get_instance_pk()
|
|
296
|
+
|
|
297
|
+
params: tuple[int, ...]
|
|
298
|
+
if self._symmetrical:
|
|
299
|
+
sql = (
|
|
300
|
+
f'DELETE FROM "{self._junction_table}" ' # noqa: S608
|
|
301
|
+
f'WHERE "{self._from_col}" = ? OR "{self._to_col}" = ?'
|
|
302
|
+
)
|
|
303
|
+
params = (from_pk, from_pk)
|
|
304
|
+
else:
|
|
305
|
+
sql = (
|
|
306
|
+
f'DELETE FROM "{self._junction_table}" ' # noqa: S608
|
|
307
|
+
f'WHERE "{self._from_col}" = ?'
|
|
308
|
+
)
|
|
309
|
+
params = (from_pk,)
|
|
310
|
+
|
|
311
|
+
conn = db.connect()
|
|
312
|
+
cursor = conn.cursor()
|
|
313
|
+
try:
|
|
314
|
+
cursor.execute(sql, params)
|
|
315
|
+
except Exception:
|
|
316
|
+
self._rollback_if_needed(db)
|
|
317
|
+
raise
|
|
318
|
+
finally:
|
|
319
|
+
cursor.close()
|
|
320
|
+
db._maybe_commit() # noqa: SLF001
|
|
321
|
+
|
|
322
|
+
def set(self, *instances: T) -> None:
|
|
323
|
+
"""Replace all relationships with the given instances.
|
|
324
|
+
|
|
325
|
+
Clears existing relationships then adds the new ones.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
*instances: Model instances to set as the new related set.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
ManyToManyIntegrityError: If no db_context or no pk.
|
|
332
|
+
"""
|
|
333
|
+
self.clear()
|
|
334
|
+
if instances:
|
|
335
|
+
self.add(*instances)
|
|
336
|
+
|
|
337
|
+
def fetch_all(self) -> list[T]:
|
|
338
|
+
"""Fetch all related objects.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
List of related model instances.
|
|
342
|
+
"""
|
|
343
|
+
pks = self._fetch_related_pks()
|
|
344
|
+
if not pks or self._db is None:
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
model = cast("type[BaseDBModel]", self._to_model)
|
|
348
|
+
return cast(
|
|
349
|
+
"list[T]",
|
|
350
|
+
self._db.select(model)
|
|
351
|
+
.filter(pk__in=self._as_filter_list(pks))
|
|
352
|
+
.fetch_all(),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def fetch_one(self) -> Optional[T]:
|
|
356
|
+
"""Fetch a single related object.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
A related model instance, or None.
|
|
360
|
+
"""
|
|
361
|
+
pks = self._fetch_related_pks()
|
|
362
|
+
if not pks or self._db is None:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
model = cast("type[BaseDBModel]", self._to_model)
|
|
366
|
+
pk_filter = self._as_filter_list(pks)
|
|
367
|
+
results = (
|
|
368
|
+
self._db.select(model).filter(pk__in=pk_filter).limit(1).fetch_all()
|
|
369
|
+
)
|
|
370
|
+
return cast("Optional[T]", results[0]) if results else None
|
|
371
|
+
|
|
372
|
+
def count(self) -> int:
|
|
373
|
+
"""Count related objects via the junction table.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Number of related objects.
|
|
377
|
+
"""
|
|
378
|
+
if self._db is None:
|
|
379
|
+
return 0
|
|
380
|
+
pk = getattr(self._instance, "pk", None)
|
|
381
|
+
if not pk:
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
params: tuple[int, ...]
|
|
385
|
+
if self._symmetrical:
|
|
386
|
+
sql = (
|
|
387
|
+
f'SELECT COUNT(*) FROM "{self._junction_table}" ' # noqa: S608
|
|
388
|
+
f'WHERE "{self._from_col}" = ? OR "{self._to_col}" = ?'
|
|
389
|
+
)
|
|
390
|
+
params = (pk, pk)
|
|
391
|
+
else:
|
|
392
|
+
sql = (
|
|
393
|
+
f'SELECT COUNT(*) FROM "{self._junction_table}" ' # noqa: S608
|
|
394
|
+
f'WHERE "{self._from_col}" = ?'
|
|
395
|
+
)
|
|
396
|
+
params = (pk,)
|
|
397
|
+
conn = self._db.connect()
|
|
398
|
+
cursor = conn.cursor()
|
|
399
|
+
cursor.execute(sql, params)
|
|
400
|
+
row = cursor.fetchone()
|
|
401
|
+
return int(row[0]) if row else 0
|
|
402
|
+
|
|
403
|
+
def exists(self) -> bool:
|
|
404
|
+
"""Check if any related objects exist.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
True if at least one related object exists.
|
|
408
|
+
"""
|
|
409
|
+
return self.count() > 0
|
|
410
|
+
|
|
411
|
+
def filter(
|
|
412
|
+
self,
|
|
413
|
+
**kwargs: Any, # noqa: ANN401
|
|
414
|
+
) -> QueryBuilder[Any]:
|
|
415
|
+
"""Return a QueryBuilder filtered to related objects.
|
|
416
|
+
|
|
417
|
+
Allows full chaining (order_by, limit, offset, etc.).
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
**kwargs: Additional filter criteria.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
A QueryBuilder instance.
|
|
424
|
+
|
|
425
|
+
Raises:
|
|
426
|
+
ManyToManyIntegrityError: If no db_context or no pk.
|
|
427
|
+
"""
|
|
428
|
+
db = self._check_context()
|
|
429
|
+
model = cast("type[BaseDBModel]", self._to_model)
|
|
430
|
+
pks = self._fetch_related_pks()
|
|
431
|
+
if not pks:
|
|
432
|
+
return db.select(model).filter(pk__in=[-1], **kwargs)
|
|
433
|
+
pk_filter = self._as_filter_list(pks)
|
|
434
|
+
return db.select(model).filter(pk__in=pk_filter, **kwargs)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class ManyToMany(Generic[T]):
|
|
438
|
+
"""Descriptor for many-to-many relationship fields.
|
|
439
|
+
|
|
440
|
+
Usage:
|
|
441
|
+
class Article(BaseDBModel):
|
|
442
|
+
title: str
|
|
443
|
+
tags: ManyToMany[Tag] = ManyToMany(Tag)
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
def __init__(
|
|
447
|
+
self,
|
|
448
|
+
to_model: type[T] | str,
|
|
449
|
+
*,
|
|
450
|
+
through: Optional[str] = None,
|
|
451
|
+
related_name: Optional[str] = None,
|
|
452
|
+
symmetrical: bool = False,
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Initialize M2M descriptor.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
to_model: The related model class (or string forward ref).
|
|
458
|
+
through: Custom junction table name.
|
|
459
|
+
related_name: Name for the reverse accessor on the target.
|
|
460
|
+
symmetrical: If True, self-referential relationships are symmetric.
|
|
461
|
+
"""
|
|
462
|
+
if through is not None:
|
|
463
|
+
validate_table_name(through)
|
|
464
|
+
self.to_model = to_model
|
|
465
|
+
self.m2m_info = ManyToManyInfo(
|
|
466
|
+
to_model=to_model,
|
|
467
|
+
through=through,
|
|
468
|
+
related_name=related_name,
|
|
469
|
+
symmetrical=symmetrical,
|
|
470
|
+
)
|
|
471
|
+
self.related_name = related_name
|
|
472
|
+
self.name: Optional[str] = None
|
|
473
|
+
self.owner: Optional[type] = None
|
|
474
|
+
self._junction_table: Optional[str] = None
|
|
475
|
+
|
|
476
|
+
@classmethod
|
|
477
|
+
def __get_pydantic_core_schema__(
|
|
478
|
+
cls,
|
|
479
|
+
source_type: type[Any],
|
|
480
|
+
handler: GetCoreSchemaHandler,
|
|
481
|
+
) -> core_schema.CoreSchema:
|
|
482
|
+
"""Prevent Pydantic from copying descriptor to instance."""
|
|
483
|
+
return core_schema.no_info_plain_validator_function(
|
|
484
|
+
function=lambda _: None
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _get_junction_table_name(self, owner: type[Any]) -> str:
|
|
488
|
+
"""Compute the junction table name.
|
|
489
|
+
|
|
490
|
+
Alphabetically sorts the two table names and joins with '_'.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
owner: The model class owning this descriptor.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
The junction table name.
|
|
497
|
+
"""
|
|
498
|
+
if self.m2m_info.through:
|
|
499
|
+
return self.m2m_info.through
|
|
500
|
+
|
|
501
|
+
owner_model = cast("type[BaseDBModel]", owner)
|
|
502
|
+
target_model = cast("type[BaseDBModel]", self.to_model)
|
|
503
|
+
owner_table = owner_model.get_table_name()
|
|
504
|
+
target_table = target_model.get_table_name()
|
|
505
|
+
sorted_names = sorted([owner_table, target_table])
|
|
506
|
+
return f"{sorted_names[0]}_{sorted_names[1]}"
|
|
507
|
+
|
|
508
|
+
def resolve_forward_ref(self, model_class: type[Any]) -> None:
|
|
509
|
+
"""Resolve a string forward ref to a concrete model class."""
|
|
510
|
+
self.to_model = model_class
|
|
511
|
+
self.m2m_info.to_model = model_class
|
|
512
|
+
if self._junction_table is None and self.owner is not None:
|
|
513
|
+
self._junction_table = self._get_junction_table_name(self.owner)
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def junction_table(self) -> Optional[str]:
|
|
517
|
+
"""Return the resolved junction table name, if available."""
|
|
518
|
+
return self._junction_table
|
|
519
|
+
|
|
520
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
521
|
+
"""Called during class creation to register the M2M field.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
owner: The model class.
|
|
525
|
+
name: The attribute name.
|
|
526
|
+
"""
|
|
527
|
+
self.name = name
|
|
528
|
+
self.owner = owner
|
|
529
|
+
if isinstance(self.to_model, str):
|
|
530
|
+
if self.to_model == owner.__name__:
|
|
531
|
+
self.resolve_forward_ref(owner)
|
|
532
|
+
elif self.m2m_info.through:
|
|
533
|
+
self._junction_table = self.m2m_info.through
|
|
534
|
+
else:
|
|
535
|
+
self._junction_table = self._get_junction_table_name(owner)
|
|
536
|
+
|
|
537
|
+
# Store in class's own m2m_descriptors
|
|
538
|
+
if "m2m_descriptors" not in owner.__dict__:
|
|
539
|
+
owner.m2m_descriptors = {} # type: ignore[attr-defined]
|
|
540
|
+
owner.m2m_descriptors[name] = self # type: ignore[attr-defined]
|
|
541
|
+
|
|
542
|
+
self_ref = owner is self.to_model
|
|
543
|
+
|
|
544
|
+
# Auto-generate related_name if not provided
|
|
545
|
+
if self.related_name is None and not (
|
|
546
|
+
self_ref and self.m2m_info.symmetrical
|
|
547
|
+
):
|
|
548
|
+
base_name = owner.__name__.lower()
|
|
549
|
+
try:
|
|
550
|
+
import inflect # noqa: PLC0415
|
|
551
|
+
|
|
552
|
+
p = inflect.engine()
|
|
553
|
+
self.related_name = p.plural(base_name)
|
|
554
|
+
except ImportError:
|
|
555
|
+
self.related_name = (
|
|
556
|
+
base_name if base_name.endswith("s") else base_name + "s"
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
self.m2m_info.related_name = self.related_name
|
|
560
|
+
|
|
561
|
+
# Register with ModelRegistry
|
|
562
|
+
from sqliter.orm.registry import ModelRegistry # noqa: PLC0415
|
|
563
|
+
|
|
564
|
+
if isinstance(self.to_model, str):
|
|
565
|
+
ModelRegistry.add_pending_m2m_relationship(
|
|
566
|
+
from_model=owner,
|
|
567
|
+
to_model_name=self.to_model,
|
|
568
|
+
m2m_field=name,
|
|
569
|
+
related_name=self.related_name,
|
|
570
|
+
symmetrical=self.m2m_info.symmetrical,
|
|
571
|
+
descriptor=self,
|
|
572
|
+
)
|
|
573
|
+
else:
|
|
574
|
+
if self.junction_table is None:
|
|
575
|
+
msg = "ManyToMany junction table could not be resolved."
|
|
576
|
+
raise ValueError(msg)
|
|
577
|
+
ModelRegistry.add_m2m_relationship(
|
|
578
|
+
from_model=owner,
|
|
579
|
+
to_model=self.to_model,
|
|
580
|
+
m2m_field=name,
|
|
581
|
+
junction_table=self.junction_table,
|
|
582
|
+
related_name=self.related_name,
|
|
583
|
+
symmetrical=self.m2m_info.symmetrical,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
@overload
|
|
587
|
+
def __get__(self, instance: None, owner: type[object]) -> ManyToMany[T]: ...
|
|
588
|
+
|
|
589
|
+
@overload
|
|
590
|
+
def __get__(
|
|
591
|
+
self, instance: object, owner: type[object]
|
|
592
|
+
) -> ManyToManyManager[T]: ...
|
|
593
|
+
|
|
594
|
+
def __get__(
|
|
595
|
+
self, instance: Optional[object], owner: type[object]
|
|
596
|
+
) -> Union[ManyToMany[T], ManyToManyManager[T]]:
|
|
597
|
+
"""Return ManyToManyManager on instance, descriptor on class.
|
|
598
|
+
|
|
599
|
+
Args:
|
|
600
|
+
instance: Model instance or None.
|
|
601
|
+
owner: Model class.
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
ManyToManyManager for instance access, self for class access.
|
|
605
|
+
"""
|
|
606
|
+
if instance is None:
|
|
607
|
+
return self
|
|
608
|
+
|
|
609
|
+
if isinstance(self.to_model, str):
|
|
610
|
+
msg = (
|
|
611
|
+
"ManyToMany target model is unresolved. "
|
|
612
|
+
"Define the target model class before accessing the "
|
|
613
|
+
"relationship."
|
|
614
|
+
)
|
|
615
|
+
raise TypeError(msg)
|
|
616
|
+
|
|
617
|
+
return ManyToManyManager(
|
|
618
|
+
instance=cast("HasPKAndContext", instance),
|
|
619
|
+
to_model=self.to_model,
|
|
620
|
+
from_model=owner,
|
|
621
|
+
junction_table=self._junction_table or "",
|
|
622
|
+
db_context=getattr(instance, "db_context", None),
|
|
623
|
+
options=ManyToManyOptions(symmetrical=self.m2m_info.symmetrical),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def __set__(self, instance: object, value: object) -> None:
|
|
627
|
+
"""Prevent direct assignment to M2M fields.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
instance: Model instance.
|
|
631
|
+
value: The value being assigned.
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
AttributeError: Always, directing users to use
|
|
635
|
+
add()/remove()/clear()/set().
|
|
636
|
+
"""
|
|
637
|
+
msg = (
|
|
638
|
+
f"Cannot assign to ManyToMany field '{self.name}'. "
|
|
639
|
+
f"Use .add(), .remove(), .clear(), or .set() instead."
|
|
640
|
+
)
|
|
641
|
+
raise AttributeError(msg)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class ReverseManyToMany:
|
|
645
|
+
"""Descriptor for the reverse side of a M2M relationship.
|
|
646
|
+
|
|
647
|
+
Placed automatically on the target model by ModelRegistry.
|
|
648
|
+
"""
|
|
649
|
+
|
|
650
|
+
def __init__(
|
|
651
|
+
self,
|
|
652
|
+
from_model: type[Any],
|
|
653
|
+
to_model: type[Any],
|
|
654
|
+
junction_table: str,
|
|
655
|
+
related_name: str,
|
|
656
|
+
*,
|
|
657
|
+
symmetrical: bool = False,
|
|
658
|
+
) -> None:
|
|
659
|
+
"""Initialize reverse M2M descriptor.
|
|
660
|
+
|
|
661
|
+
Args:
|
|
662
|
+
from_model: The model that defined the ManyToMany field.
|
|
663
|
+
to_model: The target model (where this descriptor lives).
|
|
664
|
+
junction_table: Name of the junction table.
|
|
665
|
+
related_name: Name of this reverse accessor.
|
|
666
|
+
symmetrical: Whether self-referential relationships are symmetric.
|
|
667
|
+
"""
|
|
668
|
+
self._from_model = from_model
|
|
669
|
+
self._to_model = to_model
|
|
670
|
+
self._junction_table = junction_table
|
|
671
|
+
self._related_name = related_name
|
|
672
|
+
self._symmetrical = symmetrical
|
|
673
|
+
|
|
674
|
+
@overload
|
|
675
|
+
def __get__(
|
|
676
|
+
self, instance: None, owner: type[object]
|
|
677
|
+
) -> ReverseManyToMany: ...
|
|
678
|
+
|
|
679
|
+
@overload
|
|
680
|
+
def __get__(
|
|
681
|
+
self, instance: object, owner: type[object]
|
|
682
|
+
) -> ManyToManyManager[Any]: ...
|
|
683
|
+
|
|
684
|
+
def __get__(
|
|
685
|
+
self, instance: Optional[object], owner: type[object]
|
|
686
|
+
) -> Union[ReverseManyToMany, ManyToManyManager[Any]]:
|
|
687
|
+
"""Return ManyToManyManager with from/to swapped.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
instance: Model instance or None.
|
|
691
|
+
owner: Model class.
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
ManyToManyManager (reversed) on instance, self on class.
|
|
695
|
+
"""
|
|
696
|
+
if instance is None:
|
|
697
|
+
return self
|
|
698
|
+
|
|
699
|
+
# Swap from/to so queries work from the reverse side
|
|
700
|
+
return ManyToManyManager(
|
|
701
|
+
instance=cast("HasPKAndContext", instance),
|
|
702
|
+
to_model=self._from_model,
|
|
703
|
+
from_model=self._to_model,
|
|
704
|
+
junction_table=self._junction_table,
|
|
705
|
+
db_context=getattr(instance, "db_context", None),
|
|
706
|
+
options=ManyToManyOptions(
|
|
707
|
+
symmetrical=self._symmetrical,
|
|
708
|
+
swap_columns=self._from_model is self._to_model
|
|
709
|
+
and not self._symmetrical,
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
def __set__(self, instance: object, value: object) -> None:
|
|
714
|
+
"""Prevent direct assignment to reverse M2M.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
instance: Model instance.
|
|
718
|
+
value: The value being assigned.
|
|
719
|
+
|
|
720
|
+
Raises:
|
|
721
|
+
AttributeError: Always.
|
|
722
|
+
"""
|
|
723
|
+
msg = (
|
|
724
|
+
f"Cannot assign to reverse ManyToMany "
|
|
725
|
+
f"'{self._related_name}'. "
|
|
726
|
+
f"Use .add(), .remove(), .clear(), or .set() instead."
|
|
727
|
+
)
|
|
728
|
+
raise AttributeError(msg)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def create_junction_table(
|
|
732
|
+
db: SqliterDB,
|
|
733
|
+
junction_table: str,
|
|
734
|
+
table_a: str,
|
|
735
|
+
table_b: str,
|
|
736
|
+
) -> None:
|
|
737
|
+
"""Create a junction table for a M2M relationship.
|
|
738
|
+
|
|
739
|
+
The table has FK columns for both sides with CASCADE constraints
|
|
740
|
+
and a UNIQUE constraint on the pair.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
db: The database instance.
|
|
744
|
+
junction_table: Name of the junction table.
|
|
745
|
+
table_a: First table name (alphabetically first).
|
|
746
|
+
table_b: Second table name (alphabetically second).
|
|
747
|
+
"""
|
|
748
|
+
col_a, col_b = _m2m_column_names(table_a, table_b)
|
|
749
|
+
|
|
750
|
+
create_sql = (
|
|
751
|
+
f'CREATE TABLE IF NOT EXISTS "{junction_table}" ('
|
|
752
|
+
f'"id" INTEGER PRIMARY KEY AUTOINCREMENT, '
|
|
753
|
+
f'"{col_a}" INTEGER NOT NULL, '
|
|
754
|
+
f'"{col_b}" INTEGER NOT NULL, '
|
|
755
|
+
f'FOREIGN KEY ("{col_a}") REFERENCES "{table_a}"("pk") '
|
|
756
|
+
f"ON DELETE CASCADE ON UPDATE CASCADE, "
|
|
757
|
+
f'FOREIGN KEY ("{col_b}") REFERENCES "{table_b}"("pk") '
|
|
758
|
+
f"ON DELETE CASCADE ON UPDATE CASCADE, "
|
|
759
|
+
f'UNIQUE ("{col_a}", "{col_b}")'
|
|
760
|
+
f")"
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
conn = db.connect()
|
|
765
|
+
cursor = conn.cursor()
|
|
766
|
+
cursor.execute(create_sql)
|
|
767
|
+
conn.commit()
|
|
768
|
+
except sqlite3.Error as exc:
|
|
769
|
+
raise TableCreationError(junction_table) from exc
|
|
770
|
+
|
|
771
|
+
# Create indexes on both FK columns
|
|
772
|
+
for col in (col_a, col_b):
|
|
773
|
+
index_sql = (
|
|
774
|
+
f"CREATE INDEX IF NOT EXISTS "
|
|
775
|
+
f'"idx_{junction_table}_{col}" '
|
|
776
|
+
f'ON "{junction_table}" ("{col}")'
|
|
777
|
+
)
|
|
778
|
+
try:
|
|
779
|
+
conn = db.connect()
|
|
780
|
+
cursor = conn.cursor()
|
|
781
|
+
cursor.execute(index_sql)
|
|
782
|
+
conn.commit()
|
|
783
|
+
except sqlite3.Error:
|
|
784
|
+
pass # Non-critical: index creation failure
|