forge-mvc-pivot 1.0.0b16__tar.gz
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.
- forge_mvc_pivot-1.0.0b16/LICENSE +36 -0
- forge_mvc_pivot-1.0.0b16/PKG-INFO +48 -0
- forge_mvc_pivot-1.0.0b16/README.md +27 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot/__init__.py +24 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot/make_pivot_crud.py +489 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot/service.py +363 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot.egg-info/PKG-INFO +48 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot.egg-info/SOURCES.txt +11 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot.egg-info/dependency_links.txt +1 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot.egg-info/requires.txt +1 -0
- forge_mvc_pivot-1.0.0b16/forge_mvc_pivot.egg-info/top_level.txt +1 -0
- forge_mvc_pivot-1.0.0b16/pyproject.toml +34 -0
- forge_mvc_pivot-1.0.0b16/setup.cfg +4 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Forge — Licence propriétaire / source disponible
|
|
2
|
+
|
|
3
|
+
Copyright (c) Roger Lequette.
|
|
4
|
+
Tous droits réservés.
|
|
5
|
+
|
|
6
|
+
Le code source de Forge est mis à disposition uniquement pour lecture, étude,
|
|
7
|
+
évaluation et usage éducatif personnel.
|
|
8
|
+
|
|
9
|
+
Sauf autorisation écrite préalable de Roger Lequette, il est interdit de :
|
|
10
|
+
|
|
11
|
+
- utiliser Forge dans un cadre professionnel, commercial ou institutionnel ;
|
|
12
|
+
- utiliser Forge pour une prestation client ;
|
|
13
|
+
- intégrer Forge dans un produit, service, SaaS, application vendue ou solution
|
|
14
|
+
déployée pour un tiers ;
|
|
15
|
+
- redistribuer Forge, modifié ou non ;
|
|
16
|
+
- publier une version modifiée de Forge ;
|
|
17
|
+
- vendre, louer, sous-licencier ou exploiter commercialement Forge ;
|
|
18
|
+
- supprimer ou modifier les mentions de copyright et de licence.
|
|
19
|
+
|
|
20
|
+
Les usages autorisés sans autorisation écrite préalable sont limités à :
|
|
21
|
+
|
|
22
|
+
- lire le code ;
|
|
23
|
+
- étudier son fonctionnement ;
|
|
24
|
+
- tester Forge à titre personnel ;
|
|
25
|
+
- l'utiliser dans un cadre éducatif personnel ou pédagogique non commercial ;
|
|
26
|
+
- évaluer le framework avant une éventuelle demande d'autorisation.
|
|
27
|
+
|
|
28
|
+
Toute utilisation non explicitement autorisée par cette licence nécessite une
|
|
29
|
+
autorisation écrite préalable de Roger Lequette.
|
|
30
|
+
|
|
31
|
+
Cette licence pourra évoluer ultérieurement. Toute version publiée de Forge
|
|
32
|
+
reste soumise à la licence présente dans son dépôt au moment de sa récupération.
|
|
33
|
+
|
|
34
|
+
LE LOGICIEL EST FOURNI « TEL QUEL », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
|
|
35
|
+
IMPLICITE. EN AUCUN CAS LE DÉTENTEUR DU COPYRIGHT NE POURRA ÊTRE TENU
|
|
36
|
+
RESPONSABLE DE TOUT DOMMAGE DÉCOULANT DE L'UTILISATION DE CE LOGICIEL.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-pivot
|
|
3
|
+
Version: 1.0.0b16
|
|
4
|
+
Summary: Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud.
|
|
5
|
+
Author: Roger Lequette
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://forgemvc.com/docs/forge/
|
|
10
|
+
Keywords: python,mvc,forge,pivot,many-to-many
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b16
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# forge-mvc-pivot
|
|
23
|
+
|
|
24
|
+
Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
|
|
25
|
+
portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
|
|
26
|
+
|
|
27
|
+
Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
|
|
28
|
+
générales ; le pivot avancé est une brique spécialisée, optionnelle.
|
|
29
|
+
|
|
30
|
+
## Contenu
|
|
31
|
+
|
|
32
|
+
- `PivotAdvancedService` : lecture et écriture d'associations pivot avec
|
|
33
|
+
attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
|
|
34
|
+
accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
|
|
35
|
+
- `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
|
|
36
|
+
`pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
|
|
37
|
+
- Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
|
|
38
|
+
sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
|
|
39
|
+
`relations.json`.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install --pre forge-mvc-pivot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
|
|
48
|
+
une application qui s'appuie sur un sous-CRUD pivot.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# forge-mvc-pivot
|
|
2
|
+
|
|
3
|
+
Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
|
|
4
|
+
portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
|
|
5
|
+
|
|
6
|
+
Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
|
|
7
|
+
générales ; le pivot avancé est une brique spécialisée, optionnelle.
|
|
8
|
+
|
|
9
|
+
## Contenu
|
|
10
|
+
|
|
11
|
+
- `PivotAdvancedService` : lecture et écriture d'associations pivot avec
|
|
12
|
+
attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
|
|
13
|
+
accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
|
|
14
|
+
- `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
|
|
15
|
+
`pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
|
|
16
|
+
- Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
|
|
17
|
+
sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
|
|
18
|
+
`relations.json`.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install --pre forge-mvc-pivot
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
|
|
27
|
+
une application qui s'appuie sur un sous-CRUD pivot.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""forge-mvc-pivot — Pivot advanced opt-in (extrait du core, ADR-021).
|
|
2
|
+
|
|
3
|
+
Service de persistance pour tables pivot enrichies (associations many_to_many
|
|
4
|
+
avec attributs) et générateur `forge make:pivot-crud`.
|
|
5
|
+
"""
|
|
6
|
+
from forge_mvc_pivot.service import (
|
|
7
|
+
PivotAdvancedService,
|
|
8
|
+
PivotConstraintError,
|
|
9
|
+
PivotFieldConstraint,
|
|
10
|
+
PivotFormError,
|
|
11
|
+
PivotRow,
|
|
12
|
+
pivot_error_to_form_error,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PivotAdvancedService",
|
|
17
|
+
"PivotConstraintError",
|
|
18
|
+
"PivotFieldConstraint",
|
|
19
|
+
"PivotFormError",
|
|
20
|
+
"PivotRow",
|
|
21
|
+
"pivot_error_to_form_error",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
__version__ = "1.0.0b16"
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""forge_mvc_pivot/make_pivot_crud.py — Générateur de sous-CRUD Pivot advanced (opt-in).
|
|
2
|
+
|
|
3
|
+
Commande : forge make:pivot-crud <EntitéSource> <nomRelation> [--dry-run]
|
|
4
|
+
|
|
5
|
+
Analyse une relation many_to_many avec pivot.fields[] et génère une structure
|
|
6
|
+
dédiée minimale sans modifier make:crud.
|
|
7
|
+
|
|
8
|
+
Décision : PIVOT-ADVANCED-004.
|
|
9
|
+
Service runtime : forge_mvc_pivot.PivotAdvancedService (PIVOT-ADVANCED-003).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field as dc_field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import forge_cli.output as out
|
|
20
|
+
|
|
21
|
+
_SNAKE_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _to_snake(name: str) -> str:
|
|
25
|
+
return _SNAKE_RE.sub("_", name).lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Données de la relation résolue ────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ResolvedPivotRelation:
|
|
32
|
+
from_entity: str
|
|
33
|
+
to_entity: str
|
|
34
|
+
relation_name: str
|
|
35
|
+
pivot_table: str
|
|
36
|
+
from_key: str
|
|
37
|
+
to_key: str
|
|
38
|
+
pivot_fields: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Résultat ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class MakePivotCrudResult:
|
|
45
|
+
created: list[Path] = dc_field(default_factory=list)
|
|
46
|
+
preserved: list[Path] = dc_field(default_factory=list)
|
|
47
|
+
dry_run: bool = False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Résolution de la relation ─────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def resolve_pivot_relation(
|
|
53
|
+
source_entity: str,
|
|
54
|
+
relation_name: str,
|
|
55
|
+
*,
|
|
56
|
+
relations_path: Path,
|
|
57
|
+
) -> ResolvedPivotRelation:
|
|
58
|
+
"""Charge relations.json et trouve la relation many_to_many cible.
|
|
59
|
+
|
|
60
|
+
Raises SystemExit avec message clair pour tous les cas d'erreur.
|
|
61
|
+
"""
|
|
62
|
+
if not relations_path.exists():
|
|
63
|
+
print(out.error(
|
|
64
|
+
"mvc/entities/relations.json introuvable.\n"
|
|
65
|
+
"Créez d'abord vos relations avec forge make:relation."
|
|
66
|
+
))
|
|
67
|
+
raise SystemExit(1)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(relations_path.read_text(encoding="utf-8"))
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
print(out.error(f"relations.json invalide (JSON malformé) : {exc}"))
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
|
|
75
|
+
if not isinstance(raw, dict) or raw.get("schema_version") != "1.0":
|
|
76
|
+
print(out.error('relations.json invalide : schema_version "1.0" attendu.'))
|
|
77
|
+
raise SystemExit(1)
|
|
78
|
+
|
|
79
|
+
relations = raw.get("relations")
|
|
80
|
+
if not isinstance(relations, list):
|
|
81
|
+
print(out.error("relations.json invalide : clé \"relations\" manquante ou invalide."))
|
|
82
|
+
raise SystemExit(1)
|
|
83
|
+
|
|
84
|
+
candidates = [
|
|
85
|
+
r for r in relations
|
|
86
|
+
if isinstance(r, dict)
|
|
87
|
+
and r.get("type") == "many_to_many"
|
|
88
|
+
and r.get("from") == source_entity
|
|
89
|
+
and r.get("name") == relation_name
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
if not candidates:
|
|
93
|
+
# Distinguer les cas d'erreur
|
|
94
|
+
source_exists = any(
|
|
95
|
+
isinstance(r, dict) and r.get("from") == source_entity
|
|
96
|
+
for r in relations
|
|
97
|
+
)
|
|
98
|
+
if not source_exists:
|
|
99
|
+
print(out.error(f"Entité source inconnue : {source_entity!r} n'apparaît dans aucune relation."))
|
|
100
|
+
else:
|
|
101
|
+
print(out.error(
|
|
102
|
+
f"Relation introuvable : {source_entity}.{relation_name}.\n"
|
|
103
|
+
f"Vérifiez le nom de la relation (clé \"name\" dans relations.json)."
|
|
104
|
+
))
|
|
105
|
+
raise SystemExit(1)
|
|
106
|
+
|
|
107
|
+
if len(candidates) > 1:
|
|
108
|
+
print(out.error(f"Relation ambiguë : plusieurs relations {source_entity}.{relation_name} trouvées."))
|
|
109
|
+
raise SystemExit(1)
|
|
110
|
+
|
|
111
|
+
rel = candidates[0]
|
|
112
|
+
|
|
113
|
+
if rel.get("type") != "many_to_many":
|
|
114
|
+
print(out.error(
|
|
115
|
+
f"Relation {source_entity}.{relation_name} n'est pas many_to_many.\n"
|
|
116
|
+
"make:pivot-crud est réservé aux relations many_to_many avec pivot.fields[]."
|
|
117
|
+
))
|
|
118
|
+
raise SystemExit(1)
|
|
119
|
+
|
|
120
|
+
pivot = rel.get("pivot")
|
|
121
|
+
if not isinstance(pivot, dict):
|
|
122
|
+
print(out.error(
|
|
123
|
+
f"Relation {source_entity}.{relation_name} n'a pas de bloc pivot.\n"
|
|
124
|
+
"make:pivot-crud est réservé aux pivots avec attributs."
|
|
125
|
+
))
|
|
126
|
+
raise SystemExit(1)
|
|
127
|
+
|
|
128
|
+
fields = pivot.get("fields")
|
|
129
|
+
if not isinstance(fields, list) or len(fields) == 0:
|
|
130
|
+
print(out.error(
|
|
131
|
+
f"Relation {source_entity}.{relation_name} : pivot.fields[] est absent ou vide.\n"
|
|
132
|
+
"make:pivot-crud est réservé aux pivots avec attributs.\n"
|
|
133
|
+
"Utilisez make:crud pour les pivots simples."
|
|
134
|
+
))
|
|
135
|
+
raise SystemExit(1)
|
|
136
|
+
|
|
137
|
+
pivot_field_names = tuple(
|
|
138
|
+
f["name"] for f in fields
|
|
139
|
+
if isinstance(f, dict) and isinstance(f.get("name"), str)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return ResolvedPivotRelation(
|
|
143
|
+
from_entity=source_entity,
|
|
144
|
+
to_entity=rel.get("to", ""),
|
|
145
|
+
relation_name=relation_name,
|
|
146
|
+
pivot_table=pivot.get("table", ""),
|
|
147
|
+
from_key=pivot.get("from_key", ""),
|
|
148
|
+
to_key=pivot.get("to_key", ""),
|
|
149
|
+
pivot_fields=pivot_field_names,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Contenu généré ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def _build_controller(rel: ResolvedPivotRelation) -> str:
|
|
156
|
+
src_snake = _to_snake(rel.from_entity)
|
|
157
|
+
tgt_snake = _to_snake(rel.to_entity)
|
|
158
|
+
fields_repr = repr(list(rel.pivot_fields))
|
|
159
|
+
|
|
160
|
+
return f'''\
|
|
161
|
+
"""mvc/controllers/pivot/{src_snake}_{rel.relation_name}_pivot_controller.py
|
|
162
|
+
|
|
163
|
+
Sous-CRUD Pivot advanced pour {rel.from_entity}.{rel.relation_name}.
|
|
164
|
+
Généré par forge make:pivot-crud {rel.from_entity} {rel.relation_name}.
|
|
165
|
+
|
|
166
|
+
Ce contrôleur est opt-in — les routes (imbriquées sous la source) sont à
|
|
167
|
+
déclarer manuellement dans mvc/routes.py. Noms de routes au format ADR-029
|
|
168
|
+
(<contrôleur>-<méthode>) ; les identifiants sont lus depuis les paramètres
|
|
169
|
+
de route « source_id » et « target_id ».
|
|
170
|
+
|
|
171
|
+
Exemple de routes à ajouter :
|
|
172
|
+
with router.group("") as g:
|
|
173
|
+
g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}", controller.index, name="{src_snake}_{rel.relation_name}_pivot-index")
|
|
174
|
+
g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}/add", controller.add_form, name="{src_snake}_{rel.relation_name}_pivot-add_form")
|
|
175
|
+
g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/add", controller.add, name="{src_snake}_{rel.relation_name}_pivot-add")
|
|
176
|
+
g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/edit", controller.edit_form, name="{src_snake}_{rel.relation_name}_pivot-edit_form")
|
|
177
|
+
g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/edit", controller.edit, name="{src_snake}_{rel.relation_name}_pivot-edit")
|
|
178
|
+
g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/remove", controller.remove, name="{src_snake}_{rel.relation_name}_pivot-remove")
|
|
179
|
+
"""
|
|
180
|
+
from __future__ import annotations
|
|
181
|
+
|
|
182
|
+
from forge_mvc_pivot import PivotAdvancedService, PivotConstraintError, pivot_error_to_form_error
|
|
183
|
+
from core.http.request import Request
|
|
184
|
+
from core.http.response import Response
|
|
185
|
+
from core.mvc.controller import BaseController
|
|
186
|
+
|
|
187
|
+
_SERVICE = PivotAdvancedService(
|
|
188
|
+
table={rel.pivot_table!r},
|
|
189
|
+
source_key={rel.from_key!r},
|
|
190
|
+
target_key={rel.to_key!r},
|
|
191
|
+
pivot_fields={fields_repr},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
_TEMPLATE_INDEX = "pivot/{src_snake}_{rel.relation_name}/index.html"
|
|
195
|
+
_TEMPLATE_FORM = "pivot/{src_snake}_{rel.relation_name}/form.html"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class {rel.from_entity}{rel.relation_name.capitalize()}PivotController:
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def index(request: Request) -> Response:
|
|
202
|
+
"""Liste les associations {rel.pivot_table}."""
|
|
203
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
204
|
+
rows = _SERVICE.list_for_source({src_snake}_id)
|
|
205
|
+
return BaseController.render(_TEMPLATE_INDEX, context={{
|
|
206
|
+
"{src_snake}_id": {src_snake}_id,
|
|
207
|
+
"rows": rows,
|
|
208
|
+
}}, request=request)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def add_form(request: Request) -> Response:
|
|
212
|
+
"""Formulaire d'ajout d'une association."""
|
|
213
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
214
|
+
return BaseController.render(_TEMPLATE_FORM, context={{
|
|
215
|
+
"{src_snake}_id": {src_snake}_id,
|
|
216
|
+
"row": None,
|
|
217
|
+
"action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/add",
|
|
218
|
+
"error": None,
|
|
219
|
+
}}, request=request)
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def add(request: Request) -> Response:
|
|
223
|
+
"""Crée une nouvelle association."""
|
|
224
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
225
|
+
{tgt_snake}_id = int(request.form("{rel.to_key}"))
|
|
226
|
+
pivot_data = {{
|
|
227
|
+
field: request.form(field)
|
|
228
|
+
for field in {fields_repr}
|
|
229
|
+
if request.form(field) is not None
|
|
230
|
+
}}
|
|
231
|
+
try:
|
|
232
|
+
_SERVICE.attach({src_snake}_id, {tgt_snake}_id, pivot_data)
|
|
233
|
+
return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
error = pivot_error_to_form_error(exc)
|
|
236
|
+
return BaseController.render(_TEMPLATE_FORM, context={{
|
|
237
|
+
"{src_snake}_id": {src_snake}_id,
|
|
238
|
+
"row": None,
|
|
239
|
+
"action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/add",
|
|
240
|
+
"error": error,
|
|
241
|
+
}}, request=request)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def edit_form(request: Request) -> Response:
|
|
245
|
+
"""Formulaire de modification d'une association."""
|
|
246
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
247
|
+
{tgt_snake}_id = int(request.route("target_id"))
|
|
248
|
+
row = _SERVICE.get({src_snake}_id, {tgt_snake}_id)
|
|
249
|
+
if row is None:
|
|
250
|
+
return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
|
|
251
|
+
return BaseController.render(_TEMPLATE_FORM, context={{
|
|
252
|
+
"{src_snake}_id": {src_snake}_id,
|
|
253
|
+
"row": row,
|
|
254
|
+
"action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/{{{tgt_snake}_id}}/edit",
|
|
255
|
+
"error": None,
|
|
256
|
+
}}, request=request)
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def edit(request: Request) -> Response:
|
|
260
|
+
"""Modifie les attributs pivot d'une association."""
|
|
261
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
262
|
+
{tgt_snake}_id = int(request.route("target_id"))
|
|
263
|
+
pivot_data = {{
|
|
264
|
+
field: request.form(field)
|
|
265
|
+
for field in {fields_repr}
|
|
266
|
+
if request.form(field) is not None
|
|
267
|
+
}}
|
|
268
|
+
try:
|
|
269
|
+
_SERVICE.update({src_snake}_id, {tgt_snake}_id, pivot_data)
|
|
270
|
+
return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
|
|
271
|
+
except Exception as exc:
|
|
272
|
+
error = pivot_error_to_form_error(exc)
|
|
273
|
+
row = _SERVICE.get({src_snake}_id, {tgt_snake}_id)
|
|
274
|
+
return BaseController.render(_TEMPLATE_FORM, context={{
|
|
275
|
+
"{src_snake}_id": {src_snake}_id,
|
|
276
|
+
"row": row,
|
|
277
|
+
"action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/{{{tgt_snake}_id}}/edit",
|
|
278
|
+
"error": error,
|
|
279
|
+
}}, request=request)
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def remove(request: Request) -> Response:
|
|
283
|
+
"""Supprime une association."""
|
|
284
|
+
{src_snake}_id = int(request.route("source_id"))
|
|
285
|
+
{tgt_snake}_id = int(request.route("target_id"))
|
|
286
|
+
_SERVICE.detach({src_snake}_id, {tgt_snake}_id)
|
|
287
|
+
return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
controller = {rel.from_entity}{rel.relation_name.capitalize()}PivotController()
|
|
291
|
+
'''
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _build_index_template(rel: ResolvedPivotRelation) -> str:
|
|
295
|
+
src_snake = _to_snake(rel.from_entity)
|
|
296
|
+
tgt_snake = _to_snake(rel.to_entity)
|
|
297
|
+
field_headers = "".join(f" <th>{f}</th>\n" for f in rel.pivot_fields)
|
|
298
|
+
field_cells = "".join(
|
|
299
|
+
f" <td>{{{{ row.pivot_data.get('{f}', '') }}}}</td>\n"
|
|
300
|
+
for f in rel.pivot_fields
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return f"""\
|
|
304
|
+
{{% extends "layouts/app.html" %}}
|
|
305
|
+
{{% block content %}}
|
|
306
|
+
<div class="flex justify-between items-center mb-6">
|
|
307
|
+
<h1 class="text-2xl font-bold text-gray-800">
|
|
308
|
+
{rel.from_entity} #{{{ src_snake}_id}} — {rel.relation_name}
|
|
309
|
+
</h1>
|
|
310
|
+
<a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/add"
|
|
311
|
+
class="text-blue-600 hover:underline">Ajouter</a>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{{% if rows %}}
|
|
315
|
+
<table class="w-full bg-white shadow rounded">
|
|
316
|
+
<thead>
|
|
317
|
+
<tr>
|
|
318
|
+
<th>{tgt_snake}</th>
|
|
319
|
+
{field_headers} <th>Actions</th>
|
|
320
|
+
</tr>
|
|
321
|
+
</thead>
|
|
322
|
+
<tbody>
|
|
323
|
+
{{% for row in rows %}}
|
|
324
|
+
<tr>
|
|
325
|
+
<td>{{{{ row.target_id }}}}</td>
|
|
326
|
+
{field_cells} <td>
|
|
327
|
+
<a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/{{{{ row.target_id }}}}/edit">Modifier</a>
|
|
328
|
+
<form method="post" action="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/{{{{ row.target_id }}}}/remove" style="display:inline">
|
|
329
|
+
<input type="hidden" name="csrf_token" value="{{{{ csrf_token }}}}">
|
|
330
|
+
<button type="submit">Retirer</button>
|
|
331
|
+
</form>
|
|
332
|
+
</td>
|
|
333
|
+
</tr>
|
|
334
|
+
{{% endfor %}}
|
|
335
|
+
</tbody>
|
|
336
|
+
</table>
|
|
337
|
+
{{% else %}}
|
|
338
|
+
<p class="text-gray-500">Aucune association.</p>
|
|
339
|
+
{{% endif %}}
|
|
340
|
+
{{% endblock %}}
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _build_form_template(rel: ResolvedPivotRelation) -> str:
|
|
345
|
+
src_snake = _to_snake(rel.from_entity)
|
|
346
|
+
tgt_snake = _to_snake(rel.to_entity)
|
|
347
|
+
field_inputs = "".join(
|
|
348
|
+
f"""<div>
|
|
349
|
+
<label>{f}</label>
|
|
350
|
+
<input type="text" name="{f}" value="{{{{ row.pivot_data.get('{f}', '') if row else '' }}}}">
|
|
351
|
+
</div>
|
|
352
|
+
"""
|
|
353
|
+
for f in rel.pivot_fields
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return f"""\
|
|
357
|
+
{{% extends "layouts/app.html" %}}
|
|
358
|
+
{{% block content %}}
|
|
359
|
+
<h1 class="text-2xl font-bold text-gray-800 mb-6">
|
|
360
|
+
{{{{ "Modifier" if row else "Ajouter" }}}} — {rel.from_entity}.{rel.relation_name}
|
|
361
|
+
</h1>
|
|
362
|
+
|
|
363
|
+
{{% if error %}}
|
|
364
|
+
<div class="bg-red-50 border border-red-300 text-red-700 px-4 py-3 rounded mb-4">
|
|
365
|
+
{{{{ error.message }}}}
|
|
366
|
+
</div>
|
|
367
|
+
{{% endif %}}
|
|
368
|
+
|
|
369
|
+
<form method="post" action="{{{{ action }}}}">
|
|
370
|
+
<input type="hidden" name="csrf_token" value="{{{{ csrf_token }}}}">
|
|
371
|
+
|
|
372
|
+
{{% if not row %}}
|
|
373
|
+
<div>
|
|
374
|
+
<label>{tgt_snake}</label>
|
|
375
|
+
<input type="number" name="{rel.to_key}" required>
|
|
376
|
+
</div>
|
|
377
|
+
{{% endif %}}
|
|
378
|
+
|
|
379
|
+
{field_inputs}
|
|
380
|
+
<button type="submit">Enregistrer</button>
|
|
381
|
+
<a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}">Annuler</a>
|
|
382
|
+
</form>
|
|
383
|
+
{{% endblock %}}
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ── Écriture fichier ──────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
def _write_if_new(
|
|
390
|
+
path: Path,
|
|
391
|
+
content: str,
|
|
392
|
+
result: MakePivotCrudResult,
|
|
393
|
+
dry_run: bool,
|
|
394
|
+
) -> None:
|
|
395
|
+
if path.exists():
|
|
396
|
+
result.preserved.append(path)
|
|
397
|
+
else:
|
|
398
|
+
if not dry_run:
|
|
399
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
400
|
+
path.write_text(content, encoding="utf-8")
|
|
401
|
+
result.created.append(path)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── Entrée principale ─────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
def make_pivot_crud(
|
|
407
|
+
source_entity: str,
|
|
408
|
+
relation_name: str,
|
|
409
|
+
*,
|
|
410
|
+
entities_root: Path,
|
|
411
|
+
output_root: Path,
|
|
412
|
+
dry_run: bool = False,
|
|
413
|
+
) -> MakePivotCrudResult:
|
|
414
|
+
"""Génère un sous-CRUD Pivot advanced pour une relation many_to_many."""
|
|
415
|
+
relations_path = entities_root / "relations.json"
|
|
416
|
+
rel = resolve_pivot_relation(
|
|
417
|
+
source_entity,
|
|
418
|
+
relation_name,
|
|
419
|
+
relations_path=relations_path,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
src_snake = _to_snake(rel.from_entity)
|
|
423
|
+
controllers_dir = output_root / "mvc" / "controllers" / "pivot"
|
|
424
|
+
templates_dir = output_root / "mvc" / "templates" / "pivot" / f"{src_snake}_{rel.relation_name}"
|
|
425
|
+
|
|
426
|
+
controller_path = controllers_dir / f"{src_snake}_{rel.relation_name}_pivot_controller.py"
|
|
427
|
+
index_path = templates_dir / "index.html"
|
|
428
|
+
form_path = templates_dir / "form.html"
|
|
429
|
+
|
|
430
|
+
result = MakePivotCrudResult(dry_run=dry_run)
|
|
431
|
+
|
|
432
|
+
_write_if_new(controller_path, _build_controller(rel), result, dry_run)
|
|
433
|
+
_write_if_new(index_path, _build_index_template(rel), result, dry_run)
|
|
434
|
+
_write_if_new(form_path, _build_form_template(rel), result, dry_run)
|
|
435
|
+
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def cmd_make_pivot_crud_main(args: list[str]) -> None:
|
|
440
|
+
if len(args) < 2 or args[0].startswith("-"):
|
|
441
|
+
print("Usage : forge make:pivot-crud EntitéSource nomRelation [--dry-run]")
|
|
442
|
+
raise SystemExit(1)
|
|
443
|
+
|
|
444
|
+
source_entity = args[0]
|
|
445
|
+
relation_name = args[1]
|
|
446
|
+
dry_run = "--dry-run" in args
|
|
447
|
+
unknown = [a for a in args[2:] if a != "--dry-run"]
|
|
448
|
+
if unknown:
|
|
449
|
+
print(out.error(f"Arguments inconnus : {' '.join(unknown)}"))
|
|
450
|
+
raise SystemExit(1)
|
|
451
|
+
|
|
452
|
+
result = make_pivot_crud(
|
|
453
|
+
source_entity,
|
|
454
|
+
relation_name,
|
|
455
|
+
entities_root=Path("mvc") / "entities",
|
|
456
|
+
output_root=Path("."),
|
|
457
|
+
dry_run=dry_run,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
if dry_run:
|
|
461
|
+
print(out.dry_run("Aucun fichier créé."))
|
|
462
|
+
if result.created:
|
|
463
|
+
print("Fichiers qui seraient générés :")
|
|
464
|
+
for p in result.created:
|
|
465
|
+
print(f" - {p.as_posix()}")
|
|
466
|
+
else:
|
|
467
|
+
for p in result.created:
|
|
468
|
+
print(out.created(p.as_posix()))
|
|
469
|
+
for p in result.preserved:
|
|
470
|
+
print(out.preserved(p.as_posix(), "← fichier existant, non touché"))
|
|
471
|
+
|
|
472
|
+
if result.created:
|
|
473
|
+
src_snake = _to_snake(source_entity)
|
|
474
|
+
print()
|
|
475
|
+
print(out.ok(f"Pivot advanced généré pour {source_entity}.{relation_name}"))
|
|
476
|
+
print()
|
|
477
|
+
ctrl_ref = f"{src_snake}_{relation_name}_ctrl"
|
|
478
|
+
name_prefix = f"{src_snake}_{relation_name}_pivot"
|
|
479
|
+
base = f"/{src_snake}s/{{source_id}}/{relation_name}"
|
|
480
|
+
print("Routes à ajouter dans mvc/routes.py :")
|
|
481
|
+
print(f' # Pivot {source_entity}.{relation_name}')
|
|
482
|
+
print(f' from mvc.controllers.pivot.{src_snake}_{relation_name}_pivot_controller import controller as {ctrl_ref}')
|
|
483
|
+
print(' with router.group("") as g:')
|
|
484
|
+
print(f' g.add("GET", "{base}", {ctrl_ref}.index, name="{name_prefix}-index")')
|
|
485
|
+
print(f' g.add("GET", "{base}/add", {ctrl_ref}.add_form, name="{name_prefix}-add_form")')
|
|
486
|
+
print(f' g.add("POST", "{base}/add", {ctrl_ref}.add, name="{name_prefix}-add")')
|
|
487
|
+
print(f' g.add("GET", "{base}/{{target_id}}/edit", {ctrl_ref}.edit_form, name="{name_prefix}-edit_form")')
|
|
488
|
+
print(f' g.add("POST", "{base}/{{target_id}}/edit", {ctrl_ref}.edit, name="{name_prefix}-edit")')
|
|
489
|
+
print(f' g.add("POST", "{base}/{{target_id}}/remove", {ctrl_ref}.remove, name="{name_prefix}-remove")')
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""forge_mvc_pivot/service.py — Service de persistance pour tables pivot avec attributs (opt-in extrait du core, ADR-021).
|
|
2
|
+
|
|
3
|
+
Permet de gérer les associations many_to_many enrichies (pivot.fields[]) sans
|
|
4
|
+
modifier make:crud. Utilise le même pattern d'injection que MariaDbSessionStore :
|
|
5
|
+
fetch_one, fetch_all et execute sont injectables pour les tests.
|
|
6
|
+
|
|
7
|
+
Décision : PIVOT-ADVANCED-001 / PIVOT-ADVANCED-003.
|
|
8
|
+
Contraintes : PIVOT-ADVANCED-006.
|
|
9
|
+
UX erreurs : PIVOT-ADVANCED-007.
|
|
10
|
+
UX future : PIVOT-ADVANCED-004 (commande/générateur dédié).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field as dc_field
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _safe_identifier(name: str, label: str) -> str:
|
|
23
|
+
"""Valide qu'un nom de table ou de colonne ne contient pas de caractères dangereux."""
|
|
24
|
+
if not _IDENTIFIER_RE.match(name):
|
|
25
|
+
raise ValueError(
|
|
26
|
+
f"{label} invalide : {name!r}. "
|
|
27
|
+
"Seuls les caractères [A-Za-z0-9_] sont autorisés, le premier doit être une lettre ou _."
|
|
28
|
+
)
|
|
29
|
+
return name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Résultat ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PivotRow:
|
|
36
|
+
"""Représente une ligne de table pivot avec ses attributs."""
|
|
37
|
+
source_id: int | str
|
|
38
|
+
target_id: int | str
|
|
39
|
+
pivot_data: dict = dc_field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Erreur UX ─────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PivotFormError:
|
|
46
|
+
"""Erreur pivot normalisée — affichable dans un formulaire de sous-CRUD pivot.
|
|
47
|
+
|
|
48
|
+
code : identifiant stable du type d'erreur
|
|
49
|
+
message : message humain à afficher dans le formulaire
|
|
50
|
+
field : champ concerné si applicable, None sinon
|
|
51
|
+
"""
|
|
52
|
+
code: str
|
|
53
|
+
message: str
|
|
54
|
+
field: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PivotConstraintError(ValueError):
|
|
58
|
+
"""Levée quand une contrainte pivot est violée (required, nullable, unique_pair).
|
|
59
|
+
|
|
60
|
+
Porte un code stable et éventuellement le champ concerné pour
|
|
61
|
+
permettre un affichage structuré dans les formulaires pivot.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
message: str,
|
|
67
|
+
*,
|
|
68
|
+
code: str = "invalid_pivot_data",
|
|
69
|
+
field: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.code = code
|
|
73
|
+
self.field = field
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Contraintes ───────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class PivotFieldConstraint:
|
|
80
|
+
"""Contrainte déclarative sur un champ pivot.
|
|
81
|
+
|
|
82
|
+
required=True → le champ doit être présent dans pivot_data lors d'un attach.
|
|
83
|
+
nullable=False → la valeur None est refusée lors d'un attach ou update.
|
|
84
|
+
"""
|
|
85
|
+
name: str
|
|
86
|
+
required: bool = False
|
|
87
|
+
nullable: bool = True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Helper UX ─────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def pivot_error_to_form_error(error: Exception) -> PivotFormError:
|
|
93
|
+
"""Convertit une exception de PivotAdvancedService en PivotFormError affichable.
|
|
94
|
+
|
|
95
|
+
- PivotConstraintError → PivotFormError avec le code et le champ structurés.
|
|
96
|
+
- ValueError (hors PivotConstraintError) → code unknown_pivot_field.
|
|
97
|
+
- Autre exception → code invalid_pivot_data, message générique (pas de détail SQL).
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(error, PivotConstraintError):
|
|
100
|
+
return PivotFormError(
|
|
101
|
+
code=error.code,
|
|
102
|
+
message=str(error),
|
|
103
|
+
field=error.field,
|
|
104
|
+
)
|
|
105
|
+
if isinstance(error, ValueError):
|
|
106
|
+
return PivotFormError(
|
|
107
|
+
code="unknown_pivot_field",
|
|
108
|
+
message=str(error),
|
|
109
|
+
)
|
|
110
|
+
return PivotFormError(
|
|
111
|
+
code="invalid_pivot_data",
|
|
112
|
+
message="Une erreur est survenue lors de l'enregistrement.",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Accesseurs DB par défaut (lazy — pas de connexion à l'import) ─────────────
|
|
117
|
+
|
|
118
|
+
def _default_fetch_one(sql: str, params: tuple) -> dict | None:
|
|
119
|
+
from core.database.db import fetch_one
|
|
120
|
+
return fetch_one(sql, params)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _default_fetch_all(sql: str, params: tuple) -> list[dict]:
|
|
124
|
+
from core.database.db import fetch_all
|
|
125
|
+
return fetch_all(sql, params) or []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _default_execute(sql: str, params: tuple) -> int:
|
|
129
|
+
from core.database.db import execute
|
|
130
|
+
return execute(sql, params)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _default_insert(sql: str, params: tuple) -> int:
|
|
134
|
+
from core.database.db import insert
|
|
135
|
+
return insert(sql, params)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Service ───────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
class PivotAdvancedService:
|
|
141
|
+
"""Service générique pour lire et modifier des associations pivot avec attributs.
|
|
142
|
+
|
|
143
|
+
Paramètres :
|
|
144
|
+
table — nom de la table pivot (ex: "article_tag")
|
|
145
|
+
source_key — colonne de clé source (ex: "article_id")
|
|
146
|
+
target_key — colonne de clé cible (ex: "tag_id")
|
|
147
|
+
pivot_fields — liste des noms de colonnes métier autorisés (ex: ["position", "note"])
|
|
148
|
+
pivot_constraints — liste de PivotFieldConstraint pour les contraintes avancées
|
|
149
|
+
unique_pair — si True, vérifie l'unicité avant INSERT (default False)
|
|
150
|
+
id_field — nom de la colonne id technique (ex: "id"), active get_by_id etc.
|
|
151
|
+
|
|
152
|
+
Les callables fetch_one, fetch_all, execute et insert_fn sont injectables pour les tests.
|
|
153
|
+
En production ils délèguent aux helpers core.database.db.
|
|
154
|
+
|
|
155
|
+
API pivot_fields (ancienne) et pivot_constraints (nouvelle) sont mutuellement exclusives.
|
|
156
|
+
Si pivot_constraints est fourni, il prend la priorité et définit la liste des champs autorisés.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
table: str,
|
|
162
|
+
source_key: str,
|
|
163
|
+
target_key: str,
|
|
164
|
+
pivot_fields: list[str] | None = None,
|
|
165
|
+
*,
|
|
166
|
+
pivot_constraints: list[PivotFieldConstraint] | None = None,
|
|
167
|
+
unique_pair: bool = False,
|
|
168
|
+
id_field: str | None = None,
|
|
169
|
+
fetch_one: Callable | None = None,
|
|
170
|
+
fetch_all: Callable | None = None,
|
|
171
|
+
execute: Callable | None = None,
|
|
172
|
+
insert_fn: Callable | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self._table = _safe_identifier(table, "table")
|
|
175
|
+
self._source_key = _safe_identifier(source_key, "source_key")
|
|
176
|
+
self._target_key = _safe_identifier(target_key, "target_key")
|
|
177
|
+
self._unique_pair = unique_pair
|
|
178
|
+
self._id_field = _safe_identifier(id_field, "id_field") if id_field else None
|
|
179
|
+
|
|
180
|
+
if pivot_constraints is not None:
|
|
181
|
+
for c in pivot_constraints:
|
|
182
|
+
_safe_identifier(c.name, f"pivot_constraints[{c.name}].name")
|
|
183
|
+
self._pivot_fields: tuple[str, ...] = tuple(c.name for c in pivot_constraints)
|
|
184
|
+
self._constraints: dict[str, PivotFieldConstraint] = {c.name: c for c in pivot_constraints}
|
|
185
|
+
else:
|
|
186
|
+
self._pivot_fields = tuple(
|
|
187
|
+
_safe_identifier(f, f"pivot_fields[{i}]")
|
|
188
|
+
for i, f in enumerate(pivot_fields or [])
|
|
189
|
+
)
|
|
190
|
+
self._constraints = {}
|
|
191
|
+
|
|
192
|
+
self._fetch_one = fetch_one or _default_fetch_one
|
|
193
|
+
self._fetch_all = fetch_all or _default_fetch_all
|
|
194
|
+
self._execute = execute or _default_execute
|
|
195
|
+
self._insert_fn = insert_fn or _default_insert
|
|
196
|
+
|
|
197
|
+
# ── helpers internes ──────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def _check_pivot_data(self, pivot_data: dict) -> None:
|
|
200
|
+
"""Lève ValueError si pivot_data contient des clés inconnues."""
|
|
201
|
+
unknown = set(pivot_data) - set(self._pivot_fields)
|
|
202
|
+
if unknown:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Champs pivot inconnus : {sorted(unknown)}. "
|
|
205
|
+
f"Champs autorisés : {sorted(self._pivot_fields)}."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _enforce_attach_constraints(self, pivot_data: dict) -> None:
|
|
209
|
+
"""Vérifie required et nullable lors d'un attach."""
|
|
210
|
+
for field_name, constraint in self._constraints.items():
|
|
211
|
+
if constraint.required and field_name not in pivot_data:
|
|
212
|
+
raise PivotConstraintError(
|
|
213
|
+
f"Champ pivot requis absent : {field_name!r}.",
|
|
214
|
+
code="required_field_missing",
|
|
215
|
+
field=field_name,
|
|
216
|
+
)
|
|
217
|
+
if not constraint.nullable and field_name in pivot_data and pivot_data[field_name] is None:
|
|
218
|
+
raise PivotConstraintError(
|
|
219
|
+
f"Champ pivot non nullable reçoit None : {field_name!r}.",
|
|
220
|
+
code="nullable_field_rejected",
|
|
221
|
+
field=field_name,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
def _enforce_update_constraints(self, pivot_data: dict) -> None:
|
|
225
|
+
"""Vérifie nullable lors d'un update (required non vérifié — mise à jour partielle)."""
|
|
226
|
+
for field_name, constraint in self._constraints.items():
|
|
227
|
+
if not constraint.nullable and field_name in pivot_data and pivot_data[field_name] is None:
|
|
228
|
+
raise PivotConstraintError(
|
|
229
|
+
f"Champ pivot non nullable reçoit None : {field_name!r}.",
|
|
230
|
+
code="nullable_field_rejected",
|
|
231
|
+
field=field_name,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _row_to_pivot_row(self, row: dict) -> PivotRow:
|
|
235
|
+
pivot_data = {k: v for k, v in row.items()
|
|
236
|
+
if k not in (self._source_key, self._target_key)}
|
|
237
|
+
return PivotRow(
|
|
238
|
+
source_id=row[self._source_key],
|
|
239
|
+
target_id=row[self._target_key],
|
|
240
|
+
pivot_data=pivot_data,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# ── API publique ──────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def list_for_source(self, source_id: int | str) -> list[PivotRow]:
|
|
246
|
+
"""Retourne toutes les associations liées à source_id."""
|
|
247
|
+
sql = f"SELECT * FROM {self._table} WHERE {self._source_key} = ?"
|
|
248
|
+
rows = self._fetch_all(sql, (source_id,))
|
|
249
|
+
return [self._row_to_pivot_row(r) for r in rows]
|
|
250
|
+
|
|
251
|
+
def get(self, source_id: int | str, target_id: int | str) -> PivotRow | None:
|
|
252
|
+
"""Retourne l'association (source_id, target_id), ou None si absente."""
|
|
253
|
+
sql = (
|
|
254
|
+
f"SELECT * FROM {self._table} "
|
|
255
|
+
f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
|
|
256
|
+
)
|
|
257
|
+
row = self._fetch_one(sql, (source_id, target_id))
|
|
258
|
+
return self._row_to_pivot_row(row) if row else None
|
|
259
|
+
|
|
260
|
+
def attach(
|
|
261
|
+
self,
|
|
262
|
+
source_id: int | str,
|
|
263
|
+
target_id: int | str,
|
|
264
|
+
pivot_data: dict | None = None,
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Crée une association. Retourne le lastrowid.
|
|
267
|
+
|
|
268
|
+
Seuls les champs déclarés dans pivot_fields / pivot_constraints sont acceptés.
|
|
269
|
+
Si unique_pair=True, lève PivotConstraintError si l'association existe déjà.
|
|
270
|
+
"""
|
|
271
|
+
pivot_data = pivot_data or {}
|
|
272
|
+
self._check_pivot_data(pivot_data)
|
|
273
|
+
self._enforce_attach_constraints(pivot_data)
|
|
274
|
+
|
|
275
|
+
if self._unique_pair and self.get(source_id, target_id) is not None:
|
|
276
|
+
raise PivotConstraintError(
|
|
277
|
+
f"Association ({source_id}, {target_id}) déjà existante dans {self._table}.",
|
|
278
|
+
code="duplicate_pair",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
columns = [self._source_key, self._target_key] + [
|
|
282
|
+
f for f in self._pivot_fields if f in pivot_data
|
|
283
|
+
]
|
|
284
|
+
placeholders = ", ".join("?" for _ in columns)
|
|
285
|
+
col_list = ", ".join(columns)
|
|
286
|
+
values = (source_id, target_id) + tuple(
|
|
287
|
+
pivot_data[f] for f in self._pivot_fields if f in pivot_data
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
sql = f"INSERT INTO {self._table} ({col_list}) VALUES ({placeholders})"
|
|
291
|
+
return self._insert_fn(sql, values)
|
|
292
|
+
|
|
293
|
+
def update(
|
|
294
|
+
self,
|
|
295
|
+
source_id: int | str,
|
|
296
|
+
target_id: int | str,
|
|
297
|
+
pivot_data: dict,
|
|
298
|
+
) -> int:
|
|
299
|
+
"""Modifie les attributs pivot d'une association existante. Retourne rowcount.
|
|
300
|
+
|
|
301
|
+
Ne modifie pas source_key ni target_key.
|
|
302
|
+
Seuls les champs déclarés dans pivot_fields / pivot_constraints sont acceptés.
|
|
303
|
+
required n'est pas vérifié (mise à jour partielle autorisée).
|
|
304
|
+
"""
|
|
305
|
+
self._check_pivot_data(pivot_data)
|
|
306
|
+
self._enforce_update_constraints(pivot_data)
|
|
307
|
+
if not pivot_data:
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
set_clause = ", ".join(f"{f} = ?" for f in pivot_data)
|
|
311
|
+
values = tuple(pivot_data[f] for f in pivot_data) + (source_id, target_id)
|
|
312
|
+
sql = (
|
|
313
|
+
f"UPDATE {self._table} SET {set_clause} "
|
|
314
|
+
f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
|
|
315
|
+
)
|
|
316
|
+
return self._execute(sql, values)
|
|
317
|
+
|
|
318
|
+
def detach(self, source_id: int | str, target_id: int | str) -> int:
|
|
319
|
+
"""Supprime l'association (source_id, target_id). Retourne rowcount."""
|
|
320
|
+
sql = (
|
|
321
|
+
f"DELETE FROM {self._table} "
|
|
322
|
+
f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
|
|
323
|
+
)
|
|
324
|
+
return self._execute(sql, (source_id, target_id))
|
|
325
|
+
|
|
326
|
+
# ── Méthodes par id technique (requiert id_field) ─────────────────────────
|
|
327
|
+
|
|
328
|
+
def get_by_id(self, pivot_id: int | str) -> PivotRow | None:
|
|
329
|
+
"""Retourne la ligne pivot par son id technique. Requiert id_field."""
|
|
330
|
+
if not self._id_field:
|
|
331
|
+
raise PivotConstraintError(
|
|
332
|
+
"get_by_id requiert id_field sur PivotAdvancedService.",
|
|
333
|
+
code="missing_id_field",
|
|
334
|
+
)
|
|
335
|
+
sql = f"SELECT * FROM {self._table} WHERE {self._id_field} = ?"
|
|
336
|
+
row = self._fetch_one(sql, (pivot_id,))
|
|
337
|
+
return self._row_to_pivot_row(row) if row else None
|
|
338
|
+
|
|
339
|
+
def update_by_id(self, pivot_id: int | str, pivot_data: dict) -> int:
|
|
340
|
+
"""Modifie les attributs d'une ligne pivot par son id technique. Requiert id_field."""
|
|
341
|
+
if not self._id_field:
|
|
342
|
+
raise PivotConstraintError(
|
|
343
|
+
"update_by_id requiert id_field sur PivotAdvancedService.",
|
|
344
|
+
code="missing_id_field",
|
|
345
|
+
)
|
|
346
|
+
self._check_pivot_data(pivot_data)
|
|
347
|
+
self._enforce_update_constraints(pivot_data)
|
|
348
|
+
if not pivot_data:
|
|
349
|
+
return 0
|
|
350
|
+
set_clause = ", ".join(f"{f} = ?" for f in pivot_data)
|
|
351
|
+
values = tuple(pivot_data[f] for f in pivot_data) + (pivot_id,)
|
|
352
|
+
sql = f"UPDATE {self._table} SET {set_clause} WHERE {self._id_field} = ?"
|
|
353
|
+
return self._execute(sql, values)
|
|
354
|
+
|
|
355
|
+
def delete_by_id(self, pivot_id: int | str) -> int:
|
|
356
|
+
"""Supprime une ligne pivot par son id technique. Requiert id_field."""
|
|
357
|
+
if not self._id_field:
|
|
358
|
+
raise PivotConstraintError(
|
|
359
|
+
"delete_by_id requiert id_field sur PivotAdvancedService.",
|
|
360
|
+
code="missing_id_field",
|
|
361
|
+
)
|
|
362
|
+
sql = f"DELETE FROM {self._table} WHERE {self._id_field} = ?"
|
|
363
|
+
return self._execute(sql, (pivot_id,))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-pivot
|
|
3
|
+
Version: 1.0.0b16
|
|
4
|
+
Summary: Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud.
|
|
5
|
+
Author: Roger Lequette
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://forgemvc.com/docs/forge/
|
|
10
|
+
Keywords: python,mvc,forge,pivot,many-to-many
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b16
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# forge-mvc-pivot
|
|
23
|
+
|
|
24
|
+
Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
|
|
25
|
+
portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
|
|
26
|
+
|
|
27
|
+
Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
|
|
28
|
+
générales ; le pivot avancé est une brique spécialisée, optionnelle.
|
|
29
|
+
|
|
30
|
+
## Contenu
|
|
31
|
+
|
|
32
|
+
- `PivotAdvancedService` : lecture et écriture d'associations pivot avec
|
|
33
|
+
attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
|
|
34
|
+
accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
|
|
35
|
+
- `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
|
|
36
|
+
`pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
|
|
37
|
+
- Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
|
|
38
|
+
sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
|
|
39
|
+
`relations.json`.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install --pre forge-mvc-pivot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
|
|
48
|
+
une application qui s'appuie sur un sous-CRUD pivot.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
forge_mvc_pivot/__init__.py
|
|
5
|
+
forge_mvc_pivot/make_pivot_crud.py
|
|
6
|
+
forge_mvc_pivot/service.py
|
|
7
|
+
forge_mvc_pivot.egg-info/PKG-INFO
|
|
8
|
+
forge_mvc_pivot.egg-info/SOURCES.txt
|
|
9
|
+
forge_mvc_pivot.egg-info/dependency_links.txt
|
|
10
|
+
forge_mvc_pivot.egg-info/requires.txt
|
|
11
|
+
forge_mvc_pivot.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge-mvc<2,>=1.0.0b16
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge_mvc_pivot
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forge-mvc-pivot"
|
|
7
|
+
version = "1.0.0b16"
|
|
8
|
+
description = "Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud."
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Roger Lequette" },
|
|
13
|
+
]
|
|
14
|
+
license = "LicenseRef-Forge-Proprietary"
|
|
15
|
+
keywords = ["python", "mvc", "forge", "pivot", "many-to-many"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"forge-mvc>=1.0.0b16,<2",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/caucrogeGit/Forge"
|
|
29
|
+
Repository = "https://github.com/caucrogeGit/Forge"
|
|
30
|
+
Documentation = "https://forgemvc.com/docs/forge/"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["."]
|
|
34
|
+
include = ["forge_mvc_pivot*"]
|