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

Complexités NULL – Partie 2

Cet article est le deuxième d'une série sur les complexités NULL. Le mois dernier, j'ai introduit le NULL comme marqueur SQL pour tout type de valeur manquante. J'ai expliqué que SQL ne vous permet pas de faire la distinction entre manquant et applicable (valeurs A) et manquantes et inapplicables (valeurs I) marqueurs. J'ai également expliqué comment les comparaisons impliquant des valeurs NULL fonctionnent avec des constantes, des variables, des paramètres et des colonnes. Ce mois-ci, je continue la discussion en couvrant les incohérences de traitement NULL dans différents éléments T-SQL.

Je vais continuer à utiliser l'exemple de base de données TSQLV5 comme le mois dernier dans certains de mes exemples. Vous pouvez trouver le script qui crée et remplit cette base de données ici, et son diagramme ER ici.

Incohérences de traitement NULL

Comme vous l'avez déjà compris, le traitement NULL n'est pas trivial. Une partie de la confusion et de la complexité est liée au fait que le traitement des valeurs NULL peut être incohérent entre différents éléments de T-SQL pour des opérations similaires. Dans les sections à venir, je décris la gestion de NULL dans les calculs linéaires par rapport aux calculs agrégés, les clauses ON/WHERE/HAVING, la contrainte CHECK par rapport à l'option CHECK, les éléments IF/WHILE/CASE, l'instruction MERGE, la distinction et le regroupement, ainsi que l'ordre et l'unicité.

Calculs linéaires ou agrégés

T-SQL, et il en va de même pour le SQL standard, utilise une logique de gestion NULL différente lors de l'application d'une fonction d'agrégation réelle telle que SUM, MIN et MAX sur les lignes par rapport à l'application du même calcul qu'un calcul linéaire sur les colonnes. Pour illustrer cette différence, j'utiliserai deux exemples de tables appelées #T1 et #T2 que vous créez et remplissez en exécutant le code suivant :

DROP TABLE IF EXISTS #T1, #T2;
 
SELECT * INTO #T1 FROM ( VALUES(10, 5, NULL) ) AS D(col1, col2, col3);
 
SELECT * INTO #T2 FROM ( VALUES(10),(5),(NULL) ) AS D(col1);

La table #T1 a trois colonnes appelées col1, col2 et col3. Il a actuellement une ligne avec les valeurs de colonne 10, 5 et NULL, respectivement :

SELECT * FROM #T1;
col1        col2        col3
----------- ----------- -----------
10          5           NULL

La table #T2 a une colonne appelée col1. Il a actuellement trois lignes avec les valeurs 10, 5 et NULL dans col1 :

SELECT * FROM #T2;
col1
-----------
10
5
NULL

Lors de l'application de ce qui est finalement un calcul agrégé tel qu'une addition linéaire sur plusieurs colonnes, la présence de toute entrée NULL donne un résultat NULL. La requête suivante illustre ce comportement :

SELECT col1 + col2 + col3 AS total
FROM #T1;

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

total
-----------
NULL

Inversement, les fonctions d'agrégation réelles, qui sont appliquées sur les lignes, sont conçues pour ignorer les entrées NULL. La requête suivante illustre ce comportement à l'aide de la fonction SOMME :

SELECT SUM(col1) AS total
FROM #T2;

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

total
-----------
15

Warning: Null value is eliminated by an aggregate or other SET operation.

Notez l'avertissement mandaté par le standard SQL indiquant la présence d'entrées NULL qui ont été ignorées. Vous pouvez supprimer ces avertissements en désactivant l'option de session ANSI_WARNINGS.

De même, lorsqu'elle est appliquée à une expression d'entrée, la fonction COUNT compte le nombre de lignes avec des valeurs d'entrée non NULL (par opposition à COUNT(*) qui compte simplement le nombre de lignes). Par exemple, remplacer SUM(col1) par COUNT(col1) dans la requête ci-dessus renvoie le nombre de 2.

