Database
 sql >> Base de données >  >> RDS >> Database

Complexités NULL – Partie 4, Contrainte d'unicité standard manquante

Cet article est la partie 4 d'une série sur les complexités NULL. Dans les articles précédents (Partie 1, Partie 2 et Partie 3), j'ai couvert la signification de NULL en tant que marqueur d'une valeur manquante, comment les NULL se comportent dans les comparaisons et dans d'autres éléments de requête, et les fonctionnalités de gestion NULL standard qui ne sont pas encore disponible dans T-SQL. Ce mois-ci, je couvre la différence entre la façon dont une contrainte unique est définie dans la norme ISO/IEC SQL et la façon dont elle fonctionne dans T-SQL. Je fournirai également des solutions personnalisées que vous pourrez mettre en œuvre si vous avez besoin des fonctionnalités standard.

Contrainte UNIQUE standard

SQL Server gère les valeurs NULL comme des valeurs non NULL dans le but d'appliquer une contrainte d'unicité. Autrement dit, une contrainte d'unicité sur T est satisfaite si et seulement s'il n'existe pas deux lignes R1 et R2 de T telles que R1 et R2 aient la même combinaison de valeurs NULL et non NULL dans les colonnes uniques. Par exemple, supposons que vous définissiez une contrainte unique sur col1, qui est une colonne NULLable d'un type de données INT. Une tentative de modification de la table d'une manière qui entraînerait plus d'une ligne avec un NULL dans col1 sera rejetée, tout comme une modification qui entraînerait plus d'une ligne avec la valeur 1 dans col1 sera rejetée.

Supposons que vous définissiez une contrainte unique composite sur la combinaison de colonnes INT NULLable col1 et col2. Une tentative de modification de la table d'une manière qui entraînerait plus d'une occurrence de l'une des combinaisons suivantes de valeurs (col1, col2) sera rejetée :(NULL, NULL), (3, NULL), (NULL, 300 ), (1 100).

Ainsi, comme vous pouvez le voir, l'implémentation T-SQL de la contrainte unique traite les valeurs NULL comme des valeurs non NULL dans le but de renforcer l'unicité.

Si vous souhaitez définir une clé étrangère sur une table X référençant une table Y, vous devez imposer l'unicité sur la ou les colonnes référencées avec l'une des options suivantes :

  • Clé primaire
  • Contrainte unique
  • Index unique non filtré

Une clé primaire n'est pas autorisée sur les colonnes NULLable. Une contrainte unique (qui crée un index sous les couvertures) et un index unique créé explicitement sont autorisés sur les colonnes NULLable et appliquent leur unicité dans T-SQL en utilisant la logique susmentionnée. La table de référence est autorisée à avoir des lignes avec un NULL dans la colonne de référence, que la table référencée ait ou non une ligne avec un NULL dans la colonne référencée. L'idée est de soutenir une relation facultative. Certaines lignes de la table de référence peuvent être celles qui ne sont liées à aucune ligne de la table référencée. Vous implémenterez cela en utilisant un NULL dans la colonne de référence.

Pour illustrer l'implémentation T-SQL d'une contrainte unique, exécutez le code suivant, qui crée une table appelée T3 avec une contrainte unique définie sur la colonne NULLable INT col1, et la remplit avec quelques exemples de lignes :

USE tempdb;
GO
 
DROP TABLE IF EXISTS dbo.T3;
GO
 
CREATE TABLE dbo.T3(col1 INT NULL, col2 INT NULL, CONSTRAINT UNQ_T3 UNIQUE(col1));
 
INSERT INTO dbo.T3(col1, col2) VALUES(1, 100),(2, -1),(NULL, -1),(3, 300);

Utilisez le code suivant pour interroger la table :

SELECT * FROM dbo.T3;

Cette requête génère la sortie suivante :

col1        col2
----------- -----------
1           100
2           -1
NULL        -1
3           300

Essayez d'insérer une deuxième ligne avec un NULL dans col1 :

INSERT INTO dbo.T3(col1, col2) VALUES(NULL, 400);

Cette tentative est rejetée et vous obtenez l'erreur suivante :

Msg 2627, Niveau 14, État 1
Violation de la contrainte UNIQUE KEY 'UNQ_T3'. Impossible d'insérer la clé en double dans l'objet 'dbo.T3'. La valeur de la clé en double est ().

