Skip to content

Commit 89449ac

Browse files
committed
feat: add tags hierarchy
1 parent 8b355e6 commit 89449ac

6 files changed

Lines changed: 1507 additions & 256 deletions

File tree

sandbox/postgres/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ l'interface [schema.org](https://schema.org) par-dessus les composants fragment
4747
├── 01_schema_and_security.sql # Types physiques, BRIN, RBAC, SECURITY DEFINER
4848
├── 02_identity_logic.sql # Comptes, slugs, bitmask, connexions
4949
├── 03_content_logic.sql # Documents, révisions, commentaires ltree
50-
└── 04_commerce_logic.sql # Stock, snapshots de prix, agrégats
50+
├── 04_commerce_logic.sql # Stock, snapshots de prix, agrégats
51+
└── 05_tag_hierarchy.sql # Mots clefs hiérarchisés
5152
```
5253

5354
### `master_schema_ddl.pgsql`

sandbox/postgres/architecture_decision_records.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,83 @@ descriptives (`mime_type`, `folder_url`, `file_name`, `width`, `height`).
132132

133133
---
134134

135+
## ADR-026 — Closure Table pour la taxonomie des tags : ltree conservé pour les commentaires
136+
137+
**Statut** : Adopté
138+
139+
### Contexte
140+
141+
Le composant `content.tag` utilisait un chemin `ltree` (`path`) et un `parent_id`
142+
pour représenter la hiérarchie taxonomique. Deux limites opérationnelles ont été
143+
identifiées :
144+
145+
1. **Mobilité des tags** : déplacer un tag dans la hiérarchie avec ltree nécessite
146+
un UPDATE en cascade de `path` sur tous les descendants — O(n_descendants) UPDATEs,
147+
risque de locks en production.
148+
2. **Couplage structurel** : `path` encode à la fois l'identité du tag
149+
(`theology.patristics.cyrille`) et sa position hiérarchique. Renommer un ancêtre
150+
force un UPDATE en cascade sur tous ses descendants.
151+
152+
### Décision : Closure Table pour les tags
153+
154+
La hiérarchie est portée par `content.tag_hierarchy(ancestor_id, descendant_id, depth)`
155+
indépendamment des données du tag. Le spine `content.tag` devient `(id, slug, name)` — immuable.
156+
157+
**Invariant opérationnel** : chaque tag possède obligatoirement une self-reference
158+
`(id, id, 0)`. La procédure `content.create_tag` garantit cet invariant à l'insertion.
159+
160+
**Profondeur maximale = 4** : contrainte CHECK `depth BETWEEN 0 AND 4`. La procédure
161+
lève une exception à l'INSERT si un parent est déjà à depth 4.
162+
163+
### Pourquoi pas ltree pour les tags
164+
165+
| Critère | ltree | Closure Table |
166+
| ----------------- | -------------------------------- | ------------------------------------- |
167+
| Sous-arbre query | `path <@ 'parent.path'` (GiST) | `WHERE ancestor_id = X AND depth > 0` (B-tree) |
168+
| Move tag | UPDATE en cascade O(descendants) | DELETE + reinsert O(depth) |
169+
| Rename ancestor | UPDATE en cascade O(descendants) | Zéro impact (noms dans tag spine) |
170+
| Insert nouveau tag| O(1) — concat path | O(depth) — inserts ancêtres |
171+
| Breadcrumb | Gratuit (le path IS le chemin) | string_agg + self-join |
172+
| Dépendance | Extension ltree | SQL pur |
173+
174+
Pour une taxonomie éditoriale à insertions rares et depth ≤ 4, le coût du move
175+
est le critère déterminant. La requête de sous-arbre sur la Closure Table est un
176+
équijoin sur INT4 avec index B-tree — plus cache-friendly qu'un scan GiST sur varlena.
177+
178+
### ltree conservé pour `content.comment`
179+
180+
**Non négociable.** ADR-012 est architecturalement construit autour du ltree :
181+
la procédure `create_comment` utilise `nextval()` préalable + construction du
182+
chemin en mémoire + INSERT unique pour garantir zéro dead tuple. La Closure Table
183+
sur les commentaires est inapplicable :
184+
185+
- Volume : 10 000+ commentaires/jour → O(profondeur) inserts par commentaire
186+
avec locks en cascade sur `tag_hierarchy` équivalent.
187+
- Les commentaires ne sont jamais "déplacés" — la raison principale de la Closure
188+
Table n'existe pas pour eux.
189+
- La profondeur des commentaires est non bornée (ADR-011 : ltree supporte des
190+
arborescences profondes avec O(log n) via GiST).
191+
192+
L'extension ltree reste installée et en usage actif pour `content.comment.path`.
193+
194+
### Physique — densité
195+
196+
| Table | Avant | Après |
197+
| ------------------ | ------------------ | --------------------- |
198+
| `content.tag` | ~80 B (ltree path) | ~50 B (slug+name) |
199+
| `content.tag_hierarchy` || 12 B/tuple (~682/page)|
200+
201+
Pour 232 tags à plat : 232 lignes dans `tag_hierarchy` (self-refs).
202+
Pour une taxonomie 4 niveaux de 232 tags répartis : max ~500-900 lignes.
203+
204+
### Procédure `create_tag`
205+
206+
Seul point d'entrée autorisé pour `marius_user`. Gère atomiquement :
207+
l'INSERT dans le spine `tag`, la self-reference depth=0, et l'héritage des
208+
ancêtres du parent via SELECT/INSERT depuis `tag_hierarchy`.
209+
210+
---
211+
135212
## ADR-025 — Interface sémantique snake_case : schema.org sans guillemets SQL
136213

137214
**Statut** : Adopté
@@ -1054,4 +1131,4 @@ au même titre que les `REVOKE` qui les suivent.
10541131

10551132
---
10561133

1057-
*Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 25 décisions*
1134+
*Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 26 décisions*

sandbox/postgres/master_schema_ddl.pgsql

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -662,24 +662,41 @@ CREATE TABLE content.revision (
662662

663663
CREATE INDEX revision_recent ON content.revision (document_id, revision_num DESC);
664664

665-
-- CONTENT TAG — taxonomie ltree (lectures fréquentes, insertions rares)
666-
-- Layout : id INT4 · parent_id INT4 | puis varlena (ltree + slug + name)
665+
-- CONTENT TAG — spine taxonomique (Closure Table, ADR-026)
666+
-- parent_id et path ltree supprimés : la hiérarchie est portée par tag_hierarchy.
667+
-- Le spine tag reste immuable : seuls name et slug le définissent.
668+
-- Layout (ADR-004) : id INT4 | slug varlena · name varlena
669+
-- Tuple ~50 B (nom+slug 20 chars chacun) → ~164 tuples/page
667670
CREATE TABLE content.tag (
668-
id INT GENERATED ALWAYS AS IDENTITY,
669-
parent_id INT NULL,
670-
path ltree NOT NULL,
671-
slug VARCHAR(64) NOT NULL,
672-
name VARCHAR(64) NOT NULL,
671+
id INT GENERATED ALWAYS AS IDENTITY,
672+
slug VARCHAR(64) NOT NULL,
673+
name VARCHAR(64) NOT NULL,
673674
PRIMARY KEY (id),
674-
UNIQUE (path),
675675
UNIQUE (slug),
676-
FOREIGN KEY (parent_id) REFERENCES content.tag(id) ON DELETE RESTRICT,
677-
CONSTRAINT slug_format CHECK (slug ~ '^[a-z0-9-]+$'),
678-
CONSTRAINT path_format CHECK (path::text ~ '^[a-z0-9_]+(\.[a-z0-9_]+)*$')
676+
CONSTRAINT slug_format CHECK (slug ~ '^[a-z0-9-]+$')
679677
);
680678

681-
CREATE INDEX tag_path_gist ON content.tag USING gist (path);
682-
CREATE INDEX tag_path_btree ON content.tag (path);
679+
-- TAG HIERARCHY — Closure Table (ADR-026)
680+
-- Stocke toutes les paires (ancêtre, descendant) avec leur distance.
681+
-- Un tag est son propre ancêtre à depth=0 (self-reference obligatoire).
682+
-- Profondeur maximale : 4 niveaux (depth BETWEEN 0 AND 4).
683+
-- Layout (ADR-004) :
684+
-- ancestor_id INT4 (offset 0) · descendant_id INT4 (4) · depth SMALLINT (8) · 2B pad
685+
-- Tuple 12 B → ~682 tuples/page
686+
-- Cardinalité maximale théorique : N*(N+1)/2 ≈ 500 000 / 2 = 250 000 pour 1 000 tags
687+
-- profonds. En pratique, taxonomie de ~200-500 tags avec depth ≤ 4 : ~1 000-2 000 lignes.
688+
CREATE TABLE content.tag_hierarchy (
689+
ancestor_id INT NOT NULL,
690+
descendant_id INT NOT NULL,
691+
depth SMALLINT NOT NULL,
692+
PRIMARY KEY (ancestor_id, descendant_id),
693+
FOREIGN KEY (ancestor_id) REFERENCES content.tag(id) ON DELETE CASCADE,
694+
FOREIGN KEY (descendant_id) REFERENCES content.tag(id) ON DELETE CASCADE,
695+
CONSTRAINT depth_range CHECK (depth BETWEEN 0 AND 4)
696+
);
697+
698+
-- Index inverse : chercher tous les ancêtres d'un tag (breadcrumb, move)
699+
CREATE INDEX tag_hierarchy_descendant ON content.tag_hierarchy (descendant_id, depth);
683700

684701
-- MEDIA CORE — métadonnées fichiers (MOYENNE fréquence)
685702
-- Layout : 2×TIMESTAMPTZ · id INT4 · author_id INT4 · 2×INT4 (w/h) | varlena × 3
@@ -1118,6 +1135,52 @@ BEGIN
11181135
END;
11191136
$$;
11201137

1138+
-- Création d'un tag et insertion automatique dans la Closure Table (ADR-026)
1139+
-- Automatise la gestion des ancêtres : l'appelant fournit uniquement le parent_id.
1140+
-- SECURITY DEFINER : marius_user n'a pas de droits DML directs (ADR-020).
1141+
--
1142+
-- Mécanisme :
1143+
-- 1. INSERT du tag dans content.tag (spine)
1144+
-- 2. Self-reference (ancestor = descendant = new_tag_id, depth = 0)
1145+
-- 3. Héritage des ancêtres du parent :
1146+
-- INSERT (ancestor_id, new_tag_id, parent_depth + 1)
1147+
-- FROM tag_hierarchy WHERE descendant_id = parent_id
1148+
-- Validation de profondeur : si le parent est déjà à depth=4, l'INSERT viole
1149+
-- le CHECK depth BETWEEN 0 AND 4 — l'exception est propagée à l'appelant.
1150+
CREATE PROCEDURE content.create_tag(
1151+
p_name VARCHAR(64),
1152+
p_slug VARCHAR(64),
1153+
p_parent_id INT DEFAULT NULL,
1154+
OUT p_tag_id INT
1155+
) LANGUAGE plpgsql AS $$
1156+
BEGIN
1157+
-- 1. Créer le tag (spine)
1158+
INSERT INTO content.tag (slug, name)
1159+
VALUES (p_slug, p_name)
1160+
RETURNING id INTO p_tag_id;
1161+
1162+
-- 2. Self-reference obligatoire (depth = 0)
1163+
INSERT INTO content.tag_hierarchy (ancestor_id, descendant_id, depth)
1164+
VALUES (p_tag_id, p_tag_id, 0);
1165+
1166+
-- 3. Propager les ancêtres du parent (si fourni)
1167+
IF p_parent_id IS NOT NULL THEN
1168+
-- Valider l'existence du parent
1169+
IF NOT EXISTS (SELECT 1 FROM content.tag_hierarchy
1170+
WHERE ancestor_id = p_parent_id AND descendant_id = p_parent_id) THEN
1171+
RAISE EXCEPTION 'Tag parent introuvable dans la Closure Table (id=%)', p_parent_id
1172+
USING ERRCODE = 'foreign_key_violation';
1173+
END IF;
1174+
1175+
INSERT INTO content.tag_hierarchy (ancestor_id, descendant_id, depth)
1176+
SELECT th.ancestor_id, p_tag_id, th.depth + 1
1177+
FROM content.tag_hierarchy th
1178+
WHERE th.descendant_id = p_parent_id;
1179+
-- Le CHECK depth BETWEEN 0 AND 4 rejette automatiquement si depth + 1 > 4.
1180+
END IF;
1181+
END;
1182+
$$;
1183+
11211184
-- Insertion d'un commentaire avec construction du chemin ltree (une seule écriture heap)
11221185
-- Remplace définitivement le double trigger BEFORE/AFTER (ADR-012).
11231186
-- nextval() préalable → path construit en mémoire → INSERT unique, zéro dead tuple.
@@ -1509,17 +1572,38 @@ JOIN content.core co ON co.document_id = d.id
15091572
JOIN content.identity ci ON ci.document_id = d.id
15101573
LEFT JOIN content.body b ON b.document_id = d.id;
15111574

1512-
-- CONTENT : v_tag_tree — taxonomie avec métadonnées hiérarchiques
1575+
-- CONTENT : v_tag_tree — taxonomie avec Closure Table (ADR-026)
1576+
-- depth = distance depuis la racine (0 = racine, 4 = feuille maximale).
1577+
-- parent_id = ancêtre immédiat (depth = 1), NULL si racine.
1578+
-- breadcrumb = chemin textuel racine→tag, séparateurs " > ".
1579+
-- article_count = articles directement taggés (pas subtree — requête explicite via tag_hierarchy).
1580+
--
1581+
-- Navigation de sous-arbre côté applicatif :
1582+
-- SELECT descendant_id FROM content.tag_hierarchy
1583+
-- WHERE ancestor_id = :tag_id AND depth > 0
15131584
CREATE VIEW content.v_tag_tree AS
15141585
SELECT
1515-
t.id AS identifier,
1516-
t.name, t.slug,
1517-
t.path::text AS path,
1518-
t.parent_id,
1519-
nlevel(t.path) AS depth,
1586+
t.id AS identifier,
1587+
t.name,
1588+
t.slug,
1589+
-- Profondeur depuis la racine (0 = racine)
1590+
COALESCE((
1591+
SELECT MAX(th.depth) FROM content.tag_hierarchy th
1592+
WHERE th.descendant_id = t.id AND th.ancestor_id <> t.id
1593+
), 0) AS depth,
1594+
-- Parent immédiat (NULL si racine)
1595+
(SELECT th_p.ancestor_id FROM content.tag_hierarchy th_p
1596+
WHERE th_p.descendant_id = t.id AND th_p.depth = 1
1597+
LIMIT 1) AS parent_id,
1598+
-- Breadcrumb : ancêtres ordonnés racine en tête (depth DESC)
1599+
(SELECT string_agg(a.name, ' > ' ORDER BY th_a.depth DESC)
1600+
FROM content.tag_hierarchy th_a
1601+
JOIN content.tag a ON a.id = th_a.ancestor_id
1602+
WHERE th_a.descendant_id = t.id AND th_a.depth > 0) AS breadcrumb,
1603+
-- Articles directement associés à ce tag (statut publié)
15201604
(SELECT COUNT(*) FROM content.content_to_tag ct
1521-
JOIN content.core co ON co.document_id = ct.content_id
1522-
WHERE ct.tag_id = t.id AND co.status = 1) AS article_count
1605+
JOIN content.core co ON co.document_id = ct.content_id
1606+
WHERE ct.tag_id = t.id AND co.status = 1) AS article_count
15231607
FROM content.tag t;
15241608

15251609

@@ -1667,6 +1751,9 @@ ALTER PROCEDURE content.save_revision(integer, integer)
16671751
ALTER PROCEDURE content.create_comment(integer, integer, text, integer, smallint)
16681752
SECURITY DEFINER SET search_path = 'content', 'pg_catalog';
16691753

1754+
ALTER PROCEDURE content.create_tag(character varying, character varying, integer)
1755+
SECURITY DEFINER SET search_path = 'content', 'pg_catalog';
1756+
16701757
ALTER PROCEDURE commerce.create_transaction(integer, integer, smallint, smallint, text)
16711758
SECURITY DEFINER SET search_path = 'commerce', 'identity', 'pg_catalog';
16721759

0 commit comments

Comments
 (0)