NULL ve Predicate'ler: Üç Değerli Mantık, LIKE, IN, BETWEEN ve COALESCE
SQL'de karşılaştırmanın üç sonucu var: TRUE, FALSE ve UNKNOWN; bu yüzden = NULL hiçbir row bulmaz, NOT bir nullable column'da NULL row'ları da eler ve NOT IN listesindeki tek bir NULL sonucu boşaltır. COALESCE ile ISNULL'ın tip farkını, NULLIF ile sıfıra bölme korumasını, LIKE'ta escape ve baştaki %'nin index'e etkisini ve BETWEEN'in tarih aralığında son günü kaçırmasını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, PostgreSQL karşılaştırmaları PostgreSQL 18.6 üzerinde çalıştırılmıştır.
Önce Tahmin Et: "Eşittir ya da Eşit Değildir" Kaç Row Döndürür?
Üç sayım yapıyoruz. Birincisi alt başlığı NULL'a eşit olan kitaplar, ikincisi alt başlığı IS NULL olanlar. Üçüncüsü bir totoloji: alt başlığı ya "Essays on Software Engineering"e eşit ya da ona eşit değil. Mantık dersinde bu koşul her row için doğrudur; 77 kitabın hepsini döndürmesi gerekir:
SELECT
(SELECT COUNT(*) FROM dbo.books WHERE subtitle = NULL) AS eq_null,
(SELECT COUNT(*) FROM dbo.books WHERE subtitle IS NULL) AS is_null,
(SELECT COUNT(*) FROM dbo.books WHERE subtitle = N'Essays on Software Engineering'
OR subtitle <> N'Essays on Software Engineering') AS either_way;
eq_null is_null either_way
------- ------- ----------
0 71 6
(1 rows affected)
= NULL hiçbir row bulmadı, oysa 71 kitabın alt başlığı yok. Totoloji ise 77 yerine 6 döndürdü. İkisinin sebebi aynı: SQL'de bir karşılaştırmanın iki değil üç sonucu var. Bu yazıda üç değerli mantığı, NULL'ın ifadelerde nasıl yayıldığını, COALESCE, ISNULL ve NULLIF'i, ardından LIKE, IN ve BETWEEN predicate'lerini ve her birinin NULL ile ilişkisini ele alacağız.
Üç Değerli Mantık: TRUE, FALSE ve UNKNOWN
NULL bir değer değil, değerin yokluğunu ya da bilinmediğini gösteren bir işaret. Bilinmeyen bir şeyi bir değerle karşılaştırınca cevap da bilinmez: NULL = 'x', NULL <> 'x', hatta NULL = NULL UNKNOWN (bilinmiyor) sonucunu verir. WHERE yalnız TRUE olan row'ları geçirir; FALSE ve UNKNOWN aynı şekilde elenir. Açılıştaki totolojide alt başlığı olmayan 71 kitap için iki karşılaştırma da UNKNOWN, UNKNOWN OR UNKNOWN da UNKNOWN; kalan 6 kitap geçiyor.
AND ve OR'un UNKNOWN ile nasıl davrandığını SQL Server'a hesaplatalım. TRUE için 1 = 1, FALSE için 0 = 1, UNKNOWN için NULL = 1 değerlendiriliyor:
SELECT a.label AS a, b.label AS b,
CASE WHEN a.v = 1 AND b.v = 1 THEN 'TRUE'
WHEN NOT (a.v = 1 AND b.v = 1) THEN 'FALSE' ELSE 'UNKNOWN' END AS a_and_b,
CASE WHEN a.v = 1 OR b.v = 1 THEN 'TRUE'
WHEN NOT (a.v = 1 OR b.v = 1) THEN 'FALSE' ELSE 'UNKNOWN' END AS a_or_b
FROM (VALUES ('TRUE', 1), ('FALSE', 0), ('UNKNOWN', NULL)) AS a(label, v)
CROSS JOIN (VALUES ('TRUE', 1), ('FALSE', 0), ('UNKNOWN', NULL)) AS b(label, v)
WHERE a.label <= b.label;
a b a_and_b a_or_b
------- ------- ------- -------
TRUE TRUE TRUE TRUE
TRUE UNKNOWN UNKNOWN TRUE
FALSE TRUE FALSE TRUE
FALSE FALSE FALSE FALSE
FALSE UNKNOWN FALSE UNKNOWN
UNKNOWN UNKNOWN UNKNOWN UNKNOWN
(6 rows affected)
Kuralı ezberlemek yerine şöyle düşün: UNKNOWN "TRUE ya da FALSE, hangisi olduğunu bilmiyorum" demek. FALSE AND UNKNOWN FALSE'tur, çünkü bilinmeyen ne olursa olsun sonuç değişmez. TRUE OR UNKNOWN TRUE'dur, aynı sebeple. Sonucun bilinmeyene bağlı olduğu her durumda cevap UNKNOWN. NOT UNKNOWN da UNKNOWN'dır ve en sinsi hata buradan çıkar. "Programming kategorisinde olmayan kitaplar" sorgusunu NOT ile yazalım:
SELECT
(SELECT COUNT(*) FROM dbo.books) AS all_books,
(SELECT COUNT(*) FROM dbo.books WHERE category_id = 11) AS programming,
(SELECT COUNT(*) FROM dbo.books WHERE NOT (category_id = 11)) AS not_programming;
all_books programming not_programming
--------- ----------- ---------------
77 9 66
(1 rows affected)
9 + 66 = 75. İki kitap ne Programming'de ne de onun dışında: kategorisi olmayan The Elements of Style ve Meditations. Onlar için category_id = 11 UNKNOWN, NOT'u da UNKNOWN. Bir koşulu ve onun tersini topladığında table'ın tamamını bulamıyorsan sebep neredeyse her zaman bir nullable column'dur. Doğru yazım NULL'ı açıkça ele almak: WHERE category_id <> 11 OR category_id IS NULL 68 döndürüyor. SQL Server 2022'den beri bunu tek predicate'le yazmak da mümkün: WHERE category_id IS DISTINCT FROM 11 aynı 68'i veriyor. IS [NOT] DISTINCT FROM iki NULL'ı eşit sayan standart karşılaştırma; 17. yazıda ayrıntısıyla göreceğiz.
Eski kodda SET ANSI_NULLS OFF görürsen dikkat: bu oturum ayarı = NULL'ı çalışır hale getirir, ama Microsoft ayarın kullanımdan kaldırılacağını ve ileride her zaman açık olacağını yazıyor. Doğru araç IS NULL ve IS NOT NULL.
NULL'ın Yayılması: Aritmetik, Birleştirme, Aggregate ve Sıralama
NULL ifadelere bulaşır. NULL içeren bir toplama, çarpma ya da string birleştirme NULL döndürür. Şehri olmayan müşterilerin etiketini iki yolla üretelim:
SELECT TOP (2) first_name + N' (' + city + N')' AS plus_concat,
CONCAT(first_name, N' (', city, N')') AS concat_fn
FROM dbo.customers
WHERE city IS NULL
ORDER BY customer_id;
plus_concat concat_fn
----------- ----------
NULL Barbara ()
NULL Niklaus ()
(2 rows affected)
+ bütün metni kaybetti; CONCAT ise NULL'ları boş string sayıyor. Aggregate function'lar başka bir kural izler, NULL'ları yok sayarlar:
SELECT COUNT(*) AS all_books, COUNT(page_count) AS with_pages, AVG(page_count) AS avg_pages
FROM dbo.books;
all_books with_pages avg_pages
--------- ---------- ---------
77 67 420
(1 rows affected)
COUNT(*) row sayar, COUNT(page_count) değer sayar. Ortalama da 67 kitap üzerinden hesaplandı; sayfa sayısı bilinmeyen 10 kitabı sıfır saymadı. Bu çoğu zaman istenen davranış ama "ortalama"yı raporlayan kişinin bunu bilmesi gerekir. Aggregate'lerin ayrıntısı 10. yazıda. Sıralamada ise NULL bir yere konmak zorunda ve ürünler ayrışıyor. SQL Server artan sıralamada NULL'ları başa koyuyor:
SELECT TOP (3) book_id, title, page_count
FROM dbo.books
ORDER BY page_count, book_id;
book_id title page_count
------- -------------------------- ----------
17 Mrs Dalloway NULL
21 The Picture of Dorian Gray NULL
24 Beowulf NULL
(3 rows affected)
PostgreSQL 18.6'da SELECT v FROM (VALUES (3), (NULL), (1)) AS t(v) ORDER BY v sonucu 1, 3 ve en sonda NULL. Standart bu seçimi NULLS FIRST ve NULLS LAST ile açıkça yapmaya izin veriyor; PostgreSQL destekliyor, SQL Server desteklemiyor. T-SQL'de aynı etki için ORDER BY CASE WHEN page_count IS NULL THEN 1 ELSE 0 END, page_count yazarsın.
COALESCE ve ISNULL
COALESCE argümanlarından NULL olmayan ilkini döndürür ve standarttır; SQL Server, PostgreSQL ve MySQL'de aynı şekilde çalışır. İstediğin kadar argüman alabilir: COALESCE(NULL, NULL, N'third', N'fourth') sonucu third. Müşteri listesinde eksik bilgileri doldurmak için:
SELECT TOP (4) customer_id, first_name, phone,
COALESCE(phone, 'no phone') AS phone_text,
COALESCE(city, N'unknown city') AS city_text
FROM dbo.customers
WHERE phone IS NULL OR city IS NULL
ORDER BY customer_id;
customer_id first_name phone phone_text city_text
----------- ---------- ----- ---------- ----------
1 Ada NULL no phone Manchester
2 Alan NULL no phone Berlin
3 Grace NULL no phone Istanbul
6 Donald NULL no phone Berlin
(4 rows affected)
T-SQL'in bir de ISNULL'u var; iki argüman alır ve çoğu zaman COALESCE gibi davranır. Fark dönüş tipinde:
DECLARE @code VARCHAR(3) = NULL;
SELECT ISNULL(@code, 'unknown') AS with_isnull,
COALESCE(@code, 'unknown') AS with_coalesce;
with_isnull with_coalesce
----------- -------------
unk unknown
(1 rows affected)
ISNULL'ın sonucu ilk argümanın tipini alır, burada VARCHAR(3); "unknown" sessizce "unk"e kesildi. COALESCE ise argümanlar arasında öncelik kuralına göre bir tip seçer ve uzunluğu 7 olan bir sonuç döndürür. SQL_VARIANT_PROPERTY ile bakınca da isnull_len 3, coalesce_len 7 çıkıyor. İki fark daha var: ISNULL yalnız iki argüman alır ve taşınabilir değildir (PostgreSQL'de bu isimde bir function yok, MySQL'deki karşılığı IFNULL). COALESCE ise Microsoft dokümanına göre arka planda bir CASE ifadesine çevrilir; argümanlardan biri bir subquery ise birden fazla kez değerlendirilebilir. Varsayılan tercihin COALESCE olsun, ilk argümanın tipinin korunmasını özellikle istediğin yerde ISNULL kullan.
NULLIF ile Sıfıra Bölme Koruması
NULLIF(a, b), iki değer eşitse NULL, değilse a döndürür. En yaygın kullanımı sıfıra bölmeyi engellemek. Kitap başına yorum oranını (yorum sayısı / sipariş sayısı) hesaplayalım; The Mythical Man-Month hiç sipariş almadı:
SELECT b.book_id, b.title,
COUNT(DISTINCT r.review_id) AS review_count,
COUNT(DISTINCT oi.order_id) AS order_count,
COUNT(DISTINCT r.review_id) * 100 / COUNT(DISTINCT oi.order_id) AS review_rate_pct
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r ON r.book_id = b.book_id
LEFT JOIN dbo.order_items AS oi ON oi.book_id = b.book_id
WHERE b.book_id IN (26, 62, 64)
GROUP BY b.book_id, b.title;
book_id title review_count order_count review_rate_pct
------- ----- ------------ ----------- ---------------
26 Dune 5 27 18
Msg 8134, Level 16, State 1, Server b8be943222fa, Line 1
Divide by zero error encountered.
Warning: Null value is eliminated by an aggregate or other SET operation.
Rapor ilk row'dan sonra öldü. Böleni NULLIF(COUNT(DISTINCT oi.order_id), 0) ile sarınca sıfır NULL'a dönüşüyor ve NULL'a bölme hata değil NULL veriyor:
book_id title review_count order_count review_rate_pct
------- ------------------------------------------------- ------------ ----------- ---------------
26 Dune 5 27 18
62 The Mythical Man-Month 0 0 NULL
64 Structure and Interpretation of Computer Programs 0 1 0
(3 rows affected)
"Sıfır sipariş, oran tanımsız" ile "bir sipariş, sıfır yorum, oran yüzde 0" artık ayrı görünüyor. Bunu COALESCE(..., 0) ile sıfıra çevirmek bu bilgiyi yok eder; ekranda göstermek için yapıyorsan sorgunun en dış katmanında yap. 18 sonucuna da dikkat: tam sayı bölmesi 500 / 27'yi 18'e kesti. INT ile ortalama ve oranın tuzağını aggregate yazısında tekrar göreceğiz. Sorgudaki iki LEFT JOIN ve COUNT(DISTINCT ...) ise bilinçli: iki one-to-many ilişkiyi aynı sorguda join'lemenin row'ları çoğalttığını 9. yazıda inceleyeceğiz.
LIKE: Pattern'ler, Escape ve Baştaki %
LIKE iki joker karakter tanır: % sıfır ya da daha fazla karakter, _ tam bir karakter. "The" ile başlayıp içinde "of" geçen kitaplar:
SELECT book_id, title
FROM dbo.books
WHERE title LIKE N'The %'
AND title LIKE N'%of%'
ORDER BY book_id;
book_id title
------- ---------------------------------------
21 The Picture of Dorian Gray
30 The Left Hand of Darkness
32 The War of the Worlds
40 The Fellowship of the Ring
44 The Colour of Magic
45 The Hound of the Baskervilles
55 The Structure of Scientific Revolutions
65 The Art of Computer Programming
72 The Elements of Style
(9 rows affected)
Kesme işareti içeren bir değeri aramak için string içinde iki kez yazarsın: LIKE N'%''s%' The Handmaid's Tale, Alice's Adventures in Wonderland ve Charlotte's Web'i buluyor. Joker karakterin kendisini aramak ise daha ince. Kampanya kodlarında alt çizgi var:
SELECT code,
CASE WHEN code LIKE 'SUMMER_10' THEN 'match' ELSE '-' END AS plain_underscore,
CASE WHEN code LIKE 'SUMMER\_10' ESCAPE '\' THEN 'match' ELSE '-' END AS escaped_underscore
FROM (VALUES ('SUMMER_10'), ('SUMMERX10'), ('SUMMER10')) AS c(code);
code plain_underscore escaped_underscore
--------- ---------------- ------------------
SUMMER_10 match match
SUMMERX10 match -
SUMMER10 - -
(3 rows affected)
Kaçışsız _ herhangi bir karakteri kabul ettiği için SUMMERX10 de eşleşti. ESCAPE standart; bu sorgu PostgreSQL'de de aynı çalışır. T-SQL buna ek olarak köşeli parantezle karakter aralığı da tanır: LIKE N'[A-C]%' A, B ya da C ile başlayan başlıkları getirir. Bu standart değil; PostgreSQL'de aynı iş için SIMILAR TO ya da regex kullanılır. Uygulamadan gelen bir arama metnini LIKE'a koyarken içindeki %, _ ve [ karakterlerini kaçırmazsan kullanıcı farkında olmadan joker kullanmış olur.
Performans tarafında kural şu: pattern'in başı sabitse index kullanılabilir, joker karakterle başlıyorsa kullanılamaz. title üzerine bir index koyup iki sorgunun planına bakalım:
CREATE INDEX IX_books_title ON dbo.books (title);
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT book_id, title FROM dbo.books WHERE title LIKE N'Dune%';
SELECT book_id, title FROM dbo.books WHERE title LIKE N'%Dune%';
GO
SET SHOWPLAN_TEXT OFF;
|--Index Seek(OBJECT:([sqlt_06].[dbo].[books].[IX_books_title]), SEEK:([sqlt_06].[dbo].[books].[title] >= N'Dune' AND [sqlt_06].[dbo].[books].[title] < N'DunF'), WHERE:([sqlt_06].[dbo].[books].[title] like N'Dune%') ORDERED FORWARD)
|--Index Scan(OBJECT:([sqlt_06].[dbo].[books].[IX_books_title]), WHERE:([sqlt_06].[dbo].[books].[title] like N'%Dune%'))
İlk sorgu LIKE N'Dune%''u bir aralığa çevirmiş (>= N'Dune' AND < N'DunF') ve index'te yalnız o aralığa gidiyor: Index Seek. İkincisi her başlığın içine bakmak zorunda: Index Scan. 77 kitapta fark 2 ile 3 logical read; 40.000 kitaplık bir katalogda ise aramanın her seferinde bütün index'i okuması demek. "İçinde geçen" araması gerçekten gerekiyorsa çözüm full-text search ya da ayrı bir arama motoru; bu serinin konusu değil.
IN Listeleri
IN, bir değerin bir listede olup olmadığını sorar ve OR zincirinin kısa yazımıdır. Henüz teslim edilmemiş siparişler:
SELECT status, COUNT(*) AS order_count
FROM dbo.orders
WHERE status IN ('Pending', 'Paid', 'Shipped')
GROUP BY status
ORDER BY status;
status order_count
------- -----------
Paid 1
Pending 1
Shipped 8
(3 rows affected)
IN, OR zinciri olduğu için NULL kurallarını da miras alır. NOT IN ise AND zinciridir ve listede tek bir NULL bütün sonucu yok eder:
SELECT
(SELECT COUNT(*) FROM dbo.categories WHERE category_id NOT IN (1, 8)) AS not_in_values,
(SELECT COUNT(*) FROM dbo.categories WHERE category_id NOT IN (1, 8, NULL)) AS not_in_with_null;
not_in_values not_in_with_null
------------- ----------------
11 0
(1 rows affected)
category_id NOT IN (1, 8, NULL) aslında category_id <> 1 AND category_id <> 8 AND category_id <> NULL; son parça her row için UNKNOWN, dolayısıyla bütün ifade hiçbir row için TRUE olamıyor. Elle yazılan bir listeye NULL koymazsın, ama liste bir subquery'den gelince ve o subquery'nin column'u nullable olunca tam olarak bu olur. Bu tuzağı ve NOT EXISTS ile çözümünü 11. yazıda göreceğiz. Uygulamadan gelen uzun IN listeleri için de bir not: her farklı uzunluktaki liste ayrı bir sorgu metni ve ayrı bir plan demek; ANSI (American National Standards Institute) string'lerini ve TVP (Table-Valued Parameter) alternatifini Dapper serisinin "Dapper ve SQL Server: TVP, ANSI String'ler, {=literal} ve Parameter Sniffing" yazısında görmüştük.
BETWEEN ve Tarih Aralıkları
BETWEEN a AND b, >= a AND <= b demek; iki ucu da dahil. Sayılarda sorun çıkarmaz: list_price BETWEEN 400 AND 500 17 kitap buluyor. Sınırları ters yazarsan (BETWEEN 500 AND 400) hata almazsın, sıfır row alırsın. Asıl tuzak tarih ve saatte. Mart 2025 siparişlerini sayalım:
SELECT
(SELECT COUNT(*) FROM dbo.orders WHERE ordered_at BETWEEN '2025-03-01' AND '2025-03-31') AS with_between,
(SELECT COUNT(*) FROM dbo.orders WHERE ordered_at >= '2025-03-01' AND ordered_at < '2025-04-01') AS half_open;
with_between half_open
------------ ---------
25 28
(1 rows affected)
Üç sipariş kayboldu. ordered_at bir DATETIME2 ve '2025-03-31' literal'ı 31 Mart'ın 00:00:00'ı demek. O gün 00:00'dan sonra verilen her sipariş üst sınırın dışında kaldı:
SELECT order_id, ordered_at
FROM dbo.orders
WHERE ordered_at >= '2025-03-31' AND ordered_at < '2025-04-01'
ORDER BY ordered_at;
order_id ordered_at
-------- -------------------
54 2025-03-31 12:01:22
55 2025-03-31 13:52:21
56 2025-03-31 21:48:08
(3 rows affected)
Doğru yazım yarı açık aralık: alt sınır dahil (>=), üst sınır hariç (<) ve üst sınır bir sonraki dönemin başlangıcı. Bu biçim column'un hassasiyetinden bağımsız çalışır: DATE, DATETIME2(0) ya da DATETIME2(7) fark etmez, "23:59:59.999" gibi bir değer uydurmak gerekmez ve 3. yazıda gördüğümüz DATETIME yuvarlamasına da takılmaz. Ardışık dönemler de üst üste binmez: Mart'ın üst sınırı Nisan'ın alt sınırı.
Üretimde Dikkat
Nullable bir column'u filtreleyen her sorgu için "NULL olan row'lar sonuca girmeli mi?" sorusunu açıkça cevapla ve cevabı sorguya yaz. Raporlarda "toplam" ile "parçaların toplamı" tutmuyorsa ilk şüpheli bir NOT, <> ya da NOT IN ile birleşen nullable bir column'dur. Bir column'un NULL alabilmesi bir iş kararı olmalı, varsayılan değil; NOT NULL olabilecek her column'u NOT NULL yapmak bu yazıdaki tuzakların çoğunu baştan kapatır. Entity Framework Core (EF Core) tarafında C#'ın iki değerli mantığı ile SQL'in üç değerli mantığı arasındaki çeviriyi EF Core yapar ve nullable column'larla karşılaştırma yaptığında ek IS NULL kontrolleri üretir; ürettiği SQL'i okurken bunların nereden geldiğini artık biliyorsun.
Sık Düşülen Tuzaklar
WHERE column = NULLyazmak. Her zaman sıfır row döner; doğrusuIS NULL.NOTya da<>ile yazılan bir filtrenin nullable column'dakiNULLrow'ları da elediğini unutmak.+ile string birleştirirken tek birNULL'ın bütün sonucuNULLyapması;CONCATya daCOALESCEkullan.ISNULL'ın sonucu ilk argümanın tipine göre kesmesi.- Bölme işleminde böleni
NULLIF(..., 0)ile korumamak ve raporun ilk sıfırda ölmesi. LIKE'a kullanıcı girdisini%ve_kaçırmadan koymak; baştaki%ile index'i kullanılamaz hale getirmek.- Tarih ve saat aralıklarında
BETWEENkullanmak ve son günün siparişlerini kaybetmek. NOT IN'e nullable bir subquery vermek.
Küçük Bir Deneme
★☆☆ Telefonu olmayan müşterileri say, sonra telefonu olanları say ve ikisinin toplamının 40 ettiğini doğrula. Aynı denemeyi WHERE phone <> '' ve WHERE NOT (phone <> '') ile yap; toplam neden 40 değil?
★★☆ 2025 yılının her çeyreği için sipariş sayısını yarı açık aralıklarla hesaplayan bir sorgu yaz. Sonra aynı sorguyu BETWEEN '2025-01-01' AND '2025-03-31' biçimiyle yaz ve hangi çeyreklerde fark çıktığını bul.
★★★ Ortalama sayfa sayısını iki şekilde hesapla: AVG(page_count) ve AVG(COALESCE(page_count, 0)). Hangisi hangi soruya doğru cevap verir? Sonucun neden tam sayı olduğunu ve ondalıklı bir ortalama almak için ne değiştirmen gerektiğini bul.
WHERE'de column'lara dokunmadan filtrelemenin önemini LIKE'ta gördük. Bir sonraki yazıda bunun genel halini, sargability'yi, scalar function'lar üzerinden konuşacağız: string ve tarih function'ları, CAST/CONVERT/TRY_CAST, CASE ifadeleri ve WHERE YEAR(ordered_at) = 2025 yazmanın bedeli.