La définition de contrainte unique standard est un peu différente de la version T-SQL. La principale différence concerne la gestion de NULL. Voici la définition de contrainte unique de la norme :

"Une contrainte d'unicité sur T est satisfaite si et seulement s'il n'existe pas deux lignes R1 et R2 de T telles que R1 et R2 aient les mêmes valeurs non NULL dans les colonnes uniques."

Ainsi, une table T avec une contrainte unique sur col1 autorisera plusieurs lignes avec un NULL dans col1, mais interdira plusieurs lignes avec la même valeur non NULL dans col1.

Ce qui est un peu plus délicat à expliquer, c'est ce qui se passe selon la norme avec une contrainte d'unicité composite. Disons que vous avez une contrainte unique définie sur (col1, col2). Vous pouvez avoir plusieurs lignes avec (NULL, NULL), mais vous ne pouvez pas avoir plusieurs lignes avec (3, NULL), tout comme vous ne pouvez pas avoir plusieurs lignes avec (1, 100). De même, vous ne pouvez pas avoir plusieurs lignes avec (NULL, 300). Le fait est que vous n'êtes pas autorisé à avoir plusieurs lignes avec les mêmes valeurs non NULL dans les colonnes uniques. Comme pour une clé étrangère, vous pouvez avoir n'importe quel nombre de lignes dans la table de référence avec des valeurs NULL dans toutes les colonnes de référence, indépendamment de ce qui existe dans la table référencée. Ces lignes ne sont liées à aucune ligne de la table référencée (relation facultative). Cependant, si vous avez une valeur non NULL dans l'une des colonnes de référence, il doit exister une ligne dans la table référencée avec les mêmes valeurs non NULL dans les colonnes référencées.

Supposons que vous disposiez d'une base de données sur une plate-forme prenant en charge la contrainte d'unicité standard et que vous deviez migrer cette base de données vers SQL Server. Vous pouvez rencontrer des problèmes avec l'application de contraintes uniques dans SQL Server si les colonnes uniques prennent en charge les valeurs NULL. Les données considérées comme valides dans le système source peuvent être considérées comme non valides dans SQL Server. Dans les sections suivantes, j'explorerai un certain nombre de solutions de contournement possibles dans SQL Server.

Solution 1, en utilisant un index filtré ou une vue indexée

Une solution de contournement courante dans T-SQL pour appliquer la fonctionnalité de contrainte unique standard lorsqu'une seule colonne cible est impliquée consiste à utiliser un index filtré unique qui filtre uniquement les lignes où la colonne cible n'est pas NULL. Le code suivant supprime la contrainte d'unicité existante de T3 et implémente un tel index :

ALTER TABLE dbo.T3 DROP CONSTRAINT UNQ_T3;
 
CREATE UNIQUE NONCLUSTERED INDEX idx_col1_notnull ON dbo.T3(col1) WHERE col1 IS NOT NULL;

Étant donné que l'index ne filtre que les lignes où col1 n'est pas NULL, sa propriété UNIQUE n'est appliquée que sur les valeurs col1 non NULL.

Rappelez-vous que T3 a déjà une ligne avec un NULL dans col1. Pour tester cette solution, utilisez le code suivant pour ajouter une deuxième ligne avec un NULL dans col1 :

INSERT INTO dbo.T3(col1, col2) VALUES(NULL, 400);

Ce code s'exécute avec succès.

Rappelez-vous que T3 a déjà une ligne avec la valeur 1 dans col1. Exécutez le code suivant pour tenter d'ajouter une deuxième ligne avec 1 dans col1 :

INSERT INTO dbo.T3(col1, col2) VALUES(1, 500);

Comme prévu, cette tentative échoue avec l'erreur suivante :

Msg 2601, Niveau 14, État 1
Impossible d'insérer une ligne de clé en double dans l'objet 'dbo.T3' avec l'index unique 'idx_col1_notnull'. La valeur de clé en double est (1).

Utilisez le code suivant pour interroger T3 :

SELECT * FROM dbo.T3;

Ce code génère la sortie suivante montrant deux lignes avec un NULL dans col1 :

col1        col2
----------- -----------
1           100
2           -1
NULL        -1
3           300
NULL        400

Cette solution fonctionne bien lorsque vous devez appliquer l'unicité sur une seule colonne et lorsque vous n'avez pas besoin d'appliquer l'intégrité référentielle avec une clé étrangère pointant vers cette colonne.

