Avec PostgreSQL 15 ou une version ultérieure, utilisez une contrainte UNIQUE NULLS NOT DISTINCT déclarée DEFERRABLE, puis différez-la dans la transaction de réordonnancement. Cela permet aux lignes d’articles d’échanger leurs positions tout en garantissant un ordre final unique, y compris pour les lignes dont le parent est NULL. Syntaxe des contraintes PostgreSQL
Ce tutoriel s’adresse aux développeurs travaillant dans le Terminal de Mac et qui doivent réordonner des lignes d’articles synthétiques au sein d’un même groupe parent. Il comprend une démonstration SQL, un programme complet avec node-postgres qui valide l’ordre demandé, ainsi qu’une solution de remplacement pour PostgreSQL 14. Le changement de parent des articles n’entre pas dans le périmètre de cet exemple.
Pourquoi un parent nullable complique-t-il l’unicité des positions ?
Supposons que chaque article ait un id, un parent_id nullable et un position entier positif. Les articles ayant le même parent forment un groupe de frères ordonné. Dans ce modèle, tous les articles dont le parent est null appartiennent à un seul groupe racine.
Un UNIQUE (parent_id, position) classique ne garantit pas cette règle pour le groupe racine. PostgreSQL considère normalement que les valeurs null sont distinctes lors du contrôle d’unicité : deux articles racines peuvent donc tous deux occuper la position 1. L’ajout de NULLS NOT DISTINCT fait que ces valeurs de parent null sont considérées comme identiques lors de la vérification de la clé composite. Des parents non nuls différents peuvent toujours avoir chacun leur propre position 1. PostgreSQL : contraintes d’unicité
Le réordonnancement pose aussi un problème de temporisation distinct. Si l’article 1 occupe la position 1 et que l’article 2 occupe la position 2, déplacer l’article 2 vers la position 1 duplique temporairement la position de l’article 1. Une contrainte d’unicité non différable est vérifiée immédiatement à chaque insertion ou modification de ligne. Une contrainte différable peut attendre la fin de l’instruction ou, si elle est explicitement différée, la fin de la transaction. PostgreSQL : CREATE TABLE
Pour un réordonnancement réalisé par plusieurs mises à jour, ces deux propriétés comptent : l’unicité qui tient compte des valeurs null définit les groupes corrects, et le contrôle différé permet les collisions temporaires entre les mises à jour.
Quelle définition utiliser selon votre version de PostgreSQL ?
PostgreSQL 15 a introduit NULLS NOT DISTINCT pour les contraintes et les index d’unicité. Les principaux exemples ci-dessous nécessitent la version 15 ou une version ultérieure du serveur ; PostgreSQL 14 requiert la solution de remplacement présentée plus loin. Vérifiez la version du serveur auquel vous êtes connecté au lieu de la déduire de celle du client en ligne de commande installé. Notes de version de PostgreSQL 15
| Situation | Approche prise en charge |
|---|---|
| PostgreSQL 15 ou version ultérieure, nouvelle table | Contrainte nommée UNIQUE NULLS NOT DISTINCT avec DEFERRABLE INITIALLY IMMEDIATE |
| Contrainte d’unicité classique existante | Remplacez sa définition ; ALTER CONSTRAINT ne permet pas de modifier le caractère différable d’une contrainte d’unicité |
| Index d’unicité partiel ou sur expression existant | Remplacez la méthode de contrôle ; il ne peut pas être rattaché comme contrainte d’unicité classique |
| PostgreSQL 14 | Utilisez des colonnes de regroupement générées avec une contrainte d’unicité différable |
Les limites concernant les objets existants sont documentées dans ALTER TABLE : la modification des attributs de contrainte de cette manière est limitée aux clés étrangères, et un index rattaché en tant que contrainte d’unicité ne peut contenir d’expressions ni être partiel. Une commande autonome CREATE UNIQUE INDEX ne comporte pas non plus de clause DEFERRABLE. PostgreSQL : ALTER TABLE, PostgreSQL : CREATE INDEX
Comment permuter deux articles racines en SQL ?
Prérequis : une base de données PostgreSQL locale existante en version 15 ou ultérieure et une connexion psql fonctionnelle. Ouvrez le Terminal de Mac et lancez le client avec vos paramètres de connexion configurés. La commande suivante ignore les personnalisations de démarrage et interrompt le traitement du script en cas d’erreur. PostgreSQL : psql
psql -X -v ON_ERROR_STOP=1
Collez la démonstration SQL complète ci-dessous dans cette session. Elle crée une table temporaire qui ne contient que des articles synthétiques ; les tables temporaires disparaissent à la fin de la session. Le nom de la table ne doit pas déjà être utilisé dans cette session. PostgreSQL : tables temporaires
SHOW server_version;
CREATE TEMP TABLE article_order_demo (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
CONSTRAINT article_order_demo_parent_position_key
UNIQUE NULLS NOT DISTINCT (parent_id, position)
DEFERRABLE INITIALLY IMMEDIATE
);
INSERT INTO article_order_demo (id, parent_id, title, position)
VALUES
(1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2),
(3, NULL, 'Article C', 3),
(4, 1, 'Article A: Overview', 1),
(5, 1, 'Article A: Examples', 2);
BEGIN;
SET CONSTRAINTS article_order_demo_parent_position_key DEFERRED;
UPDATE article_order_demo
SET position = 1
WHERE id = 2 AND parent_id IS NULL;
UPDATE article_order_demo
SET position = 2
WHERE id = 1 AND parent_id IS NULL;
SET CONSTRAINTS article_order_demo_parent_position_key IMMEDIATE;
COMMIT;
SELECT id, title, position
FROM article_order_demo
WHERE parent_id IS NULL
ORDER BY position;
Après la première mise à jour, la transaction contient temporairement deux racines à la position 1. Après la deuxième mise à jour, leurs positions sont de nouveau uniques. Le retour de la contrainte à IMMEDIATE vérifie alors les modifications en attente. Si un doublon subsiste, cette commande échoue ; sinon, la transaction peut être validée. Le report change le moment où la validation a lieu. PostgreSQL : SET CONSTRAINTS
L’ordre final attendu des racines est le suivant :
| Position | ID de l’article | Titre |
|---|---|---|
| 1 | 2 | Article B |
| 2 | 1 | Article A |
| 3 | 3 | Article C |
Les articles enfants conservent leurs positions, car aucune des deux mises à jour ne les sélectionne. Si une transaction interactive rencontre une erreur, exécutez ROLLBACK; avant de réessayer. Une transaction regroupe les instructions connexes jusqu’à COMMIT ou ROLLBACK. PostgreSQL : BEGIN
Comment réordonner un groupe complet de frères avec node-postgres ?
Utilisez un seul client réservé pour toute la transaction. node-postgres l’exige explicitement : plusieurs appels à pool.query() ne conviennent pas à une transaction, car ils peuvent être exécutés sur des connexions différentes. node-postgres : transactions
Le programme ci-dessous accepte une liste complète et ordonnée d’ID de frères. Il applique une règle supplémentaire simple : la liste fournie doit être une permutation de l’ensemble actuel des frères. Le rejet des doublons, des omissions et des ID provenant d’autres groupes évite qu’un réordonnancement partiel semble avoir réussi.
Dans un projet Node.js local, installez pg, enregistrez le programme complet suivant sous le nom reorder.cjs, puis exécutez-le. Le programme suppose que les paramètres de connexion locale existants sont disponibles via des variables d’environnement PostgreSQL. node-postgres prend en charge les mêmes variables d’environnement de connexion que psql. node-postgres : connexion
npm install pg
node reorder.cjs
const { Pool } = require('pg');
function validateOrder(ids, rows) {
if (!Array.isArray(ids) || !ids.every(Number.isSafeInteger) ||
new Set(ids).size !== ids.length) {
throw new Error('Order must contain distinct integer IDs');
}
const existing = new Set(rows.map(row => row.id));
if (rows.length !== ids.length || ids.some(id => !existing.has(id))) {
throw new Error('Order must include every sibling exactly once');
}
}
async function main() {
const pool = new Pool();
let client;
try {
client = await pool.connect();
await client.query(`
CREATE TEMP TABLE article_order_demo (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
CONSTRAINT article_order_demo_parent_position_key
UNIQUE NULLS NOT DISTINCT (parent_id, position)
DEFERRABLE INITIALLY IMMEDIATE
)
`);
await client.query(`
INSERT INTO article_order_demo (id, parent_id, title, position)
VALUES (1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2),
(3, NULL, 'Article C', 3),
(4, 1, 'Article A: Overview', 1),
(5, 1, 'Article A: Examples', 2)
`);
const parentId = null;
const orderedIds = [2, 1, 3];
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
try {
await client.query(
'LOCK TABLE article_order_demo IN SHARE ROW EXCLUSIVE MODE'
);
const siblings = await client.query(
`SELECT id FROM article_order_demo
WHERE parent_id IS NOT DISTINCT FROM $1::integer`,
[parentId]
);
validateOrder(orderedIds, siblings.rows);
await client.query(
'SET CONSTRAINTS article_order_demo_parent_position_key DEFERRED'
);
for (const [index, id] of orderedIds.entries()) {
const result = await client.query(
`UPDATE article_order_demo SET position = $1
WHERE id = $2
AND parent_id IS NOT DISTINCT FROM $3::integer`,
[index + 1, id, parentId]
);
if (result.rowCount !== 1) {
throw new Error('Expected exactly one updated article');
}
}
await client.query(
'SET CONSTRAINTS article_order_demo_parent_position_key IMMEDIATE'
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
const result = await client.query(
`SELECT id, title, position FROM article_order_demo
WHERE parent_id IS NOT DISTINCT FROM $1::integer
ORDER BY position`,
[parentId]
);
console.table(result.rows);
} finally {
if (client) client.release();
await pool.end();
}
}
main().catch(error => {
console.error(error.message);
process.exitCode = 1;
});
IS NOT DISTINCT FROM permet au filtre sur le parent de fonctionner pour les valeurs nulles comme pour les valeurs non nulles. Une égalité ordinaire avec un paramètre null ne sélectionne pas les racines. Pour réordonner les enfants de l’exemple, remplacez parentId par 1 et orderedIds par [5, 4]. PostgreSQL : fonctions et opérateurs de comparaison
Les paramètres de type $1 transmettent les valeurs séparément du texte SQL. Le transtypage explicite en entier indique également le type attendu lorsque le paramètre parent est null. node-postgres : requêtes
Pourquoi obtenir le verrou avant de lire les frères ?
La démonstration temporaire n’implique aucune session concurrente. Le verrou de table illustre une méthode volontairement large pour une petite table partagée : SHARE ROW EXCLUSIVE entre en conflit avec les écritures normales sur la table et avec lui-même, tout en autorisant les lectures ordinaires. Le conserver pendant toute la transaction stabilise l’appartenance au groupe des frères durant la validation et les mises à jour. Il bloque aussi les écritures concernant des groupes parents sans rapport ; évaluez ce compromis avant de l’utiliser sur une table très sollicitée. PostgreSQL : verrouillage explicite
Le programme sélectionne explicitement READ COMMITTED. À ce niveau d’isolation, la requête sur les frères voit les modifications validées jusqu’au début de cette requête, y compris celles validées avant la fin d’une attente précédente pour obtenir un verrou. PostgreSQL : BEGIN, PostgreSQL : isolation des transactions
La validation JavaScript a été vérifiée avec une permutation valide ainsi qu’avec des ID en double, manquants et appartenant à un autre groupe. Ces vérifications déterministes ont réussi. La documentation a permis d’examiner les exemples de base de données, mais ils n’ont pas été exécutés sur PostgreSQL dans l’environnement de recherche.
Quelle solution est prise en charge dans PostgreSQL 14 ?
Dans PostgreSQL 14, matérialisez l’expression de regroupement au moyen de colonnes générées, puis appliquez une contrainte d’unicité différable à ces colonnes. Les colonnes générées stockées sont calculées lors de l’écriture des lignes et ne peuvent pas être remplacées par des valeurs ordinaires. PostgreSQL 14 : colonnes générées
Cet exemple SQL autonome utilise deux valeurs générées pour représenter le groupe parent. La valeur booléenne distingue les racines d’un véritable ID parent égal à 0 ; la valeur de repli COALESCE n’exige donc pas de réserver un ID.
CREATE TEMP TABLE article_order_demo14 (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo14(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
parent_is_root boolean
GENERATED ALWAYS AS (parent_id IS NULL) STORED,
parent_group integer
GENERATED ALWAYS AS (COALESCE(parent_id, 0)) STORED,
CONSTRAINT article_order_demo14_parent_position_key
UNIQUE (parent_is_root, parent_group, position)
DEFERRABLE INITIALLY IMMEDIATE
);
INSERT INTO article_order_demo14 (id, parent_id, title, position)
VALUES (1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2);
BEGIN;
SET CONSTRAINTS article_order_demo14_parent_position_key DEFERRED;
UPDATE article_order_demo14 SET position = 1 WHERE id = 2;
UPDATE article_order_demo14 SET position = 2 WHERE id = 1;
SET CONSTRAINTS article_order_demo14_parent_position_key IMMEDIATE;
COMMIT;
SELECT id, title, position
FROM article_order_demo14
WHERE parent_id IS NULL
ORDER BY position;
Pour une racine, les valeurs de regroupement générées sont (true, 0) ; pour le parent 0, elles sont (false, 0). Chaque parent non nul conserve sa propre valeur entière. La contrainte exprime donc le regroupement voulu sans dépendre du traitement des valeurs null de PostgreSQL 15. PostgreSQL 14 prend en charge les contraintes d’unicité différables portant sur des colonnes nommées. PostgreSQL 14 : CREATE TABLE
Que faut-il vérifier avant d’adapter une table existante ?
Un index sur expression portant sur COALESCE(parent_id, 0) peut imposer l’unicité à partir d’une expression, mais il ne peut pas simplement devenir différable ni être rattaché comme contrainte d’unicité classique. Un index partiel sur les lignes racines présente la même limite de rattachement. Si l’un de ces objets de contrôle immédiat reste en place, il peut toujours empêcher une permutation après l’ajout d’une contrainte différable. PostgreSQL : index sur les expressions, PostgreSQL : ALTER TABLE
Avant de remplacer une définition existante, recherchez les paires parent-position en double, en particulier pour les racines que l’unicité nullable classique autorisait auparavant. L’ajout d’une contrainte d’unicité valide les lignes existantes ; NOT VALID n’est pas disponible pour les contraintes d’unicité. Résolvez délibérément les positions ambiguës avant de tenter la modification. PostgreSQL : ALTER TABLE
Distinguez la garantie de la contrainte de la règle d’ordre de l’application. L’unicité seule autorise les positions discontinues, comme 1, 4 et 9. Le programme qui traite la liste complète attribue volontairement des positions consécutives à partir de 1. C’est son contrôle de l’appartenance au groupe qui garantit la participation de chaque frère.
Pour cette tâche, choisissez la contrainte qui représente les groupes à parent nullable, différez-la avant la première mise à jour entraînant une collision, validez l’ensemble complet des frères demandé et effectuez toutes les mises à jour avec un seul client avant de valider la transaction. Cette combinaison permet de réordonner les racines et les enfants tout en conservant une position finale unique pour chaque frère.
Sources
- CREATE TABLE — PostgreSQL Global Development Group.
- 5.5. Contraintes — PostgreSQL Global Development Group.
- E.20. Version 15 — PostgreSQL Global Development Group.
- ALTER TABLE — PostgreSQL Global Development Group.
- CREATE INDEX — PostgreSQL Global Development Group.
- psql — PostgreSQL Global Development Group.
- SET CONSTRAINTS — PostgreSQL Global Development Group.
- BEGIN — PostgreSQL Global Development Group.
- Transactions — node-postgres.
- Connexion — node-postgres.
- Requêtes — node-postgres.
- 9.2. Fonctions et opérateurs de comparaison — PostgreSQL Global Development Group.
- 13.3. Verrouillage explicite — PostgreSQL Global Development Group.
- 13.2. Isolation des transactions — PostgreSQL Global Development Group.
- 5.3. Colonnes générées — PostgreSQL Global Development Group.
- CREATE TABLE — PostgreSQL 14 — PostgreSQL Global Development Group.
- 11.7. Index sur les expressions — PostgreSQL Global Development Group.