SQL Server, de la zero Lecția 7 / 40

WHERE, NULL și de ce filtrele te mint

Logica trivalentă, IS NULL vs = NULL, ISNULL vs COALESCE și bug-ul tăcut pe care fiecare dezvoltator îl scrie odată. Cu exemple din suportul clienți Runehold.

Iată unul dintre faptele SQL pe care fiecare dezvoltator le învață prin durere și apoi e enervat că a trebuit să le învețe prin durere: SQL nu este un sistem de logică cu două valori. Are trei rezultate posibile dintr-o comparație: TRUE, FALSE și UNKNOWN. A treia e NULL, iar dacă nu înțelegi cum funcționează, interogările tale vor pierde tăcut rânduri pe care le voiai sau vor păstra tăcut rânduri pe care nu le voiai. Fără eroare. Doar răspunsuri greșite.

Astăzi reparăm asta definitiv.

Ce înseamnă de fapt NULL

În SQL, NULL nu e zero. Nu e șir gol. Nu e „false”. Înseamnă „valoare necunoscută sau valoare absentă, baza de date nu are nicio informație aici.”

Fiecare tip de date din SQL Server poate fi NULL cu excepția cazului în care ai declarat coloana NOT NULL. Coloana Email a unui rând ar putea avea un email real sau ar putea fi NULL dacă clientul nu ți-a dat niciodată unul. O OrderDate ar putea fi NULL pentru că coșul de cumpărături nu a fost încă finalizat. Un ShippedAt este NULL până când depozitul scanează coletul.

Asta e util. Necazul începe atunci când compari.

Comparațiile cu NULL returnează NULL, nu FALSE

Încearcă asta în minte mai întâi, apoi rulează:

SELECT
    1 = 1                           AS one_eq_one,          -- TRUE
    1 = 2                           AS one_eq_two,          -- FALSE
    1 = NULL                        AS one_eq_null,         -- NULL  (not FALSE!)
    NULL = NULL                     AS null_eq_null,        -- NULL  (not TRUE!)
    NULL <> NULL                    AS null_neq_null,       -- NULL
    1 <> NULL                       AS one_neq_null,        -- NULL
    NULL + 1                        AS null_plus_one;       -- NULL

Regula: orice expresie care implică NULL se evaluează la NULL. Nu „false”. E modul în care baza de date îți spune „nu pot să-ți spun; o parte e necunoscută.”

Clauzele WHERE păstrează doar rândurile în care filtrul este TRUE. Rândurile în care filtrul este FALSE sunt excluse. Rândurile în care filtrul este NULL sunt și ele excluse. Deci „nu FALSE” și „nu TRUE” sunt de fapt aceleași din perspectiva filtrului, dar conceptual contează când raționezi despre interogarea ta.

IS NULL, nu = NULL

Pentru că = NULL este NULL, nu TRUE sau FALSE, nu poți folosi niciodată = ca să verifici NULL. Trebuie să folosești IS NULL și IS NOT NULL:

-- WRONG: returns zero rows even if there are customers with NULL emails
SELECT CustomerId FROM Sales.Customer WHERE Email = NULL;

-- RIGHT
SELECT CustomerId FROM Sales.Customer WHERE Email IS NULL;
SELECT CustomerId FROM Sales.Customer WHERE Email IS NOT NULL;

Dacă aș avea 1€ pentru fiecare bug în care cineva a scris WHERE deleted_at = NULL și se întreba de ce nu funcționează filtrul lui de soft-delete, aș avea destul cât să-mi acopăr un abonament Runehold Loremaster.

Capcana „diferit de”, cu date

Iată o capcană care îi prinde pe toți, inclusiv pe mine săptămâna trecută.

Echipa de suport clienți Runehold vrea să trimită un sondaj fiecărui client din afara Olandei. Interogarea evidentă:

SELECT Email
FROM Sales.Customer
WHERE Country <> 'NL';

Pare corectă. E greșită.

Din cauza logicii trivalente, orice rând în care Country este NULL nu se potrivește cu <> 'NL' — pentru că NULL <> 'NL' este NULL, nu TRUE. Clienții cu o țară lipsă sunt excluși tăcut din sondaj. I-ai ratat. Marketing-ul trimite campania, iar trei săptămâni mai târziu cineva observă că segmentul „Alte țări” a avut mult mai puțini oameni decât se așteptau.

Forma sigură:

SELECT Email
FROM Sales.Customer
WHERE Country <> 'NL' OR Country IS NULL;