Le problème avec la clé étrangère est que SQL Server requiert une clé primaire ou une contrainte unique ou un index non filtré unique défini sur la colonne référencée. Cela ne fonctionne pas lorsqu'il n'y a qu'un index filtré unique défini sur la colonne référencée. Essayons de créer une table avec une clé étrangère référençant T3.col1. Tout d'abord, utilisez le code suivant pour créer la table T3 :

DROP TABLE IF EXISTS dbo.T3FK;
GO
 
CREATE TABLE dbo.T3FK
(
  id INT NOT NULL IDENTITY CONSTRAINT PK_T3FK PRIMARY KEY,
  col1 INT NULL, 
  col2 INT NULL, 
  othercol VARCHAR(10) NOT NULL
);

Essayez ensuite d'exécuter le code suivant pour tenter d'ajouter une clé étrangère pointant de T3FK.col1 vers T3.col1 :

ALTER TABLE dbo.T3FK ADD CONSTRAINT FK_T3_T3FK
  FOREIGN KEY(col1) REFERENCES dbo.T3(col1);

Cette tentative échoue avec l'erreur suivante :

Msg 1776, Niveau 16, État 0
Aucune clé primaire ou candidate dans la table référencée 'dbo.T3' ne correspond à la liste des colonnes de référence dans la clé étrangère 'FK_T3_T3FK'.

Msg 1750, Niveau 16, État 1
Impossible de créer une contrainte ou un index. Voir les erreurs précédentes.

À ce stade, supprimez l'index filtré existant pour le nettoyage :

DROP INDEX idx_col1_notnull ON dbo.T3;

Ne supprimez pas la table T3FK, car vous l'utiliserez dans des exemples ultérieurs.

L'autre problème avec la solution d'index filtré, en supposant que vous n'avez pas besoin d'une clé étrangère, est que cela ne fonctionne pas lorsque vous devez appliquer la fonctionnalité de contrainte unique standard sur plusieurs colonnes, par exemple sur la combinaison (col1, col2) . N'oubliez pas que la contrainte d'unicité standard interdit les combinaisons de valeurs non nulles en double dans les colonnes uniques. Pour implémenter cette logique avec un index filtré, vous devez filtrer uniquement les lignes où l'une des colonnes uniques n'est pas NULL. En d'autres termes, vous devez filtrer uniquement les lignes qui n'ont pas de NULL dans toutes les colonnes uniques. Malheureusement, les index filtrés n'autorisent que des expressions très simples. Ils ne prennent pas en charge OR, NOT ou la manipulation sur les colonnes. Ainsi, aucune des définitions d'index suivantes n'est actuellement prise en charge :

CREATE UNIQUE NONCLUSTERED INDEX idx_customunique ON dbo.T3(col1, col2)
  WHERE col1 IS NOT NULL OR col2 IS NOT NULL;
 
CREATE UNIQUE NONCLUSTERED INDEX idx_customunique ON dbo.T3(col1, col2)
  WHERE NOT (col1 IS NULL AND col2 IS NULL);
 
CREATE UNIQUE NONCLUSTERED INDEX idx_customunique ON dbo.T3(col1, col2)
  WHERE COALESCE(col1, col2) IS NOT NULL;

La solution de contournement dans un tel cas consiste à créer une vue indexée basée sur une requête qui renvoie col1 et col2 à partir de T3 avec l'une des clauses WHERE ci-dessus, avec un index clusterisé unique sur (col1, col2), comme ceci :

CREATE VIEW dbo.T3CustomUnique WITH SCHEMABINDING
AS
  SELECT col1, col2 FROM dbo.T3 WHERE col1 IS NOT NULL OR col2 IS NOT NULL;
GO
 
CREATE UNIQUE CLUSTERED INDEX idx_col1_col2 ON dbo.T3CustomUnique(col1, col2);
GO

Vous serez autorisé à ajouter plusieurs lignes avec (NULL, NULL) dans (col1, col2), mais vous ne serez pas autorisé à ajouter plusieurs occurrences de combinaisons de valeurs non NULL dans (col1, col2), telles que (3 , NULL) ou (NULL, 300) ou (1, 100). Pourtant, cette solution ne prend pas en charge une clé étrangère.

À ce stade, exécutez le code suivant pour le nettoyage :

DROP VIEW IF EXISTS dbo.T3CustomUnique;

Solution 2, utilisant une clé de substitution et une colonne calculée