Curieusement, si vous appliquez un agrégat COUNT à une colonne définie comme n'autorisant pas les valeurs NULL, l'optimiseur convertit l'expression COUNT() en COUNT(*). Cela permet d'utiliser n'importe quel index à des fins de comptage au lieu d'exiger l'utilisation d'un index contenant la colonne en question. C'est une raison de plus au-delà d'assurer la cohérence et l'intégrité de vos données qui devrait vous encourager à appliquer des contraintes telles que NOT NULL et autres. De telles contraintes permettent à l'optimiseur une plus grande flexibilité pour envisager des alternatives plus optimales et éviter un travail inutile.

Sur la base de cette logique, la fonction AVG divise la somme des valeurs non NULL par le nombre de valeurs non NULL. Considérez la requête suivante comme exemple :

SELECT AVG(1.0 * col1) AS avgall
FROM #T2;

Ici, la somme des valeurs col1 non NULL 15 est divisée par le nombre de valeurs non NULL 2. Vous multipliez col1 par le littéral numérique 1.0 pour forcer la conversion implicite des valeurs d'entrée entières en valeurs numériques pour obtenir une division numérique et non un entier division. Cette requête génère la sortie suivante :

avgall
---------
7.500000

De même, les agrégats MIN et MAX ignorent les entrées NULL. Considérez la requête suivante :

SELECT MIN(col1) AS mincol1, MAX(col1) AS maxcol1
FROM #T2;

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

mincol1     maxcol1
----------- -----------
5           10

Tenter d'appliquer des calculs linéaires mais émuler la sémantique de la fonction d'agrégation (ignorer les NULL) n'est pas joli. Émuler SUM, COUNT et AVG n'est pas trop complexe, mais cela vous oblige à vérifier chaque entrée pour les valeurs NULL, comme ceci :

SELECT col1, col2, col3,
  CASE
    WHEN COALESCE(col1, col2, col3) IS NULL THEN NULL
    ELSE COALESCE(col1, 0) + COALESCE(col2, 0) + COALESCE(col3, 0)
  END AS sumall,
  CASE WHEN col1 IS NOT NULL THEN 1 ELSE 0 END
    + CASE WHEN col2 IS NOT NULL THEN 1 ELSE 0 END
    + CASE WHEN col3 IS NOT NULL THEN 1 ELSE 0 END AS cntall,
  CASE
    WHEN COALESCE(col1, col2, col3) IS NULL THEN NULL
    ELSE 1.0 * (COALESCE(col1, 0) + COALESCE(col2, 0) + COALESCE(col3, 0))
           / (CASE WHEN col1 IS NOT NULL THEN 1 ELSE 0 END
                + CASE WHEN col2 IS NOT NULL THEN 1 ELSE 0 END
                + CASE WHEN col3 IS NOT NULL THEN 1 ELSE 0 END)
  END AS avgall
FROM #T1;

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

col1        col2        col3        sumall      cntall      avgall
----------- ----------- ----------- ----------- ----------- ---------------
10          5           NULL        15          2           7.500000000000

Tenter d'appliquer un minimum ou un maximum en tant que calcul linéaire à plus de deux colonnes d'entrée est assez délicat avant même d'ajouter la logique pour ignorer les NULL car cela implique d'imbriquer plusieurs expressions CASE directement ou indirectement (lorsque vous réutilisez des alias de colonne). Par exemple, voici une requête calculant le maximum parmi col1, col2 et col3 dans #T1, sans la partie qui ignore les NULL :

SELECT col1, col2, col3, 
  CASE WHEN col1 IS NULL OR col2 IS NULL OR col3 IS NULL THEN NULL ELSE max2 END AS maxall
FROM #T1
  CROSS APPLY (VALUES(CASE WHEN col1 >= col2 THEN col1 ELSE col2 END)) AS A1(max1)
  CROSS APPLY (VALUES(CASE WHEN max1 >= col3 THEN max1 ELSE col3 END)) AS A2(max2);

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

col1        col2        col3        maxall
----------- ----------- ----------- -----------
10          5           NULL        NULL

Si vous examinez le plan de requête, vous trouverez l'expression développée suivante calculant le résultat final :