-- or, often clearer:
SELECT Email
FROM Sales.Customer
WHERE ISNULL(Country, '') <> 'NL';

ISNULL(col, default) înlocuiește NULL cu valoarea implicită înainte de comparație. Acum o țară NULL e tratată ca '' și '' <> 'NL' este TRUE. Sondajul ajunge la toți cu excepția clienților NL.

De fiecare dată când scrii <>, oprește-te și întreabă-te: „poate fi această coloană NULL și vreau acele rânduri sau nu?” Durează două secunde. Salvează campanii.

ISNULL vs COALESCE

Două funcții care fac aproape același lucru. Ambele returnează prima valoare non-NULL din argumentele lor. Diferențe care contează:

-- ISNULL: exactly two arguments, T-SQL-specific
SELECT ISNULL(Phone, 'no phone') FROM Sales.Customer;

-- COALESCE: two or more arguments, standard SQL
SELECT COALESCE(Phone, MobilePhone, 'no phone') FROM Sales.Customer;

COALESCE este alegerea implicită corectă. E forma standard SQL, funcționează în mai multe baze de date dacă vreodată îți portezi interogările și acceptă orice număr de argumente. ISNULL e mai scurt pentru cazul cu două argumente și e ușor mai rapid în unele micro-benchmark-uri, dar nimeni nu observă asta în afara viselor optimizatorului de interogări.

Capcană subtilă care mi-a făcut probleme:

-- ISNULL returns the data type of the first argument
SELECT ISNULL(NULL, 'fallback value that is too long');
-- This returns 'fallback v' — truncated to 1 character because
-- ISNULL thought the type was implied by the NULL literal.
-- In modern SQL Server this warns; older versions silently truncated.

-- COALESCE uses rules from the SQL standard: it picks the type with
-- the highest precedence from all arguments. Safer.
SELECT COALESCE(NULL, 'fallback value that is too long');
-- Returns the full string.

Precedența tipurilor e o gaură de iepure. Când ai dubii, fă cast explicit.

NULL și aritmetica

Aritmetica cu NULL propagă NULL:

SELECT 5 + NULL;               -- NULL
SELECT 10 / NULL;              -- NULL
SELECT 'foo' + NULL;           -- NULL  (string concat)

Așa că dacă ai o coloană Discount care poate fi NULL, nu scrie:

SELECT Total - Discount AS NetTotal FROM Sales.Orders;

…pentru că pentru orice rând în care Discount este NULL, NetTotal va fi de asemenea NULL. Probabil nu asta voiai. Folosește COALESCE:

SELECT Total - COALESCE(Discount, 0) AS NetTotal FROM Sales.Orders;

Acest tipar — „dacă valoarea e NULL, tratează-o ca zero pentru calcul” — apare constant.

NULL și agregatele

Funcțiile de agregare (SUM, AVG, COUNT, MAX, MIN) în general ignoră NULL-urile. Se comportă ca „dă-mi agregatul valorilor non-NULL.”

-- Table has 5 rows: Total values 10, 20, NULL, 30, 40
SELECT SUM(Total) AS total_sum,        -- 100 (10+20+30+40)
       AVG(Total) AS total_avg,        -- 25  (100 / 4, not 100 / 5)
       COUNT(Total) AS count_non_null,  -- 4
       COUNT(*)     AS count_rows       -- 5
FROM Sales.Orders;

COUNT(*) numără rândurile indiferent de NULL. COUNT(column) numără doar rândurile în care column nu este NULL. E o capcană comună la interviuri.

Clauza WHERE și prietenia cu indexurile

WHERE decide ce index alege SQL Server (dacă alege vreunul). Scrierea unui WHERE care e SARGable — prescurtarea de la „Search ARGument-able”, adică SQL Server poate face seek pe un index pentru el — e jumătate din bătălia scrierii de interogări rapide.

SARGable (indexul poate face seek):

WHERE CountryCode = 'IT'
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2026-04-01'
WHERE Total BETWEEN 50 AND 500
WHERE CustomerId IN (1, 2, 3, 4)
WHERE Email LIKE 'anne%'     -- prefix search: OK

Nu SARGable (indexul trebuie să facă scan):

WHERE YEAR(OrderDate) = 2026           -- function on the column
WHERE UPPER(Name) = 'ANNE DE VRIES'    -- function on the column
WHERE Email LIKE '%@gmail.com'          -- leading wildcard
WHERE CAST(OrderId AS NVARCHAR) = '42'  -- explicit conversion
WHERE Total + Discount > 100            -- arithmetic on the column
WHERE OrderDate = @date  -- (if @date is a different type, implicit conversion)

