Depuis des lustres, SQL Server ne permets toujours pas de réaliser des contraintes d’unicité respectant la norme ISO. En effet, la norme SQL impose qu’une contrainte d’unicité porte sur les valeurs exprimées. Ce qui signifie qu’une multiplicité des marqueurs NULLs est permise dans une colonne UNIQUE. Voir :
http://sqlpro.developpez.com/cours/sqlaz/ddl/?page=partie2#L7.2.2
Or SQL Server n’accepte pas cela et refuse d’insérer plus d’une fois le marqueur NULL dans une colonne pourvue d’une contrainte d’unicité… (pour une étude sur les NULLs, voir : http://sqlpro.developpez.com/cours/null/)
C’est le défaut majeur de SQL Server et l’on peut s’étonner qu’il persiste depuis des lustres… Mais comme le contrôle de cette unicité repose sur le moteur de stockage, Microsoft refuse cette correction qui pourrait s’avérer très dangereuse. En effet autant on peut modifier à souhait le moteur relationnel, car même si quelques bugs y seront introduits il ne portent pas à conséquence au regard d’une erreur au moment du stockage de la donnée !
Voici donc différentes solutions de contournement de ce problème…
1 – une fausse bonne idée : unicité via une colonne calculée…
Dans un article un peu farfelu, un internaute cosmoplanétaire nous invite à contourner ce problème en utilisant une colonne calculée ou il remplace à la volée le NULL de la colonne en choisissant une autre colonne, toujours valuée et toujours unique, comme, par exemple, une clef auto incrémentée :
http://decipherinfosys.wordpress.com/2007/11/30/multiple-null-values-in-a-unique-index-in-sql-serverdb2-luw/
Reprenons l’exemple de cet article et étoffons le (mais ne faisons pas un INDEX UNIQUE, cela n’a pas de sens !, faisons une contrainte SQL…) :
CREATE DATABASE UNIC
GO
USE UNIC
GO
CREATE TABLE TEST_UQ
(COL1 INT IDENTITY(1,1) PRIMARY KEY,
COL2 NVARCHAR(10) NULL CONSTRAINT UQ UNIQUE)
GO
INSERT INTO TEST_UQ (COL2) values ('abc');
INSERT INTO TEST_UQ (COL2) values ('xyz');
INSERT INTO TEST_UQ (COL2) values (Null);
Une nouvelle tentative d’insertion d’un marqueur NULL dans la colonnes COL2 provoque instantanément le viol de la contrainte ;
INSERT INTO TEST_UQ (COL2) values (Null);
Serveur : Msg 2627, Niveau 14, État 2, Ligne 1
Violation de la contrainte UNIQUE KEY 'UQ'. Impossible d'insérer une clé en double dans l'objet 'TEST_UQ'.
L'instruction a été arrêtée.
Comme cet auteur nous le conseille supprimons la contrainte d’unicité :
ALTER TABLE TEST_UQ DROP CONSTRAINT UQ
Voyons voir ce que donne le truc de cet auteur à base de colonne calculée…
ALTER TABLE TEST_UQ
ADD COL3 AS (CASE
WHEN COL2 IS NULL THEN CAST(COL1 AS NVARCHAR(10))
ELSE COL2
END);
Rajoutons maintenant la contrainte d’unicité sur la colonne calculée :
ALTER TABLE TEST_UQ
ADD CONSTRAINT UQ UNIQUE (COL3)
Vous pouvez maintenant constater que la colonne respecte bien l’unicité désirée, c’est à dire l’unicité des seules valeurs exprimées, et la multiplicité des NULLs :
INSERT INTO TEST_UQ (COL2) values (Null);
INSERT INTO TEST_UQ (COL2) values (Null);
INSERT INTO TEST_UQ (COL2) values (Null);
SELECT *
FROM TEST_UQ;
COL1 COL2 COL3
----------- ---------- ----------
1 abc abc
2 xyz xyz
3 NULL 3
5 NULL 5
6 NULL 6
7 NULL 7
Bravo !!!
Sauf que cette solution farfelue est complétement idiote… En effet l’insertion suivante :
INSERT INTO TEST_UQ (COL2) values (3);
Plante lamentablement tout simplement parce qu’il existe un autoincrément de valeur 3 dans COL1 alors que c’est bien une valeur unique dans la colonne COL2…
2 – un déclencheur associé à un index
En définitif le seul moyen de réaliser cela est d’utiliser un trigger qui compte les lignes de la table par groupage sur les colonnes de l’unicité et lance un ROLLBACK si ce comptage excéde la valeur 1.
Attention, pour être efficace, un tel mécanisme doit impérativement être accompagné d’un index sur la (ou les) colonne(s) visée(s).
Voici le script réalisant un index unique à la norme SQL avec SQL Server :
USE master;
GO
IF EXISTS (SELECT *
FROM sysdatabases
WHERE name = 'UNIC')
DROP DATABASE UNIC
GO
CREATE DATABASE UNIC
GO
USE UNIC
GO
CREATE TABLE TEST_UQ
(COL1 INT IDENTITY(1,1) PRIMARY KEY,
COL2 NVARCHAR(10) NULL)
GO
CREATE INDEX X ON TEST_UQ (COL2);
GO
CREATE TRIGGER E_IU_TEST_UQ
ON TEST_UQ
FOR INSERT, UPDATE
AS
IF EXISTS(SELECT 0
FROM inserted AS i
INNER JOIN TEST_UQ as u
ON i.COL2 = u.COL2
GROUP BY u.COL2
HAVING COUNT(u.COL2) > 1)
BEGIN
ROLLBACK
RAISERROR('Violation de la contrainte d''unicité liée à la colonne COL2 de la table dbo.TEST_UQ. Transaction annulée.', 16, 1)
END
GO
Et maintenant l’heure de vérité… Le test !
INSERT INTO TEST_UQ (COL2) values ('abc');
INSERT INTO TEST_UQ (COL2) values ('xyz');
INSERT INTO TEST_UQ (COL2) values (Null);
INSERT INTO TEST_UQ (COL2) values (Null);
Tout s’est bien passé jusqu’ici…
INSERT INTO TEST_UQ (COL2) values ('abc');
Violation de la contrainte d'unicité liée à la colonne COL2 de la table dbo.TEST_UQ. Transaction annulée.
Réaction logique et attendue…
SELECT * FROM TEST_UQ
COL1 COL2
----------- ----------
3 NULL
4 NULL
1 abc
2 xyz
CQFD !
3 – un index unique filtré
Depuis la version 2008 de SQL Server, il est possible de créer des index unique filtrés. Ce qui conduit à une solution plus simple :
USE master;
GO
IF EXISTS (SELECT *
FROM sysdatabases
WHERE name = 'UNIC')
DROP DATABASE UNIC
GO
CREATE DATABASE UNIC
GO
USE UNIC
GO
CREATE TABLE TEST_UQ
(COL1 INT IDENTITY(1,1) PRIMARY KEY,
COL2 NVARCHAR(10) NULL)
GO
Et maintenant implantons l’index unique filtré sur les valeurs à l’exception des « non » valeurs :
CREATE UNIQUE INDEX X_U_C2
ON TEST_UQ (COL2)
WHERE (COL2 IS NOT NULL);
Enfin, s’il y a plusieurs colonnes, le principe est le meme :
CREATE TABLE TEST_UQ2
(COL1 INT IDENTITY(1,1) PRIMARY KEY,
COL2 NVARCHAR(10) NULL,
COL3 NVARCHAR(10) NULL)
GO
CREATE UNIQUE INDEX X_U_C23
ON TEST_UQ2 (COL2, COL3)
WHERE (COL2 IS NOT NULL AND COL3 IS NOT NULL);
Et maintenant le test de vérité :
INSERT INTO TEST_UQ2 (COL2, COL3) values ('abc', 'abc');
INSERT INTO TEST_UQ2 (COL2, COL3) values ('abc', 'def');
INSERT INTO TEST_UQ2 (COL2, COL3) values ('abc', NULL);
INSERT INTO TEST_UQ2 (COL2, COL3) values ('abc', NULL);
INSERT INTO TEST_UQ2 (COL2, COL3) values (NULL, 'abc');
INSERT INTO TEST_UQ2 (COL2, COL3) values (NULL, 'abc');
INSERT INTO TEST_UQ2 (COL2, COL3) values (NULL, NULL);
INSERT INTO TEST_UQ2 (COL2, COL3) values (NULL, NULL);
Tout s’est bien passé jusqu’ici…
INSERT INTO TEST_UQ2 (COL2, COL3) values ('abc', 'abc');
Msg 2601, Niveau 14, État 1, Ligne 1
Impossible d’insérer une ligne de clé en double dans l’objet ‘dbo.TEST_UQ2′ avec un index unique ‘X_U_C23′.
L’instruction a été arrêtée.
C’est bien ce qu’il fallait !
Contenu final :
SELECT * FROM TEST_UQ2
COL1 COL2 COL3
----------- ---------- ----------
1 abc abc
2 abc def
3 abc NULL
4 abc NULL
5 NULL abc
6 NULL abc
7 NULL NULL
8 NULL NULL
CQFD bis !!!
--------
Frédéric Brouard, SQLpro - ARCHITECTE DE DONNÉES, http://sqlpro.developpez.com/
Expert bases de données relationnelles et langage SQL. MVP Microsoft SQL Server
www.sqlspot.com : modélisation, conseil, audit, optimisation, tuning, formation
* * * * * Enseignant CNAM PACA - ISEN Toulon - CESI Aix en Provence * * * * *
Il n’y a aucune raison pour que ce que vous faite ne « passe pas ». En effet un ordre SQL est une transaction par lui même. Du moment qu’en fin de traitement de l’ordre les contraintes sont vérifiées, le SGBDR doit effectuer la chose. Si ce n’est pas le cas, c’est parce que votre SGBDR n’est pas capable de réaliser des transactions. C’est donc une faute du SGBDR en question. je serais curieux de savoir sur quel pseudo SGBDR vous avez testé ceci.
En tout cas, les tests que j’ai effectués sur différentes version de SQL Server ne posent aucun problème !
<br />
INSERT INTO TEST_UQ (COL2) values ('AA'); <br />
<br />
UPDATE TEST_UQ SET COL2 = COL2 + 'A'; <br />
<br />
SELECT * FROM TEST_UQ;
En voici le résultat sur MS SQL Server 2008, 2005, 2000 :
----------- ---------- <br />
1 AA <br />
2 AAA
Ne jamais oublier qu’un SGBDRne traite pas de manière itérative mais ensembliste. Lisez l’article que j’ai écrit sur les règles de Codd (http://sqlpro.developpez.com/SGBDR/ReglesCodd/ – discussion sur la règle 12 page 4) et notamment la nature ensemblistes des opérations dans les SGBDR. Un SGBDR qui ne serait pas capable de cela n’est pas un SGBDR et ce n’est pas moi qui le dit, mais l’inventeur des SGBDR : Frank edgar COdd !
A +
Oui, mais…
INSERT INTO TEST_UQ (COL2) values (‘A’);
INSERT INTO TEST_UQ (COL2) values (‘AA’);
UPDATE TEST_UQ SET COL2 = COL2 + ‘A';
Ca ne passe pas, car on crée un doublon ‘temporaire’ en écrivant la valeur ‘AA’.
Mais au final l’ancienne valeur ‘AA’ sera elle-même remplacée par ‘AAA’ et il n’y aura pas de doublon.
Et avec un index unique ‘classique’ il ne teste que l’unicité du résultat final.
(remarque que avec Access ca ne passe pas même avec un index Unique ‘standard’)