[Expr1005] = Scalar Operator(CASE WHEN CASE WHEN [#T1].[col1] IS NOT NULL THEN [#T1].[col1] ELSE 
  CASE WHEN [#T1].[col2] IS NOT NULL THEN [#T1].[col2] 
    ELSE [#T1].[col3] END END IS NULL THEN NULL ELSE 
  CASE WHEN CASE WHEN [#T1].[col1]>=[#T1].[col2] THEN [#T1].[col1] 
    ELSE [#T1].[col2] END>=[#T1].[col3] THEN 
  CASE WHEN [#T1].[col1]>=[#T1].[col2] THEN [#T1].[col1] 
    ELSE [#T1].[col2] END ELSE [#T1].[col3] END END)

Et c'est là qu'il n'y a que trois colonnes impliquées. Imaginez avoir une douzaine de colonnes impliquées !

Ajoutez maintenant à cela la logique pour ignorer les NULL :

SELECT col1, col2, col3, max2 AS maxall
FROM #T1
  CROSS APPLY (VALUES(CASE WHEN col1 >= col2 OR col2 IS NULL THEN col1 ELSE col2 END)) AS A1(max1)
  CROSS APPLY (VALUES(CASE WHEN max1 >= col3 OR col3 IS NULL THEN max1 ELSE col3 END)) AS A2(max2);

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

col1        col2        col3        maxall
----------- ----------- ----------- -----------
10          5           NULL        10

Oracle a une paire de fonctions appelées GREATEST et LEAST qui appliquent des calculs minimum et maximum, respectivement, comme des calculs linéaires aux valeurs d'entrée. Ces fonctions renvoient un NULL étant donné toute entrée NULL comme le font la plupart des calculs linéaires. Il y avait un élément de rétroaction ouvert demandant d'obtenir des fonctions similaires dans T-SQL, mais cette demande n'a pas été transférée dans leur dernière modification du site de rétroaction. Si Microsoft ajoute de telles fonctions à T-SQL, ce serait formidable d'avoir une option contrôlant s'il faut ignorer ou non les valeurs NULL.

En attendant, il existe une technique beaucoup plus élégante par rapport à celles mentionnées ci-dessus qui calcule tout type d'agrégat comme un agrégat linéaire à travers les colonnes en utilisant la sémantique de la fonction d'agrégation réelle en ignorant les NULL. Vous utilisez une combinaison de l'opérateur CROSS APPLY et d'une requête de table dérivée sur un constructeur de valeur de table qui fait pivoter les colonnes en lignes et applique l'agrégat comme une fonction d'agrégation réelle. Voici un exemple illustrant les calculs MIN et MAX, mais vous pouvez utiliser cette technique avec n'importe quelle fonction d'agrégation que vous aimez :

SELECT col1, col2, col3, maxall, minall
FROM #T1 CROSS APPLY
  (SELECT MAX(mycol), MIN(mycol)
   FROM (VALUES(col1),(col2),(col3)) AS D1(mycol)) AS D2(maxall, minall);

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

col1        col2        col3        maxall      minall
----------- ----------- ----------- ----------- -----------
10          5           NULL        10          5

Et si vous vouliez le contraire ? Que se passe-t-il si vous avez besoin de calculer un agrégat sur plusieurs lignes, mais de produire un NULL s'il y a une entrée NULL ? Par exemple, supposons que vous deviez additionner toutes les valeurs col1 de #T1, mais renvoyez NULL si l'une des entrées est NULL. Ceci peut être réalisé avec la technique suivante :

SELECT SUM(col1) * NULLIF(MIN(CASE WHEN col1 IS NULL THEN 0 ELSE 1 END), 0) AS sumall
FROM #T2;

Vous appliquez un agrégat MIN à une expression CASE qui renvoie des zéros pour les entrées NULL et des uns pour les entrées non NULL. S'il y a une entrée NULL, le résultat de la fonction MIN est 0, sinon c'est 1. Ensuite, en utilisant la fonction NULLIF, vous convertissez un résultat 0 en NULL. Vous multipliez ensuite le résultat de la fonction NULLIF par la somme d'origine. S'il y a une entrée NULL, vous multipliez la somme d'origine par un NULL donnant un NULL. S'il n'y a pas d'entrée NULL, vous multipliez le résultat de la somme d'origine par 1, ce qui donne la somme d'origine.

De retour aux calculs linéaires produisant un NULL pour toute entrée NULL, la même logique s'applique à la concaténation de chaînes à l'aide de l'opérateur +, comme le montre la requête suivante :

USE TSQLV5;
 
SELECT empid, country, region, city,
  country + N',' + region + N',' + city AS emplocation
FROM HR.Employees;

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

empid       country         region          city            emplocation
----------- --------------- --------------- --------------- ----------------
1           USA             WA              Seattle         USA,WA,Seattle
2           USA             WA              Tacoma          USA,WA,Tacoma
3           USA             WA              Kirkland        USA,WA,Kirkland
4           USA             WA              Redmond         USA,WA,Redmond
5           UK              NULL            London          NULL
6           UK              NULL            London          NULL
7           UK              NULL            London          NULL
8           USA             WA              Seattle         USA,WA,Seattle
9           UK              NULL            London          NULL

Vous souhaitez concaténer les parties de localisation des employés en une seule chaîne, en utilisant une virgule comme séparateur. Mais vous voulez ignorer les entrées NULL. Au lieu de cela, lorsque l'une des entrées est NULL, vous obtenez un NULL comme résultat. Certains désactivent l'option de session CONCAT_NULL_YIELDS_NULL, qui entraîne la conversion d'une entrée NULL en une chaîne vide à des fins de concaténation, mais cette option n'est pas recommandée car elle applique un comportement non standard. De plus, vous vous retrouverez avec plusieurs séparateurs consécutifs lorsqu'il y a des entrées NULL, ce qui n'est généralement pas le comportement souhaité. Une autre option consiste à remplacer explicitement les entrées NULL par une chaîne vide à l'aide des fonctions ISNULL ou COALESCE, mais cela entraîne généralement un code long et détaillé. Une option beaucoup plus élégante consiste à utiliser la fonction CONCAT_WS, qui a été introduite dans SQL Server 2017. Cette fonction concatène les entrées, en ignorant les NULL, en utilisant le séparateur fourni comme première entrée. Voici la requête de solution utilisant cette fonction :

SELECT empid, country, region, city,
  CONCAT_WS(N',', country, region, city) AS emplocation
FROM HR.Employees;

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

empid       country         region          city            emplocation
----------- --------------- --------------- --------------- ----------------
1           USA             WA              Seattle         USA,WA,Seattle
2           USA             WA              Tacoma          USA,WA,Tacoma
3           USA             WA              Kirkland        USA,WA,Kirkland
4           USA             WA              Redmond         USA,WA,Redmond
5           UK              NULL            London          UK,London
6           UK              NULL            London          UK,London
7           UK              NULL            London          UK,London
8           USA             WA              Seattle         USA,WA,Seattle
9           UK              NULL            London          UK,London

SUR/OÙ/AVOIR

Lorsque vous utilisez les clauses de requête WHERE, HAVING et ON à des fins de filtrage/correspondance, il est important de se rappeler qu'elles utilisent une logique de prédicat à trois valeurs. Lorsque vous avez une logique à trois valeurs impliquée, vous souhaitez identifier avec précision comment la clause gère les cas TRUE, FALSE et UNKNOWN. Ces trois clauses sont conçues pour accepter les cas TRUE et rejeter les cas FALSE et UNKNOWN.

Pour illustrer ce comportement, je vais utiliser une table appelée Contacts que vous créez et remplissez en exécutant le code suivant :.

DROP TABLE IF EXISTS dbo.Contacts;
GO
 
CREATE TABLE dbo.Contacts
(
  id INT NOT NULL 
    CONSTRAINT PK_Contacts PRIMARY KEY,
  name VARCHAR(10) NOT NULL,
  hourlyrate NUMERIC(12, 2) NULL
    CONSTRAINT CHK_Contacts_hourlyrate CHECK(hourlyrate > 0.00)
);
 
INSERT INTO dbo.Contacts(id, name, hourlyrate) VALUES
  (1, 'A', 100.00),(2, 'B', 200.00),(3, 'C', NULL);

Notez que les contacts 1 et 2 ont des taux horaires applicables, mais pas le contact 3, donc son taux horaire est défini sur NULL. Considérez la requête suivante à la recherche de contacts avec un taux horaire positif :

SELECT id, name, hourlyrate
FROM dbo.Contacts
WHERE hourlyrate > 0.00;

Ce prédicat est évalué à TRUE pour les contacts 1 et 2, et à UNKNOWN pour le contact 3, donc la sortie ne contient que les contacts 1 et 2 :

id          name       hourlyrate
----------- ---------- -----------
1           A          100.00
2           B          200.00

L'idée ici est que lorsque vous êtes certain que le prédicat est vrai, vous voulez retourner la ligne, sinon vous voulez la supprimer. Cela peut sembler trivial au début, jusqu'à ce que vous vous rendiez compte que certains éléments de langage qui utilisent également des prédicats fonctionnent différemment.

Contrainte CHECK versus option CHECK

Une contrainte CHECK est un outil que vous utilisez pour appliquer l'intégrité dans une table basée sur un prédicat. Le prédicat est évalué lorsque vous tentez d'insérer ou de mettre à jour des lignes dans la table. Contrairement au filtrage des requêtes et aux clauses de mise en correspondance qui acceptent les cas TRUE et rejettent les cas FALSE et UNKNOWN, une contrainte CHECK est conçue pour accepter les cas TRUE et UNKNOWN et rejeter les cas FALSE. L'idée ici est que lorsque vous êtes certain que le prédicat est faux, vous voulez rejeter la tentative de modification, sinon vous voulez l'autoriser.

Si vous examinez la définition de notre table Contacts, vous remarquerez qu'elle a la contrainte CHECK suivante, rejetant les contacts avec des taux horaires non positifs :

CONSTRAINT CHK_Contacts_hourlyrate CHECK(hourlyrate > 0.00)

Notez que la contrainte utilise le même prédicat que celui que vous avez utilisé dans le filtre de requête précédent.

Essayez d'ajouter un contact avec un taux horaire positif :

INSERT INTO dbo.Contacts(id, name, hourlyrate) VALUES (4, 'D', 150.00);

Cette tentative réussit.

Essayez d'ajouter un contact avec un taux horaire NULL :

INSERT INTO dbo.Contacts(id, name, hourlyrate) VALUES (5, 'E', NULL);

Cette tentative réussit également, car une contrainte CHECK est conçue pour accepter les cas TRUE et UNKNOWN. C'est le cas où un filtre de requête et une contrainte CHECK sont conçus pour fonctionner différemment.

Essayez d'ajouter un contact avec un taux horaire non positif :

INSERT INTO dbo.Contacts(id, name, hourlyrate) VALUES (6, 'F', -100.00);

Cette tentative échoue avec l'erreur suivante :

Msg 547, Niveau 16, État 0, Ligne 454
L'instruction INSERT est en conflit avec la contrainte CHECK "CHK_Contacts_hourlyrate". Le conflit s'est produit dans la base de données "TSQLV5", table "dbo.Contacts", colonne "hourlyrate".

T-SQL vous permet également d'appliquer l'intégrité des modifications via des vues à l'aide d'une option CHECK. Certains pensent que cette option sert un objectif similaire à une contrainte CHECK tant que vous appliquez la modification via la vue. Par exemple, considérez la vue suivante, qui utilise un filtre basé sur le prédicat taux horaire> 0,00 et est définie avec l'option CHECK :

CREATE OR ALTER VIEW dbo.MyContacts
AS
SELECT id, name, hourlyrate
FROM dbo.Contacts
WHERE hourlyrate > 0.00
WITH CHECK OPTION;

En fait, contrairement à une contrainte CHECK, l'option de vue CHECK est conçue pour accepter les cas TRUE et rejeter les cas FALSE et UNKNOWN. Il est donc en fait conçu pour se comporter davantage comme le filtre de requête le fait normalement également dans le but de renforcer l'intégrité.

Essayez d'insérer une ligne avec un taux horaire positif dans la vue :

INSERT INTO dbo.MyContacts(id, name, hourlyrate) VALUES (7, 'G', 300.00);

Cette tentative réussit.

Essayez d'insérer une ligne avec un taux horaire NULL dans la vue :

INSERT INTO dbo.MyContacts(id, name, hourlyrate) VALUES (8, 'H', NULL);

Cette tentative échoue avec l'erreur suivante :

Msg 550, Niveau 16, État 1, Ligne 473
La tentative d'insertion ou de mise à jour a échoué car la vue cible spécifie WITH CHECK OPTION ou s'étend sur une vue qui spécifie WITH CHECK OPTION et une ou plusieurs lignes résultant de l'opération n'ont pas se qualifier sous la contrainte CHECK OPTION.

L'idée ici est qu'une fois que vous avez ajouté l'option CHECK à la vue, vous souhaitez uniquement autoriser les modifications résultant en des lignes renvoyées par la vue. C'est un peu différent de la pensée avec une contrainte CHECK - rejeter les modifications pour lesquelles vous êtes certain que le prédicat est faux. Cela peut être un peu déroutant. Si vous souhaitez que la vue autorise les modifications qui définissent le taux horaire sur NULL, vous avez besoin que le filtre de requête les autorise également en ajoutant OR hourlyrate IS NULL. Vous devez juste réaliser qu'une contrainte CHECK et une option CHECK sont conçues pour fonctionner différemment par rapport au cas UNKNOWN. Le premier l'accepte tandis que le second le rejette.

Interrogez la table Contacts après toutes les modifications ci-dessus :

SELECT id, name, hourlyrate
FROM dbo.Contacts;

Vous devriez obtenir la sortie suivante à ce stade :

id          name       hourlyrate
----------- ---------- -----------
1           A          100.00
2           B          200.00
3           C          NULL
4           D          150.00
5           E          NULL
7           G          300.00

SI/PENDANT/CASE

Les éléments de langage IF, WHILE et CASE fonctionnent avec des prédicats.

L'instruction IF est conçue comme suit :

IF <predicate>
  <statement or BEGIN-END block when TRUE>
ELSE
  <statement or BEGIN-END block when FALSE or UNKNOWN>

Il est intuitif de s'attendre à avoir un bloc TRUE après la clause IF et un bloc FALSE après la clause ELSE, mais vous devez réaliser que la clause ELSE est réellement activée lorsque le prédicat est FALSE ou UNKNOWN. Théoriquement, un langage logique à trois valeurs aurait pu avoir une instruction IF avec une séparation des trois cas. Quelque chose comme ça :

IF <predicate>
  WHEN TRUE
    <statement or BEGIN-END block when TRUE>
  WHEN FALSE
    <statement or BEGIN-END block when FALSE>
  WHEN UNKNOWN
    <statement or BEGIN-END block when UNKNOWN>

Et même autoriser des combinaisons de résultats logiques de sorte que si vous vouliez combiner FALSE et UNKNOWN dans une seule section, vous pourriez utiliser quelque chose comme ceci :

IF <predicate>
  WHEN TRUE
    <statement or BEGIN-END block when TRUE>
  WHEN FALSE OR UNKNOWN
    <statement or BEGIN-END block when FALSE OR UNKNOWN>

En attendant, vous pouvez émuler de telles constructions en imbriquant des instructions IF-ELSE et en recherchant explicitement des valeurs NULL dans les opérandes avec l'opérateur IS NULL.

L'instruction WHILE n'a qu'un bloc TRUE. Il est conçu comme suit :

WHILE <predicate>
  <statement or BEGIN-END block when TRUE>

L'instruction ou le bloc BEGIN-END formant le corps de la boucle est activé alors que le prédicat est TURE. Dès que le prédicat est FALSE ou UNKNOWN, le contrôle passe à l'instruction suivant la boucle WHILE.

Contrairement à IF et WHILE, qui sont des instructions exécutant du code, CASE est une expression renvoyant une valeur. La syntaxe d'un recherché L'expression CASE est la suivante :

CASE
  WHEN <predicate 1> THEN <expression 1 when TRUE>
  WHEN <predicate 2> THEN <expression 2 when TRUE >
  ...
  WHEN <predicate n> THEN <expression n when TRUE >
  ELSE <else expression when all are FALSE or UNKNOWN>
END

Une expression CASE est conçue pour renvoyer l'expression suivant la clause THEN qui correspond au premier prédicat WHEN qui prend la valeur TRUE. S'il y a une clause ELSE, elle est activée si aucun prédicat WHEN n'est TRUE (tous sont FALSE ou UNKNOWN). En l'absence d'une clause ELSE explicite, un ELSE NULL implicite est utilisé. Si vous souhaitez gérer un cas UNKNOWN séparément, vous pouvez rechercher explicitement les valeurs NULL dans les opérandes du prédicat à l'aide de l'opérateur IS NULL.

Un simple L'expression CASE utilise des comparaisons implicites basées sur l'égalité entre l'expression source et les expressions comparées :

CASE <source expression>
  WHEN <comp expression 1> THEN <result expression 1 when TRUE>
  WHEN <comp expression 2> THEN <result expression 2 when TRUE >
  ...
  WHEN <comp expression n> THEN <result expression n when TRUE >
  ELSE <else result expression when all are FALSE or UNKNOWN>
END

L'expression CASE simple est conçue de manière similaire à l'expression CASE recherchée en termes de gestion de la logique à trois valeurs, mais comme les comparaisons utilisent une comparaison basée sur l'égalité implicite, vous ne pouvez pas gérer le cas UNKNOWN séparément. Une tentative d'utilisation d'un NULL dans l'une des expressions comparées dans les clauses WHEN n'a aucun sens puisque la comparaison ne donnera pas TRUE même si l'expression source est NULL. Prenons l'exemple suivant :

DECLARE @input AS INT = NULL;
 
SELECT CASE @input WHEN NULL THEN 'Input is NULL' ELSE 'Input is not NULL' END;

Ceci est converti implicitement en ce qui suit :

DECLARE @input AS INT = NULL;
 
SELECT CASE WHEN @input = NULL THEN 'Input is NULL' ELSE 'Input is not NULL' END;

Par conséquent, le résultat est :

L'entrée n'est pas NULL

Pour détecter une entrée NULL, vous devez utiliser la syntaxe de l'expression CASE recherchée et l'opérateur IS NULL, comme ceci :

DECLARE @input AS INT = NULL;
 
SELECT CASE WHEN @input IS NULL THEN 'Input is NULL' ELSE 'Input is not NULL' END;

Cette fois, le résultat est :

L'entrée est NULL

FUSIONNER

L'instruction MERGE est utilisée pour fusionner les données d'une source dans une cible. Vous utilisez un prédicat de fusion pour identifier les cas suivants et appliquer une action sur la cible :

  • Une ligne source correspond à une ligne cible (activé lorsqu'une correspondance est trouvée pour la ligne source où le prédicat de fusion est VRAI) :appliquez UPDATE ou DELETE à la cible
  • Une ligne source ne correspond pas à une ligne cible (activé lorsqu'aucune correspondance n'est trouvée pour la ligne source où le prédicat de fusion est TRUE, plutôt pour tous le prédicat est FALSE ou UNKNOWN) :appliquez un INSERT contre la cible
  • Une ligne cible ne correspond pas à une ligne source (activé lorsqu'aucune correspondance n'est trouvée pour la ligne cible où le prédicat de fusion est TRUE, plutôt pour tous le prédicat est FALSE ou UNKNOWN) :appliquez UPDATE ou DELETE contre la cible

Les trois scénarios séparent VRAI pour un groupe et FAUX ou INCONNU pour un autre. Vous n'obtenez pas de sections distinctes pour gérer VRAI, gérer FAUX et gérer les cas INCONNUS.

Pour illustrer cela, je vais utiliser une table appelée T3 que vous créez et remplissez en exécutant le code suivant :

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) VALUES(1),(2),(NULL);

Considérez l'instruction MERGE suivante :

MERGE INTO dbo.T3 AS TGT
USING (VALUES(1, 100), (3, 300)) AS SRC(col1, col2)
  ON SRC.col1 = TGT.col1
WHEN MATCHED THEN UPDATE
  SET TGT.col2 = SRC.col2
WHEN NOT MATCHED THEN INSERT(col1, col2) VALUES(SRC.col1, SRC.col2)
WHEN NOT MATCHED BY SOURCE THEN UPDATE
  SET col2 = -1;
 
SELECT col1, col2 FROM dbo.T3;

La ligne source où col1 vaut 1 est mise en correspondance avec la ligne cible où col1 vaut 1 (le prédicat est TRUE) et donc la col2 de la ligne cible est définie sur 100.

La ligne source où col1 vaut 3 ne correspond à aucune ligne cible (pour tous le prédicat est FALSE ou UNKNOWN) et donc une nouvelle ligne est insérée dans T3 avec 3 comme valeur col1 et 300 comme valeur col2.

Les lignes cibles où col1 est 2 et où col1 est NULL ne correspondent à aucune ligne source (pour toutes les lignes, le prédicat est FALSE ou UNKNOWN) et donc dans les deux cas, col2 dans les lignes cibles est défini sur -1.

La requête sur T3 renvoie la sortie suivante après l'exécution de l'instruction MERGE ci-dessus :

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

Gardez la table T3 à portée de main; il est utilisé plus tard.

Distinction et regroupement

Contrairement aux comparaisons effectuées à l'aide d'opérateurs d'égalité et d'inégalité, les comparaisons effectuées à des fins de distinction et de regroupement regroupent les valeurs NULL. Un NULL est considéré comme non distinct d'un autre NULL, mais un NULL est considéré comme distinct d'une valeur non NULL. Par conséquent, l'application d'une clause DISTINCT supprime les occurrences en double de NULL. La requête suivante le démontre :

SELECT DISTINCT country, region FROM HR.Employees;

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

country         region
--------------- ---------------
UK              NULL
USA             WA

Il y a plusieurs employés avec le pays USA et la région NULL, et après la suppression des doublons, le résultat montre une seule occurrence de la combinaison.

Comme la distinction, le regroupement regroupe également les valeurs NULL, comme le montre la requête suivante :

SELECT country, region, COUNT(*) AS numemps
FROM HR.Employees
GROUP BY country, region;

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

country         region          numemps
--------------- --------------- -----------
UK              NULL            4
USA             WA              5

Encore une fois, les quatre employés avec le pays UK et la région NULL ont été regroupés.

Commander

L'ordre traite plusieurs NULL comme ayant la même valeur d'ordre. La norme SQL laisse à l'implémentation le soin de choisir de classer les valeurs NULL en premier ou en dernier par rapport aux valeurs non NULL. Microsoft a choisi de considérer les valeurs NULL comme ayant des valeurs de classement inférieures à celles des valeurs non NULL dans SQL Server. Par conséquent, lors de l'utilisation de l'ordre croissant, T-SQL classe les valeurs NULL en premier. La requête suivante le démontre :

SELECT id, name, hourlyrate
FROM dbo.Contacts
ORDER BY hourlyrate;

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

id          name       hourlyrate
----------- ---------- -----------
3           C          NULL
5           E          NULL
1           A          100.00
4           D          150.00
2           B          200.00
7           G          300.00

Le mois prochain, j'ajouterai plus sur ce sujet, en discutant des éléments standard qui vous permettent de contrôler le comportement de commande NULL et les solutions de contournement pour ces éléments dans T-SQL.

Unicité

Lors de l'application de l'unicité sur une colonne NULLable à l'aide d'une contrainte UNIQUE ou d'un index unique, T-SQL traite les valeurs NULL comme des valeurs non NULL. Il rejette les NULL en double comme si un NULL n'était pas unique par rapport à un autre NULL.

Rappelons que notre table T3 a une contrainte UNIQUE définie sur col1. Voici sa définition :

CONSTRAINT UNQ_T3 UNIQUE(col1)

Interrogez T3 pour voir son contenu actuel :

SELECT * FROM dbo.T3;

Si vous avez exécuté toutes les modifications sur T3 à partir des exemples précédents de cet article, vous devriez obtenir le résultat suivant :

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

Essayez d'ajouter une deuxième ligne avec un NULL dans col1 :

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

Vous obtenez l'erreur suivante :

Msg 2627, Niveau 14, État 1, Ligne 558
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 ().

Ce comportement est en fait non standard. Le mois prochain, je décrirai la spécification standard et comment l'émuler dans T-SQL.

Conclusion

Dans cette deuxième partie de la série sur les complexités NULL, je me suis concentré sur les incohérences de traitement NULL entre différents éléments T-SQL. J'ai couvert les calculs linéaires et agrégés, les clauses de filtrage et de correspondance, la contrainte CHECK par rapport à l'option CHECK, les éléments IF, WHILE et CASE, l'instruction MERGE, la distinction et le regroupement, l'ordre et l'unicité. Les incohérences que j'ai couvertes soulignent en outre à quel point il est important de comprendre correctement le traitement des NULL dans la plate-forme que vous utilisez, pour vous assurer que vous écrivez un code correct et robuste. Le mois prochain, je continuerai la série en couvrant les options de traitement NULL standard SQL qui ne sont pas disponibles dans T-SQL, et en fournissant des solutions de contournement qui sont prises en charge dans T-SQL.