Regula: nu pune coloana indexată într-o funcție, cast sau expresie. Mută transformarea în partea cealaltă a comparației când e posibil:

-- Bad: function on column
WHERE CAST(OrderDate AS DATE) = '2026-03-15'

-- Good: rewrite as a range
WHERE OrderDate >= '2026-03-15' AND OrderDate < '2026-03-16'

-- Bad: arithmetic on column
WHERE Total + 10 > @threshold

-- Good: move arithmetic to the other side
WHERE Total > @threshold - 10

Acest singur obicei valorează mai mult decât orice sfat despre indexare. Un index altfel perfect e inutil împotriva unui WHERE non-SARGable.

IN, NOT IN și capcana NULL lovește din nou

IN verifică apartenența la o listă:

SELECT Name FROM Sales.Customer WHERE Country IN ('IT', 'ES', 'FR');

Direct. NOT IN e unde devine ciudat:

-- Looks like "customers not in NL, DE, or IT"
SELECT Name FROM Sales.Customer
WHERE Country NOT IN ('NL', 'DE', 'IT');

Asta e bine dacă lista ('NL', 'DE', 'IT') nu conține un NULL sau nu vine dintr-o subinterogare care poate returna NULL. Atunci tot NOT IN se evaluează la NULL pentru fiecare rând și obții zero rezultate. Cauza e logica trivalentă aplicată negației.

Tipar mai sigur și modern când e implicată o subinterogare:

-- Say we're looking for customers who haven't placed an order
-- NOT IN form, dangerous if CustomerId can ever be NULL in Orders
SELECT Name FROM Sales.Customer
WHERE CustomerId NOT IN (SELECT CustomerId FROM Sales.Orders);

-- NOT EXISTS form, safe against NULL
SELECT Name FROM Sales.Customer c
WHERE NOT EXISTS (
    SELECT 1 FROM Sales.Orders o WHERE o.CustomerId = c.CustomerId
);

Folosește NOT EXISTS în loc de NOT IN cu subinterogări. E logică echivalentă, sigură împotriva NULL și produce de obicei un plan de execuție identic sau mai bun.

Rulează asta pe propria mașină

USE Runehold;
GO

-- Let's make one Customer row have NULL Country to demonstrate
INSERT INTO Sales.Customer (Name, Country)
VALUES ('Mysterious Stranger', NULL);

-- Query 1: the "<>" trap
-- How many customers are NOT in Netherlands?
SELECT COUNT(*) AS wrong_count
FROM Sales.Customer
WHERE Country <> 'NL';
-- Returns 3 — missing the mysterious stranger.

SELECT COUNT(*) AS right_count
FROM Sales.Customer
WHERE Country <> 'NL' OR Country IS NULL;
-- Returns 4.

-- Query 2: safe fallback
SELECT Name,
       ISNULL(Country, '??') AS CountryOrMystery
FROM Sales.Customer
ORDER BY Country;

-- Query 3: NULL propagation in arithmetic
-- Add a nullable column Discount to Orders for the demo
ALTER TABLE Sales.Orders ADD Discount DECIMAL(19, 4) NULL;

-- Some orders have discount, some don't
UPDATE Sales.Orders SET Discount = 5.00 WHERE OrderId IN (1, 3);

SELECT OrderId,
       Total,
       Discount,
       Total - Discount               AS NetWrong,   -- NULL for rows with no discount
       Total - COALESCE(Discount, 0)  AS NetRight
FROM Sales.Orders
ORDER BY OrderId;

-- Query 4: aggregate ignores NULL
SELECT
    COUNT(*)         AS rows_total,     -- all rows
    COUNT(Discount)  AS rows_with_disc, -- only non-NULL
    SUM(Discount)    AS total_discount, -- NULL rows skipped
    AVG(Discount)    AS avg_discount    -- average over non-NULL only
FROM Sales.Orders;

-- Clean up
ALTER TABLE Sales.Orders DROP COLUMN Discount;
DELETE FROM Sales.Customer WHERE Country IS NULL;

Rulează linie cu linie. Citește fiecare rezultat. Anticipează fiecare număr înainte să te uiți la el. Când ești surprins, recitește secțiunea relevantă de mai sus.

Lecția următoare: ORDER BY, TOP și paginarea cu OFFSET ... FETCH. Plus singura greșeală pe care o face fiecare dezvoltator când încearcă pentru prima dată să „obțină primele N rânduri pe grup”.

Caută