Les solutions avec l'index filtré et la vue indexée sont bonnes tant que vous n'avez pas besoin de prendre en charge une clé étrangère. Mais que se passe-t-il si vous avez besoin d'appliquer l'intégrité référentielle ? Une option consiste à continuer à utiliser la solution d'index filtré ou de vue indexée pour appliquer l'unicité et à utiliser des déclencheurs pour appliquer l'intégrité référentielle. Cependant, cette option est assez chère.

Une autre option consiste à utiliser une solution complètement différente pour la partie d'unicité qui prend en charge une clé étrangère. La solution consiste à ajouter deux colonnes à la table référencée (T3 dans notre cas). Une colonne appelée id est une clé de substitution avec une propriété d'identité. Une autre colonne appelée flag est une colonne calculée persistante qui renvoie id lorsque col1 est NULL et 0 lorsqu'il n'est pas NULL. Vous appliquez ensuite une contrainte unique sur la combinaison de col1 et flag. Voici le code pour ajouter les deux colonnes et la contrainte unique :

ALTER TABLE dbo.T3
  ADD id INT NOT NULL IDENTITY,
      flag AS CASE WHEN col1 IS NULL THEN id ELSE 0 END PERSISTED,
      CONSTRAINT UNQ_T3_col1_flag UNIQUE(col1, flag);

Utilisez le code suivant pour interroger T3 :

SELECT * FROM dbo.T3;

Ce code génère la sortie suivante :

col1        col2        id          flag
----------- ----------- ----------- -----------
1           100         1           0
2           -1          2           0
NULL        -1          3           3
3           300         4           0
NULL        400         5           5

En ce qui concerne la table de référence (T3FK dans notre cas), vous ajoutez une colonne calculée appelée flag qui est toujours définie sur 0, et une clé étrangère définie sur (col1, flag) pointant vers les colonnes uniques de T3 (col1, flag), comme ceci :

ALTER TABLE dbo.T3FK
  ADD flag AS 0 PERSISTED,
      CONSTRAINT FK_T3_T3FK
        FOREIGN KEY(col1, flag) REFERENCES dbo.T3(col1, flag);

Testons cette solution.

Essayez d'ajouter les lignes suivantes :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES
  (1, 100, 'A'),
  (2, -1, 'B'),
  (3, 300, 'C');

Ces lignes sont ajoutées avec succès, comme il se doit, car toutes ont des lignes référencées correspondantes.

Interroger la table T3FK :

SELECT * FROM dbo.T3FK;

Vous obtenez le résultat suivant :

id          col1        col2        othercol   flag
----------- ----------- ----------- ---------- -----------
1           1           100         A          0
2           2           -1          B          0
3           3           300         C          0

Essayez d'ajouter une ligne qui n'a pas de ligne correspondante dans la table référencée :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES
  (4, 400, 'D');

La tentative est rejetée, comme il se doit, avec l'erreur suivante :

Msg 547, Niveau 16, État 0
L'instruction INSERT est en conflit avec la contrainte FOREIGN KEY "FK_T3_T3FK". Le conflit s'est produit dans la base de données "TSQLV5", table "dbo.T3".

Essayez d'ajouter une ligne à T3FK avec un NULL dans col1 :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES
  (NULL, NULL, 'E');

Cette ligne est considérée comme n'étant liée à aucune ligne dans T3FK (relation facultative) et, selon la norme, doit être autorisée, qu'un NULL existe ou non dans la table référencée dans col1. T-SQL prend en charge ce scénario et la ligne est ajoutée avec succès.

Interroger la table T3FK :

SELECT * FROM dbo.T3FK;

Ce code génère la sortie suivante :

id          col1        col2        othercol   flag
----------- ----------- ----------- ---------- -----------
1           1           100         A          0
2           2           -1          B          0
3           3           300         C          0
5           NULL        NULL        E          0

La solution fonctionne bien lorsque vous devez appliquer la fonctionnalité d'unicité standard sur une seule colonne. Mais cela pose un problème lorsque vous devez imposer l'unicité sur plusieurs colonnes. Pour illustrer le problème, supprimez d'abord les tables T3 et T3FK :

DROP TABLE IF EXISTS dbo.T3FK, dbo.T3;

Utilisez le code suivant pour recréer T3 avec une contrainte unique composite sur (col1, col2, flag) :

