@@ -662,24 +662,41 @@ CREATE TABLE content.revision (
662662
663663CREATE 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
667670CREATE 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
11181135END;
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
15091572JOIN content .identity ci ON ci .document_id = d .id
15101573LEFT 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
15131584CREATE VIEW content .v_tag_tree AS
15141585SELECT
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
15231607FROM content .tag t;
15241608
15251609
@@ -1667,6 +1751,9 @@ ALTER PROCEDURE content.save_revision(integer, integer)
16671751ALTER 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+
16701757ALTER PROCEDURE commerce .create_transaction (integer , integer , smallint , smallint , text )
16711758 SECURITY DEFINER SET search_path = ' commerce' , ' identity' , ' pg_catalog' ;
16721759
0 commit comments