Mert Özen Her Satırda Daha İleri
Veritabanı SQL Temelleri — #06

NULL ve Predicate'ler: Üç Değerli Mantık, LIKE, IN, BETWEEN ve COALESCE

Mert Özen 8 Kas 2026 14 dk 3 görüntülenme
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 = NULL yazmak. Her zaman sıfır row döner; doğrusu IS NULL.
  • NOT ya da <> ile yazılan bir filtrenin nullable column'daki NULL row'ları da elediğini unutmak.
  • + ile string birleştirirken tek bir NULL'ın bütün sonucu NULL yapması; CONCAT ya da COALESCE kullan.
  • 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 BETWEEN kullanmak 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.