CREATE TABLE dbo.T3
(
  col1 INT NULL,
  col2 INT NULL,
  id INT NOT NULL IDENTITY,
  flag AS CASE WHEN col1 IS NULL AND col2 IS NULL THEN id ELSE 0 END PERSISTED,
  CONSTRAINT UNQ_T3 UNIQUE(col1, col2, flag)
);

Notez que l'indicateur est défini sur id lorsque col1 et col2 sont NULL et 0 sinon.

La contrainte unique elle-même fonctionne bien.

Exécutez le code suivant pour ajouter quelques lignes à T3, y compris plusieurs occurrences de (NULL, NULL) dans (col1, col2) :

INSERT INTO dbo.T3(col1, col2) VALUES(1, 100),(1, 200),(NULL, NULL),(NULL, NULL);

Ces lignes sont ajoutées avec succès comme il se doit.

Essayez d'ajouter deux occurrences de (1, NULL) dans (col1, col2) :

INSERT INTO dbo.T3(col1, col2) VALUES(1, NULL),(1, NULL);

Cette tentative échoue comme il se doit avec l'erreur suivante :

Msg 2627, Niveau 14, État 1
Violation de la contrainte UNIQUE KEY 'UNQ_T3'. Impossible d'insérer la clé en double dans l'objet 'dbo.T3'. La valeur de la clé en double est (1, , 0).

Essayez d'ajouter deux occurrences de (NULL, 100) dans (col1, col2) :

INSERT INTO dbo.T3(col1, col2) VALUES(NULL, 100),(NULL, 100);

Cette tentative échoue également comme il se doit avec l'erreur suivante :

Msg 2627, Niveau 14, État 1
Violation de la contrainte UNIQUE KEY 'UNQ_T3'. Impossible d'insérer la clé en double dans l'objet 'dbo.T3'. La valeur de la clé en double est (, 100, 0).

Essayez d'ajouter les deux lignes suivantes, où aucune violation ne devrait se produire :

INSERT INTO dbo.T3(col1, col2) VALUES(3, NULL),(NULL, 300);

Ces lignes ont été ajoutées avec succès.

Interrogez la table T3 à ce stade :

SELECT * FROM dbo.T3;

Vous obtenez le résultat suivant :

col1        col2        id          flag
----------- ----------- ----------- -----------
1           100         1           0
1           200         2           0
NULL        NULL        3           3
NULL        NULL        4           4
3           NULL        9           0
NULL        300         10          0

Jusqu'ici tout va bien.

Ensuite, exécutez le code suivant pour créer la table T3FK avec une clé étrangère composite référençant les colonnes uniques de T3 :

CREATE TABLE dbo.T3FK
(
  id INT NOT NULL IDENTITY CONSTRAINT PK_T3FK PRIMARY KEY,
  col1 INT NULL, 
  col2 INT NULL, 
  othercol VARCHAR(10) NOT NULL,
  flag AS 0 PERSISTED,
  CONSTRAINT FK_T3_T3FK
    FOREIGN KEY(col1, col2, flag) REFERENCES dbo.T3(col1, col2, flag)
);

Cette solution permet naturellement d'ajouter des lignes à T3FK avec (NULL, NULL) dans (col1, col2). Le problème est qu'il permet également d'ajouter des lignes NULL dans col1 ou col2, même lorsque l'autre colonne n'est pas NULL, et que la table référencée T3 n'a pas une telle combinaison de touches. Par exemple, essayez d'ajouter la ligne suivante à T3FK :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES(5, NULL, 'A');

Cette ligne est ajoutée avec succès même s'il n'y a pas de ligne associée dans T3. Selon la norme, cette ligne ne devrait pas être autorisée.

Retour à la planche à dessin…

Solution 3, en utilisant une clé de substitution et une colonne calculée

Le problème avec la solution précédente (Solution 2) survient lorsque vous devez prendre en charge une clé étrangère composite. Il autorise les lignes de la table de référence qui ont un NULL dans au moins une colonne de référence, même lorsqu'il existe des valeurs non NULL dans d'autres colonnes de référence et aucune ligne associée dans la table référencée. Pour résoudre ce problème, vous pouvez utiliser une variante de la solution précédente, que nous appellerons Solution 3.

Tout d'abord, utilisez le code suivant pour supprimer les tables existantes :

DROP TABLE IF EXISTS dbo.T3FK, dbo.T3;

