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/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