SQL Server 2022 ile T-SQL'e Gelenler: IS DISTINCT FROM, GREATEST/LEAST, WINDOW, GENERATE_SERIES, DATE_BUCKET ve JSON Function'ları
SQL Server 2022, NULL-güvenli karşılaştırma için IS DISTINCT FROM, GREATEST/LEAST, GENERATE_SERIES, DATETRUNC, DATE_BUCKET, karakterli TRIM, sıralı STRING_SPLIT, JSON_OBJECT/JSON_ARRAY ve APPROX_PERCENTILE_CONT getirdi. Her birini önce/sonra koduyla deniyor ve 150 seviyesinde GENERATE_SERIES, WINDOW ve TRIM LEADING'in neden çalışmadığını gerçek hata mesajlarıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, CU10 (Cumulative Update 10)) üzerinde serinin Bookstore veritabanıyla, compatibility level 150, 160 ve 170 ile çalıştırılmıştır; PostgreSQL ve MySQL karşılaştırmaları PostgreSQL 18.6 ve MySQL 8.4 ile denenmiştir.
Önce Tahmin Et
CRM (Customer Relationship Management) sisteminden gelen telefon güncellemeleri customer_updates tablosuna yüklendi. Hangi müşterilerin telefonu gerçekten değişti, onu bulmak istiyoruz. Hazırlık ve mevcut durum:
DROP TABLE IF EXISTS dbo.customer_updates;
CREATE TABLE dbo.customer_updates (
customer_id INT NOT NULL PRIMARY KEY,
phone VARCHAR(20) NULL
);
INSERT INTO dbo.customer_updates (customer_id, phone) VALUES
(1, '+44 7700 900123'),
(2, NULL),
(4, NULL),
(5, '+90 555 012 91 77'),
(6, '+49 151 2345678');
SELECT u.customer_id, c.phone AS current_phone, u.phone AS new_phone
FROM dbo.customer_updates AS u
JOIN dbo.customers AS c ON c.customer_id = u.customer_id
ORDER BY u.customer_id;
customer_id current_phone new_phone
----------- ----------------- -----------------
1 NULL +44 7700 900123
2 NULL NULL
4 +90 555 017 95 53 NULL
5 +90 555 012 91 77 +90 555 012 91 77
6 NULL +49 151 2345678
Müşteri 1 ve 6'nın telefonu yoktu, artık var. Müşteri 4'ün telefonu silinmiş. Müşteri 2'de ikisi de NULL, müşteri 5'te ikisi de aynı. Yani üç değişiklik var. Şu sorgu kaç sayar?
SELECT COUNT(*) AS changed
FROM dbo.customer_updates AS u
JOIN dbo.customers AS c ON c.customer_id = u.customer_id
WHERE u.phone <> c.phone;
changed
-------
0
Sıfır. Üç değişikliğin üçünde de bir taraf NULL ve serinin 06. yazısındaki üç değerli mantık gereği NULL <> 'value' UNKNOWN döndürür; WHERE onu geçirmez. Senkronizasyon job'larında, audit tablolarında ve "değişen row'ları bul" sorgularında bu hata yıllarca fark edilmeden yaşar. SQL Server 2022 bunun standart çözümünü getirdi: IS DISTINCT FROM. Bu yazı SQL Server 2022'nin T-SQL'e (Transact-SQL) eklediği dil özelliklerini önce/sonra koduyla ve hangisinin compatibility level istediğiyle birlikte ele alıyor.
IS [NOT] DISTINCT FROM
SQL Server 2022'den önce doğru sorgu şöyle yazılırdı:
SELECT u.customer_id
FROM dbo.customer_updates AS u
JOIN dbo.customers AS c ON c.customer_id = u.customer_id
WHERE u.phone <> c.phone
OR (u.phone IS NULL AND c.phone IS NOT NULL)
OR (u.phone IS NOT NULL AND c.phone IS NULL)
ORDER BY u.customer_id;
customer_id
-----------
1
4
6
İki ek koşul, her nullable column için. Beş column'u karşılaştıran bir senkronizasyon sorgusunda bu on beş satır demek ve birini unutmak çok kolay. 2022 ile:
SELECT u.customer_id
FROM dbo.customer_updates AS u
JOIN dbo.customers AS c ON c.customer_id = u.customer_id
WHERE u.phone IS DISTINCT FROM c.phone
ORDER BY u.customer_id;
customer_id
-----------
1
4
6
IS DISTINCT FROM iki NULL'ı eşit, NULL ile bir değeri farklı sayar ve sonucu her zaman TRUE ya da FALSE'tur, asla UNKNOWN değil. IS NOT DISTINCT FROM ise NULL-güvenli eşitliktir; iki nullable column'u join'lerken işe yarar. Bu predicate standarda SQL:1999 ile girmişti; PostgreSQL uzun zamandır destekliyor (denemede 1 IS DISTINCT FROM NULL true döndü). MySQL 8.4'te ise yok; onun yerine kendi NULL-güvenli eşitlik operator'ü <=> var, NOT (a <=> b) aynı işi görür.
GREATEST ve LEAST
Bir müşterinin "son aktivite" tarihi üç kaynaktan gelir: kayıt tarihi, son sipariş ve son yorum. 2022 öncesinde birden çok column'un en büyüğünü almak için ya iç içe CASE yazılırdı ya da şu hile kullanılırdı:
WITH activity AS (
SELECT c.customer_id, c.created_at,
(SELECT MAX(o.ordered_at) FROM dbo.orders AS o WHERE o.customer_id = c.customer_id) AS last_order,
(SELECT MAX(r.reviewed_at) FROM dbo.reviews AS r WHERE r.customer_id = c.customer_id) AS last_review
FROM dbo.customers AS c
WHERE c.customer_id IN (3, 7, 38)
)
SELECT customer_id,
(SELECT MAX(v) FROM (VALUES (created_at), (last_order), (last_review)) AS x(v)) AS last_activity
FROM activity
ORDER BY customer_id;
customer_id last_activity
----------- -------------------
3 2026-06-20 16:58:05
7 2026-02-19 15:36:21
38 2026-06-29 07:58:00
VALUES ile column'ları row'a çevirip MAX almak çalışır ama niyeti saklar. 2022 ile:
WITH activity AS (
SELECT c.customer_id, c.created_at,
(SELECT MAX(o.ordered_at) FROM dbo.orders AS o WHERE o.customer_id = c.customer_id) AS last_order,
(SELECT MAX(r.reviewed_at) FROM dbo.reviews AS r WHERE r.customer_id = c.customer_id) AS last_review
FROM dbo.customers AS c
WHERE c.customer_id IN (3, 7, 38)
)
SELECT customer_id, created_at, last_order, last_review,
GREATEST(created_at, last_order, last_review) AS last_activity
FROM activity
ORDER BY customer_id;
customer_id created_at last_order last_review last_activity
----------- ------------------- ------------------- ------------------- -------------------
3 2024-06-23 13:50:00 2026-06-20 16:58:05 2026-05-05 10:33:45 2026-06-20 16:58:05
7 2024-07-22 10:34:00 2026-02-19 15:36:21 2026-02-02 06:42:52 2026-02-19 15:36:21
38 2026-06-29 07:58:00 NULL NULL 2026-06-29 07:58:00
Müşteri 38'in hiç siparişi ve yorumu yok; GREATEST NULL'ları yok saydı ve kayıt tarihini döndürdü. SQL Server'da GREATEST ve LEAST NULL'ı atlar, yalnız bütün argümanlar NULL ise NULL döner. PostgreSQL de aynı davranır (GREATEST(1, NULL, 3) 3 döndü). MySQL'de ise tek bir NULL argüman sonucu NULL yapar (aynı ifade NULL döndü). İki function standarda SQL:2023 ile (T054) girdi, ama NULL davranışı lehçeden lehçeye değişiyor; taşınabilir kod yazıyorsan COALESCE ile koru.
LEAST'in en sık kullanımı bir değeri tavanla sınırlamak:
SELECT book_id, title, list_price,
LEAST(list_price, 500.00) AS capped_price
FROM dbo.books
WHERE book_id IN (26, 57, 65);
book_id title list_price capped_price
------- ------------------------------- ---------- ------------
26 Dune 390.50 390.50
57 The Pragmatic Programmer 1150.00 500.00
65 The Art of Computer Programming 1450.00 500.00
GENERATE_SERIES
Serinin 12. yazısında tarih serisini recursive CTE (Common Table Expression) ile üretmiştik:
WITH n AS (
SELECT 0 AS i
UNION ALL
SELECT i + 1 FROM n WHERE i < 6
)
SELECT DATEADD(day, i, CAST('2025-12-20' AS date)) AS d FROM n;
d
----------
2025-12-20
2025-12-21
2025-12-22
2025-12-23
2025-12-24
2025-12-25
2025-12-26
2022 ile aynı iş tek satır:
SELECT DATEADD(day, value, CAST('2025-12-20' AS date)) AS d
FROM GENERATE_SERIES(0, 6);
d
----------
2025-12-20
2025-12-21
2025-12-22
2025-12-23
2025-12-24
2025-12-25
2025-12-26
GENERATE_SERIES(start, stop [, step]) bir table-valued function; value adında tek column döndürür. Adım negatif olabilir:
SELECT value FROM GENERATE_SERIES(100, 0, -25);
value
-----
100
75
50
25
0
Recursive CTE'nin 100 iterasyon sınırı burada yok ve optimizer seriyi tek bir operator olarak üretir. Takvim, saat dilimleri ya da test verisi üretmek için doğal araç; SQL Server Derinlemesine serisinde Bookstore'u milyonlarca row'a büyütmek için bunu kullanacağız. GENERATE_SERIES standartta yok; adı ve fikri PostgreSQL'den geliyor. SQL Server'da compatibility level 160 gerektiriyor (aşağıda deneyeceğiz).
DATETRUNC ve DATE_BUCKET
Bir tarihi ayın başına kesmenin üç kuşak yolu:
SELECT DATEADD(month, DATEDIFF(month, 0, ordered_at), 0) AS month_start_old,
DATEFROMPARTS(YEAR(ordered_at), MONTH(ordered_at), 1) AS month_start_parts,
DATETRUNC(month, ordered_at) AS month_start_new
FROM dbo.orders
WHERE order_id = 250;
month_start_old month_start_parts month_start_new
----------------------- ----------------- -------------------
2025-12-01 00:00:00.000 2025-12-01 2025-12-01 00:00:00
DATEADD(month, DATEDIFF(month, 0, x), 0) hilesi eski kod tabanlarında her yerde ve sonucu her zaman datetime (dikkat: milisaniye hassasiyeti .000). DATEFROMPARTS date döndürür. 2022'nin DATETRUNC'ı ise girdinin tipini korur; burada datetime2(0) girdi, datetime2(0) çıktı. Yıldan mikrosaniyeye kadar (yıl, çeyrek, ay, hafta, gün, saat, dakika, saniye...) keser.
DATE_BUCKET bir adım ileri gider: zamanı sabit genişlikte kovalara böler. Aralık 2025'teki siparişler haftalık kovalarda:
SELECT DATE_BUCKET(week, 1, CAST(ordered_at AS date)) AS week_start,
COUNT(*) AS orders
FROM dbo.orders
WHERE ordered_at >= '2025-12-01' AND ordered_at < '2026-01-01'
GROUP BY DATE_BUCKET(week, 1, CAST(ordered_at AS date))
ORDER BY week_start;
week_start orders
---------- ------
2025-12-01 12
2025-12-08 8
2025-12-15 7
2025-12-22 11
2025-12-29 4
Varsayılan başlangıç noktası (origin) 1900-01-01, ve o gün bir pazartesi; bu yüzden haftalar pazartesi başlıyor. Dördüncü argümanla origin'i değiştirebilirsin; haftayı pazar günü başlatmak için bir pazar tarihi ver:
SELECT DATE_BUCKET(week, 1, CAST(ordered_at AS date), CAST('2025-11-30' AS date)) AS week_start,
DATENAME(weekday, DATE_BUCKET(week, 1, CAST(ordered_at AS date), CAST('2025-11-30' AS date))) AS day_name,
COUNT(*) AS orders
FROM dbo.orders
WHERE ordered_at >= '2025-12-01' AND ordered_at < '2026-01-01'
GROUP BY DATE_BUCKET(week, 1, CAST(ordered_at AS date), CAST('2025-11-30' AS date))
ORDER BY week_start;
week_start day_name orders
---------- -------- ------
2025-11-30 Sunday 11
2025-12-07 Sunday 9
2025-12-14 Sunday 5
2025-12-21 Sunday 12
2025-12-28 Sunday 5
DATE_BUCKET(week, 2, ...) iki haftalık, DATE_BUCKET(minute, 15, ...) 15 dakikalık kovalar üretir; zaman serisi raporlarında DATEPART hesaplarıyla uğraşmaktan kurtarır. DATETRUNC ve DATE_BUCKET standart değil; PostgreSQL'deki karşılıkları date_trunc ve date_bin.
Karakterli TRIM ve Sıralı STRING_SPLIT
2022 öncesinde LTRIM ve RTRIM yalnız boşluk silerdi. Artık silinecek karakterleri verebilirsin:
SELECT LTRIM('000123', '0') AS ltrim_zeros,
RTRIM('Dune...', '.') AS rtrim_dots,
TRIM('-' FROM '--978-1-23--') AS trim_dashes,
TRIM(LEADING '+' FROM '+90 555') AS trim_leading;
ltrim_zeros rtrim_dots trim_dashes trim_leading
----------- ---------- ----------- ------------
123 Dune 978-1-23 90 555
TRIM(LEADING ... FROM ...) biçimi standardın syntax'ı; LTRIM(x, chars) biçimi ise SQL:2023'ün çok karakterli trim function'larıyla (T056) aynı fikir. Dikkat: LEADING/TRAILING/BOTH syntax'ı compatibility level 160 istiyor, iki argümanlı LTRIM/RTRIM ise istemiyor.
STRING_SPLIT 2016'dan beri var ama parçaların sırasını garanti etmezdi; "ilk etiket" ya da "üçüncü parça" gerektiğinde güvenilmezdi. 2022 ile üçüncü argüman 1 verilirse ordinal column'u da döner:
SELECT value, ordinal
FROM STRING_SPLIT('dune,arrakis,spice,fremen', ',', 1)
ORDER BY ordinal;
value ordinal
------- -------
dune 1
arrakis 2
spice 3
fremen 4
JSON_OBJECT, JSON_ARRAY ve JSON_PATH_EXISTS
SQL Server 2016'dan beri JSON'u (JavaScript Object Notation) FOR JSON ile üretebiliyordun, ama bu sorgunun bütün sonucunu JSON'a çevirir. 2022'nin constructor'ları tek bir değer içinde JSON kurmanı sağlar:
SELECT JSON_OBJECT('id': b.book_id,
'title': b.title,
'price': b.list_price,
'authors': JSON_ARRAY(a1.last_name, a2.last_name),
'subtitle': b.subtitle NULL ON NULL) AS book_json
FROM dbo.books AS b
JOIN dbo.book_authors AS ba1 ON ba1.book_id = b.book_id AND ba1.author_order = 1
JOIN dbo.authors AS a1 ON a1.author_id = ba1.author_id
JOIN dbo.book_authors AS ba2 ON ba2.book_id = b.book_id AND ba2.author_order = 2
JOIN dbo.authors AS a2 ON a2.author_id = ba2.author_id
WHERE b.book_id = 42;
book_json
----------------------------------------------------------------------------------------------
{"id":42,"title":"Good Omens","price":340.90,"authors":["Pratchett","Gaiman"],"subtitle":null}
JSON_ARRAY iki yazarı bir array'e koydu, NULL ON NULL ise NULL alt başlığı null olarak yazdı. Varsayılan da bu; ABSENT ON NULL ise key'i hiç yazmaz:
SELECT JSON_OBJECT('title': title, 'subtitle': subtitle ABSENT ON NULL) AS absent_on_null,
JSON_OBJECT('title': title, 'subtitle': subtitle) AS default_behaviour
FROM dbo.books
WHERE book_id = 26;
absent_on_null default_behaviour
---------------- --------------------------------
{"title":"Dune"} {"title":"Dune","subtitle":null}
API'ye (Application Programming Interface) gidecek bir payload'da bu fark önemlidir: istemci "alan yok" ile "alan null" arasında farklı davranabilir. JSON_PATH_EXISTS bir path'in var olup olmadığını söyler, ISJSON'a ise tip kontrolü eklendi:
SELECT JSON_PATH_EXISTS('{"title":"Dune","price":390.5}', '$.price') AS has_price,
JSON_PATH_EXISTS('{"title":"Dune","price":390.5}', '$.isbn') AS has_isbn,
ISJSON('42') AS any_type_default,
ISJSON('42', SCALAR) AS is_scalar,
ISJSON('42', OBJECT) AS is_object;
has_price has_isbn any_type_default is_scalar is_object
--------- -------- ---------------- --------- ---------
1 0 0 1 0
ISJSON('42') varsayılan olarak 0 döndü, çünkü eski davranışla uyumlu kalmak için yalnız object ve array'i geçerli sayar; SCALAR vererek tek değerlerin de geçerli JSON olduğunu kabul ettirebilirsin. JSON_OBJECT ve JSON_ARRAY standardın SQL:2016'daki SQL/JSON bölümünden geliyor. SQL Server 2025'in native json tipi ve JSON_OBJECTAGG/JSON_ARRAYAGG aggregate'leri bir sonraki yazının konusu.
APPROX_PERCENTILE_CONT ve Diğerleri
Serinin 14. yazısında medyanı PERCENTILE_CONT ile hesaplamıştık; o function bütün değerleri sıralar ve yalnız window function olarak çalışır. 2022'nin APPROX_PERCENTILE_CONT'u ise normal bir aggregate ve KLL (Karnin, Lang ve Liberty'nin 2016'da yayımladığı quantile sketch algoritması) adlı bir sketch ile çalışır:
WITH order_totals AS (
SELECT o.order_id, SUM(oi.quantity * oi.unit_price) AS total
FROM dbo.orders AS o
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id
)
SELECT APPROX_PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) AS approx_median,
(SELECT DISTINCT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total) OVER () FROM order_totals) AS exact_median
FROM order_totals;
approx_median exact_median
------------- ------------
802.62 802.62
380 siparişte sonuç birebir aynı. Microsoft Learn'e göre bu function rank bazlı hata garantisi verir: %99 güvenle en fazla %1,33 hata. Algoritma rastgele örnekleme kullandığı için büyük veride sonuç çalıştırmadan çalıştırmaya küçük farklar gösterebilir. Dashboard'da milyonlarca row'un p95 (95. persentil) süresini göstermek için iyi; fatura kesmek için değil.
2022'nin dil eklemeleri bunlarla bitmiyor. 14. yazıda kullandığımız WINDOW clause'u, FIRST_VALUE/LAST_VALUE'daki IGNORE NULLS, ve LEFT_SHIFT, RIGHT_SHIFT, BIT_COUNT, GET_BIT, SET_BIT gibi bit manipulation function'ları da aynı sürümle geldi.
Üretimde Dikkat: Compatibility Level
Her veritabanının bir compatibility level'ı var ve bazı yeni syntax'lar yalnız belirli bir seviyenin üstünde açılıyor. SQL Server 2022'de yeni veritabanları 160 ile, 2025'te 170 ile oluşturulur; ama eski bir sunucudan restore edilen ya da yükseltilen veritabanı eski seviyesini korur. Kendi veritabanımı 150'ye (SQL Server 2019 seviyesi) çekip denedim:
ALTER DATABASE CURRENT SET COMPATIBILITY_LEVEL = 150;
SELECT value FROM GENERATE_SERIES(1, 3);
Msg 208, Level 16, State 1, Server b8be943222fa, Line 1
Invalid object name 'GENERATE_SERIES'.
SELECT title, MAX(list_price) OVER w AS max_price
FROM dbo.books
WINDOW w AS (PARTITION BY category_id);
Msg 102, Level 15, State 1, Server b8be943222fa, Line 3
Incorrect syntax near 'w'.
SELECT TRIM(LEADING '0' FROM '007');
Msg 102, Level 15, State 1, Server b8be943222fa, Line 1
Incorrect syntax near '0'.
Hata mesajları yanıltıcı: "Invalid object name 'GENERATE_SERIES'" ya da "Incorrect syntax near 'w'" sana compatibility level'dan hiç bahsetmiyor. Aynı seviyede şu üçü ise sorunsuz çalıştı:
SELECT GREATEST(1, 5, 3) AS greatest_ok,
DATETRUNC(year, CAST('2025-12-22' AS date)) AS datetrunc_ok,
CASE WHEN 1 IS DISTINCT FROM NULL THEN 'yes' END AS distinct_ok;
greatest_ok datetrunc_ok distinct_ok
----------- ------------ -----------
5 2025-01-01 yes
Denemelerimde SQL Server 2025 üzerindeki 150 seviyesindeki veritabanında, bu yazıdaki özelliklerden yalnız GENERATE_SERIES, WINDOW clause'u ve TRIM(LEADING/TRAILING/BOTH ...) syntax'ı çalışmadı; IS DISTINCT FROM, GREATEST/LEAST, DATETRUNC, DATE_BUCKET, iki argümanlı LTRIM/RTRIM, STRING_SPLIT'in ordinal'ı, JSON function'ları ve APPROX_PERCENTILE_CONT çalıştı. Seviyeyi 160'a çıkarınca:
ALTER DATABASE CURRENT SET COMPATIBILITY_LEVEL = 160;
SELECT value FROM GENERATE_SERIES(1, 3);
value
-----
1
2
3
Bir uyarı daha: compatibility level yalnız syntax'ı değil, optimizer davranışını da (cardinality estimator, intelligent query processing özellikleri) değiştirir. Seviyeyi "yeni function'ı kullanabilmek için" yükseltmek, bütün sorguların planını etkileyebilecek bir karardır; Query Store ile önce/sonra karşılaştırması yapmadan production'da değiştirme. Ayrıntılar SQL Server Derinlemesine serisinde. Ve en temel tuzak: bu function'ların hiçbiri SQL Server 2019'da, hangi compatibility level olursa olsun, yok. Kodun hem 2019 hem 2022 sunucularında çalışacaksa en düşük sürüme göre yaz.
Sık Düşülen Tuzaklar
Nullable column'ları <> ile karşılaştırıp değişiklik tespiti yapmak. NULL'lı her değişiklik kaybolur; IS DISTINCT FROM kullan.
GREATEST'in NULL davranışının her lehçede aynı olduğunu sanmak. SQL Server ve PostgreSQL atlar, MySQL NULL döndürür.
GENERATE_SERIES'in "Invalid object name" hatasını eksik bir tabloya yormak. Önce sys.databases'te compatibility level'a bak.
Compatibility level'ı yalnız yeni bir function için, plan regresyonlarını ölçmeden yükseltmek.
DATE_BUCKET'in haftaları her zaman pazartesi başlattığını unutmak; raporun pazar başlaması gerekiyorsa origin ver.
JSON_OBJECT'in varsayılan olarak NULL değerleri null yazdığını bilmemek; API sözleşmen key'in hiç olmamasını bekliyorsa ABSENT ON NULL yaz.
Küçük Bir Deneme
★☆☆ customer_updates tablosuna city column'unu ekle ve hem telefonu hem şehri değişen müşterileri IS DISTINCT FROM ile bul. Aynı sorguyu 2022 öncesi syntax ile yaz ve satır sayılarını karşılaştır.
★★☆ GENERATE_SERIES ve DATE_BUCKET ile 2025'in bütün haftalarını üret (siparişi olmayan haftalar 0 ile görünsün) ve her hafta için sipariş sayısını döndür.
★★★ Her kitap için JSON_OBJECT ile {"id": ..., "title": ..., "authors": [...]} biçiminde bir JSON üret; yazar sayısı kitaptan kitaba değiştiği için JSON_ARRAY yetmeyecek. 2022'de bunu hangi yöntemle çözebilirsin? Bir sonraki yazıdaki JSON_ARRAYAGG ile karşılaştırmak için çözümünü sakla.
SQL Server 2022 dil tarafında standarda yaklaşan bir sürümdü. 2025 bu yolda daha da ileri gitti: regular expression function'ları, native json tipi, || operator'ü ve PRODUCT gibi yeni aggregate'ler. Bir sonraki yazıda onları, yine compatibility level gereksinimleriyle birlikte deniyoruz.