Dans la nouvelle solution de la table référencée (T3 dans notre cas), vous utilisez toujours la colonne de clé de substitution ID basée sur l'identité. Vous utilisez également une colonne calculée persistante appelée unqpath. Lorsque toutes les colonnes uniques (col1 et col2 dans notre exemple) sont NULL, vous définissez unqpath sur une représentation de chaîne de caractères de id (pas de séparateurs ). Lorsque l'une des colonnes uniques n'est pas NULL, vous définissez unqpath sur une chaîne de caractères représentant une liste séparée des valeurs de colonne uniques à l'aide de la fonction CONCAT. Cette fonction remplace un NULL par une chaîne vide. Ce qui est important, c'est de s'assurer d'utiliser un séparateur qui ne peut normalement pas apparaître dans les données elles-mêmes. Par exemple, avec des valeurs entières col1 et col2, vous n'avez que des chiffres, donc tout séparateur autre qu'un chiffre fonctionnera. Dans mon exemple, je vais utiliser un point (.). Vous appliquez ensuite une contrainte unique sur unqpath. Vous n'aurez jamais de conflit entre la valeur unqpath lorsque toutes les colonnes uniques sont NULL (définies sur id) et lorsque l'une des colonnes uniques n'est pas NULL car dans le premier cas, unqpath ne contient pas de séparateur, et dans le dernier cas, il le fait . N'oubliez pas que vous utiliserez la solution 3 lorsque vous avez un cas de clé composite et que vous préférerez probablement la solution 2, qui est plus simple, lorsque vous avez un cas de clé à une seule colonne. Si vous souhaitez également utiliser la solution 3 avec une clé à colonne unique et non la solution 2, assurez-vous simplement d'ajouter le séparateur lorsque la colonne unique n'est pas NULL même s'il n'y a qu'une seule valeur impliquée. De cette façon, vous n'aurez pas de conflit lorsque id dans une ligne où col1 est NULL est égal à col1 dans une autre ligne, puisque le premier n'aura pas de séparateur et le second en aura.

Voici le code pour créer T3 avec les ajouts susmentionnés :

CREATE TABLE dbo.T3
(
  col1 INT NULL,
  col2 INT NULL,
  id INT NOT NULL IDENTITY,
  unqpath AS CASE WHEN col1 IS NULL AND col2 IS NULL THEN CAST(id AS VARCHAR(10)) 
                  ELSE CONCAT(CAST(col1 AS VARCHAR(11)), '.', CAST(col2 AS VARCHAR(11)))
             END PERSISTED,
  CONSTRAINT UNQ_T3 UNIQUE(unqpath)
);

Avant de traiter une clé étrangère et la table de référencement, testons la contrainte unique. Rappelez-vous, il est censé interdire les combinaisons en double de valeurs non NULL dans les colonnes uniques, mais il est censé autoriser plusieurs occurrences de tous les NULL dans les colonnes uniques.

Exécutez le code suivant pour ajouter quelques lignes, y compris deux occurrences de (NULL, NULL) dans (col1, col2) :

INSERT INTO dbo.T3(col1, col2) VALUES(1, 100),(1, 200),(NULL, NULL),(NULL, NULL);

Ce code se termine correctement comme il se doit.

Essayez d'ajouter deux occurrences de (1, NULL) dans (col1, col2) :

INSERT INTO dbo.T3(col1, col2) VALUES(1, NULL),(1, NULL);

Ce code échoue avec l'erreur suivante comme il se doit :

Msg 2627, Niveau 14, État 1
Violation de la contrainte UNIQUE KEY 'UNQ_T3'. Impossible d'insérer la clé en double dans l'objet 'dbo.T3'. La valeur de la clé en double est (1.).

De même, la tentative suivante est également rejetée :

INSERT INTO dbo.T3(col1, col2) VALUES(NULL, 100),(NULL, 100);

Vous obtenez l'erreur suivante :

Msg 2627, Niveau 14, État 1
Violation de la contrainte UNIQUE KEY 'UNQ_T3'. Impossible d'insérer la clé en double dans l'objet 'dbo.T3'. La valeur de clé en double est (.100).

Exécutez le code suivant pour ajouter quelques lignes supplémentaires :

INSERT INTO dbo.T3(col1, col2) VALUES(3, NULL),(NULL, 300);

Ce code s'exécute correctement comme il se doit.

À ce stade, interrogez T3 :

SELECT * FROM dbo.T3;

Vous obtenez le résultat suivant :

col1        col2        id          unqpath
----------- ----------- ----------- -----------------------
1           100         1           1.100
1           200         2           1.200
NULL        NULL        3           3
NULL        NULL        4           4
3           NULL        9           3.
NULL        300         10          .300

Observez les valeurs unqpath et assurez-vous de comprendre la logique derrière leur construction, et la différence entre un cas où toutes les colonnes uniques sont NULL (pas de séparateur) et quand au moins une n'est pas NULL (le séparateur existe).

Quant à la table de référencement, T3FK; vous définissez également une colonne calculée appelée unqpath, mais dans le cas où toutes les colonnes de référence sont NULL, vous définissez la colonne sur NULL, et non sur id. Lorsque l'une des colonnes de référence n'est pas NULL, vous construisez la même liste de valeurs séparées comme vous l'avez fait dans T3. Vous définissez ensuite une clé étrangère sur T3FK.unqpath pointant vers T3.unqpath, comme ceci :

CREATE TABLE dbo.T3FK
(
  id INT NOT NULL IDENTITY CONSTRAINT PK_T3FK PRIMARY KEY,
  col1 INT NULL, 
  col2 INT NULL, 
  othercol VARCHAR(10) NOT NULL,
  unqpath AS CASE WHEN col1 IS NULL AND col2 IS NULL THEN NULL
                  ELSE CONCAT(CAST(col1 AS VARCHAR(11)), '.', CAST(col2 AS VARCHAR(11)))
             END PERSISTED,
  CONSTRAINT FK_T3_T3FK
    FOREIGN KEY(unqpath) REFERENCES dbo.T3(unqpath)
);

Cette clé étrangère rejettera les lignes dans T3FK où l'une des colonnes de référence n'est pas NULL, et il n'y a pas de ligne associée dans la table référencée T3, comme le montre la tentative suivante :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES(5, NULL, 'A');

Ce code génère l'erreur suivante :

Msg 547, Niveau 16, État 0
L'instruction INSERT est en conflit avec la contrainte FOREIGN KEY "FK_T3_T3FK". Le conflit s'est produit dans la base de données "TSQLV5", table "dbo.T3", colonne "unqpath".

Cette solution affichera les lignes dans T3FK où l'une des colonnes de référence n'est pas NULL tant qu'une ligne associée dans T3 existe, ainsi que les lignes avec des valeurs NULL dans toutes les colonnes de référence, car ces lignes sont considérées comme n'étant liées à aucune ligne de T3. Le code suivant ajoute ces lignes valides à T3FK :

INSERT INTO dbo.T3FK(col1, col2, othercol) VALUES
  (1   , 100 , 'A'),
  (1   , 200 , 'B'),
  (3   , NULL, 'C'),
  (NULL, 300 , 'D'),
  (NULL, NULL, 'E'),
  (NULL, NULL, 'F');

Ce code se termine avec succès.

Exécutez le code suivant pour interroger T3FK :

SELECT * FROM dbo.T3FK;

Vous obtenez le résultat suivant :

id          col1        col2        othercol   unqpath
----------- ----------- ----------- ---------- -----------------------
2           1           100         A          1.100
3           1           200         B          1.200
4           3           NULL        C          3.
5           NULL        300         D          .300
6           NULL        NULL        E          NULL
7           NULL        NULL        F          NULL

Il a donc fallu un peu de créativité, mais vous disposez maintenant d'une solution de contournement pour la contrainte unique standard, y compris la prise en charge des clés étrangères.

Conclusion

On pourrait penser qu'une contrainte unique est une fonctionnalité simple, mais cela peut devenir un peu délicat lorsque vous devez prendre en charge les valeurs NULL dans les colonnes uniques. Cela devient plus complexe lorsque vous devez implémenter la fonctionnalité de contrainte unique standard dans T-SQL, car les deux utilisent des règles différentes en termes de gestion des valeurs NULL. Dans cet article, j'ai expliqué la différence entre les deux et fourni des solutions de contournement qui fonctionnent dans T-SQL. Vous pouvez utiliser un index filtré simple lorsque vous devez appliquer l'unicité sur une seule colonne NULLable, et vous n'avez pas besoin de prendre en charge une clé étrangère qui référence cette colonne. Cependant, si vous devez prendre en charge une clé étrangère ou une contrainte unique composite avec la fonctionnalité standard, vous aurez besoin d'une implémentation plus complexe avec une clé de substitution et une colonne calculée.