T-SQL'de JSON ile Çalışmak: FOR JSON, OPENJSON, JSON_VALUE, JSON_QUERY, JSON_MODIFY ve Index'lenebilir JSON
FOR JSON PATH sorgu sonucunu iç içe JSON'a, OPENJSON ... WITH ise JSON'u tipli row'lara çevirir; JSON_VALUE 4000 karakteri aşan değerde lax modda sessizce NULL döner. Sipariş belgesini tek sorguda üretmek, 2033 karakterlik FOR JSON parçaları, JSON_MODIFY'ın kaçışlama tuzağı, sepet JSON'unu tek round trip'te yazmak, computed column index'i ve 2025'in JSON index'i gerçek çıktılarla.
Sürüm notu: Bu yazıdaki SQL sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde serinin Bookstore veritabanıyla, PostgreSQL karşılaştırmaları PostgreSQL 18.7 üzerinde çalıştırılmıştır.
Önce Tahmin Et: 5000 Karakterlik Bir Değeri JSON_VALUE ile Okumak
Tedarikçiden gelen bir kitap kaydında tanıtım metni (blurb) 5000 karakter. JSON_VALUE ile başlığı ve tanıtım metnini okuyoruz. Ne döner?
DECLARE @doc NVARCHAR(MAX) = CONCAT(N'{"isbn":"9798900000268","title":"Dune","blurb":"', REPLICATE(CAST(N'x' AS NVARCHAR(MAX)), 5000), N'"}');
SELECT JSON_VALUE(@doc, '$.title') AS title, JSON_VALUE(@doc, '$.blurb') AS blurb, LEN(JSON_VALUE(@doc, '$.blurb')) AS blurb_len;
title blurb blurb_len
----- ----- ---------
Dune NULL NULL
Hata yok, uyarı yok, yalnız NULL. JSON_VALUE sonucu nvarchar(4000) olarak döndürür; değer 4000 karakterden uzunsa varsayılan "lax" modda sessizce NULL verir. Değerin JSON'da olmadığını mı, yoksa sığmadığını mı anlamak için path'in başına strict yazman gerekir:
DECLARE @doc NVARCHAR(MAX) = CONCAT(N'{"isbn":"9798900000268","title":"Dune","blurb":"', REPLICATE(CAST(N'x' AS NVARCHAR(MAX)), 5000), N'"}');
SELECT JSON_VALUE(@doc, 'strict $.blurb') AS blurb;
Msg 13625, Level 16, State 1, Line 2
String value in the specified JSON path would be truncated.
Uzun değerler için doğru araç OPENJSON'ın WITH şeması; orada column tipini sen belirlersin:
DECLARE @doc NVARCHAR(MAX) = CONCAT(N'{"isbn":"9798900000268","title":"Dune","blurb":"', REPLICATE(CAST(N'x' AS NVARCHAR(MAX)), 5000), N'"}');
SELECT LEN(blurb) AS blurb_len FROM OPENJSON(@doc) WITH (blurb NVARCHAR(MAX) '$.blurb');
blurb_len
---------
5000
Bu yazı T-SQL'in JSON çekirdeğini anlatıyor: FOR JSON ile sorgu sonucunu JSON'a çevirmek, OPENJSON ile JSON'u row'lara açmak, JSON_VALUE, JSON_QUERY, JSON_MODIFY ve ISJSON, uygulamadan gelen JSON'u tek round trip'te (istemciyle sunucu arasında tek gidiş-dönüş) tablolara yazmak ve JSON'u index'lemek. Bu function'ların çoğu SQL Server 2016'dan beri var; 17. yazıdaki JSON_OBJECT/JSON_ARRAY ve 18. yazıdaki native json tipi ile JSON_ARRAYAGG bunların üstüne geldi. Örnekler sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) veritabanında.
FOR JSON: Sorgu Sonucunu JSON'a Çevirmek
Bookstore API'sinin sipariş detay endpoint'i bir siparişi, müşterisini ve kalemlerini tek bir JSON belgesi olarak döndürüyor. Bunu uygulamada üç sorgu ve bir mapping katmanıyla kurmak yerine tek sorguda üretebilirsin:
SELECT o.order_id AS id, o.status, o.ordered_at AS orderedAt,
c.first_name + ' ' + c.last_name AS [customer.name], c.email AS [customer.email],
(SELECT b.title, oi.quantity, oi.unit_price AS unitPrice
FROM dbo.order_items AS oi
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE oi.order_id = o.order_id
ORDER BY oi.order_item_id
FOR JSON PATH) AS items
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
WHERE o.order_id = 370
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER;
JSON_F52E2B61-18A1-11d1-B105-00805F49916B
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
{"id":370,"status":"Shipped","orderedAt":"2026-06-11T15:03:40","customer":{"name":"Annie Easley","email":"annie.easley@example.com"},"items":[{"title":"And Then There Were None","quantity":1,"unitPrice":430.00},{"title":"One Thousand and One Nights","quantity":1,"unitPrice":370.50},{"title":"Crime and Punishment","quantity":1,"unitPrice":200.00}]}
FOR JSON PATH'te yapıyı column alias'ları belirler. [customer.name] gibi noktalı bir alias iç içe bir object ("customer":{...}) üretir. Kalemler için kullandığımız correlated subquery kendi FOR JSON PATH'iyle bir array döndürür ve dış sorgu onu string olarak değil JSON olarak gömer; FOR JSON'ın ürettiği değer "zaten JSON" diye işaretlidir. WITHOUT_ARRAY_WRAPPER tek row'luk sonucun etrafındaki [ ]'yi kaldırır, böylece API doğrudan bir object döndürür. Tarih uluslararası standart biçimde (ISO 8601), DECIMAL değerler tırnaksız sayı olarak yazıldı; tipler JSON'a column tiplerinden geçiyor.
NULL değerlerde varsayılan davranış key'i hiç yazmamak. API sözleşmen key'in her zaman olmasını istiyorsa INCLUDE_NULL_VALUES ekle; ROOT ise sonucu isimli bir object'in içine koyar:
SELECT book_id AS id, title, subtitle FROM dbo.books WHERE book_id IN (26, 28) ORDER BY book_id FOR JSON PATH;
SELECT book_id AS id, title, subtitle FROM dbo.books WHERE book_id IN (26, 28) ORDER BY book_id FOR JSON PATH, INCLUDE_NULL_VALUES, ROOT('books');
JSON_F52E2B61-18A1-11d1-B105-00805F49916B
---------------------------------------------------------
[{"id":26,"title":"Dune"},{"id":28,"title":"Foundation"}]
(1 rows affected)
JSON_F52E2B61-18A1-11d1-B105-00805F49916B
---------------------------------------------------------------------------------------------------
{"books":[{"id":26,"title":"Dune","subtitle":null},{"id":28,"title":"Foundation","subtitle":null}]}
Angular tarafında book.subtitle'ı okuyan kod iki durumda da undefined ya da null görür, ama sözleşme testleri ve tipli istemciler bu farkı fark eder. FOR JSON AUTO ise yapıyı join'lerden çıkarır ve iç içe array'lere tablonun alias'ını key olarak verir:
SELECT b.book_id, b.title, a.last_name
FROM dbo.books AS b
JOIN dbo.book_authors AS ba ON ba.book_id = b.book_id
JOIN dbo.authors AS a ON a.author_id = ba.author_id
WHERE b.book_id IN (26, 42)
ORDER BY b.book_id, ba.author_order
FOR JSON AUTO;
JSON_F52E2B61-18A1-11d1-B105-00805F49916B
--------------------------------------------------------------------------------------------------------------------------------------------------------
[{"book_id":26,"title":"Dune","a":[{"last_name":"Herbert"}]},{"book_id":42,"title":"Good Omens","a":[{"last_name":"Pratchett"},{"last_name":"Gaiman"}]}]
Yazarlar "a" key'inin altında; çünkü authors tablosuna a alias'ını verdik. AUTO hızlı bir bakış için pratik, ama API'nin döndürdüğü yapıyı bir alias'ın adına ya da join sırasına bağlamak istemezsin. Üretim kodunda PATH ve açık alias'lar kullan.
Column adındaki JSON_F52E2B61-18A1-11d1-B105-00805F49916B da bir ipucu: bu sabit bir isim ve sonuç büyüdüğünde tek bir row'a sığmıyor. Bütün kataloğu JSON olarak isteyelim:
SELECT book_id AS id, isbn, title, list_price AS price FROM dbo.books ORDER BY book_id FOR JSON PATH;
JSON_F52E2B61-18A1-11d1-B105-00805F49916B
...
(3 rows affected)
sqlcmd üç row gösterdi: 2033, 2033 ve 1781 karakter. SQL Server uzun bir FOR JSON sonucunu istemciye parçalar hâlinde gönderir; SSMS (SQL Server'ın masaüstü yönetim aracı) grid'de bunları birleştirip tek row gibi gösterir, .NET'in veritabanı erişim katmanında (ADO.NET) ExecuteScalar ise yalnız ilk parçayı, yani bozuk bir JSON'un ilk 2033 karakterini döndürür. Ya bütün row'ları okuyup birleştirmen ya da sonucu bir değişkene atayıp tek değer olarak döndürmen gerekir:
DECLARE @j NVARCHAR(MAX) = (SELECT book_id AS id, isbn, title, list_price AS price FROM dbo.books ORDER BY book_id FOR JSON PATH);
SELECT LEN(@j) AS json_length;
json_length
-----------
5847
Subquery olarak kullanılan FOR JSON parçalanmaz: 5847 karakterin hepsi tek bir nvarchar(max) değerde. SELECT (SELECT ... FOR JSON PATH) AS books_json da aynı sonucu verir ve column'a anlamlı bir ad koyar.
OPENJSON: JSON'u Row'lara Açmak
Ters yön OPENJSON. Şemasız çağrıldığında bir object'in her key'i ya da bir array'in her element'i için bir row döndürür:
DECLARE @order NVARCHAR(MAX) = N'{"id":370,"status":"Shipped","customer":{"name":"Annie Easley"},"items":[{"bookId":46,"quantity":1},{"bookId":25,"quantity":2}],"gift":true,"note":null}';
SELECT [key], value, type FROM OPENJSON(@order);
key value type
-------- ------------------------------------------------------- ----
id 370 2
status Shipped 1
customer {"name":"Annie Easley"} 5
items [{"bookId":46,"quantity":1},{"bookId":25,"quantity":2}] 4
gift true 3
note NULL 0
type column'u JSON tipini sayıyla verir: 0 null, 1 string, 2 number, 3 boolean, 4 array, 5 object. value her zaman metin; 370 de Shipped de nvarchar. Gerçek işte çoğunlukla WITH şemasını kullanırsın: her column'un tipini ve JSON path'ini sen yazarsın, iç içe bir array'i AS JSON ile ham JSON olarak alıp CROSS APPLY ile bir seviye daha açarsın. İki siparişlik bir paket:
DECLARE @orders NVARCHAR(MAX) = N'[
{"id":501,"customerId":1,"items":[{"bookId":26,"quantity":1},{"bookId":42,"quantity":2}]},
{"id":502,"customerId":3,"items":[{"bookId":60,"quantity":1}]}
]';
SELECT o.id AS order_id, o.customer_id, i.book_id, i.quantity
FROM OPENJSON(@orders) WITH (
id INT '$.id',
customer_id INT '$.customerId',
items NVARCHAR(MAX) '$.items' AS JSON
) AS o
CROSS APPLY OPENJSON(o.items) WITH (book_id INT '$.bookId', quantity INT '$.quantity') AS i
ORDER BY o.id, i.book_id;
order_id customer_id book_id quantity
-------- ----------- ------- --------
501 1 26 1
501 1 42 2
502 3 60 1
AS JSON yalnız NVARCHAR(MAX) bir column'da kullanılabilir. JSON'daki key'ler büyük-küçük harfe duyarlıdır: path'i '$.customerid' yazsaydın hata almazdın, column NULL gelirdi. CROSS APPLY 9. yazıdaki gibi her siparişin kalemlerini kendi row'una bağlıyor; kalemi olmayan siparişleri de görmek istiyorsan OUTER APPLY kullan.
JSON_VALUE, JSON_QUERY, ISJSON ve Path Modları
Tek bir değeri okumak için iki function var ve farkları dönen değerin türünde:
DECLARE @order NVARCHAR(MAX) = N'{"id":370,"status":"Shipped","customer":{"name":"Annie Easley"},"items":[{"bookId":46,"quantity":1},{"bookId":25,"quantity":2}],"gift":true,"note":null}';
SELECT JSON_VALUE(@order, '$.customer.name') AS customer_name, JSON_VALUE(@order, '$.items[1].quantity') AS second_qty,
JSON_VALUE(@order, '$.items') AS items_as_value, JSON_QUERY(@order, '$.items') AS items_as_query,
JSON_VALUE(@order, '$.missing') AS missing, ISJSON(@order) AS is_json;
customer_name second_qty items_as_value items_as_query missing is_json
------------- ---------- -------------- ------------------------------------------------------- ------- -------
Annie Easley 2 NULL [{"bookId":46,"quantity":1},{"bookId":25,"quantity":2}] NULL 1
JSON_VALUE yalnız scalar (string, sayı, boolean) döndürür; array ya da object'e işaret eden bir path'te NULL verir. JSON_QUERY tam tersi: object ve array döndürür, scalar'da NULL. Array index'leri sıfırdan başlar; $.items[1] ikinci kalem. Olmayan bir key de lax modda NULL. Yani lax modda NULL dört farklı şey demek olabilir: değer gerçekten null, key yok, path yanlış tipte bir şeyi gösteriyor ya da değer 4000 karakteri aşıyor. Bunlardan birini hata olarak görmek istediğinde strict kullan:
DECLARE @order NVARCHAR(MAX) = N'{"id":370,"status":"Shipped"}';
SELECT JSON_VALUE(@order, 'strict $.missing') AS missing_strict;
Msg 13608, Level 16, State 2, Line 2
Property cannot be found on the specified JSON path.
ISJSON metnin geçerli JSON olup olmadığını 1/0 olarak döndürür; SQL Server 2022'den beri ikinci argümanla beklenen tipi de sorabilirsin (ISJSON(@x, OBJECT), ARRAY, SCALAR). JSON'u nvarchar'da saklıyorsan CHECK (ISJSON(payload) = 1) constraint'i bozuk veriyi kapıda durdurur; native json tipinde bu kontrolü tip kendisi yapıyor (18. yazı).
JSON_VALUE'nun sonucu hep metin olduğu için sayıyla karşılaştırırken implicit conversion devreye girer. SQL Server 2025 buna RETURNING ekledi, ama yalnız girdi native json tipindeyse:
DECLARE @text NVARCHAR(MAX) = N'{"price":390.50}';
SELECT JSON_VALUE(@text, '$.price' RETURNING DECIMAL(10,2)) AS price;
Msg 102, Level 15, State 1, Line 2
Incorrect syntax near 'RETURNING'.
DECLARE @doc JSON = N'{"price":390.50}';
SELECT JSON_VALUE(@doc, '$.price' RETURNING DECIMAL(10,2)) AS price, JSON_VALUE(@doc, '$.price') AS price_text,
SQL_VARIANT_PROPERTY(JSON_VALUE(@doc, '$.price'), 'BaseType') AS text_type;
price price_text text_type
------ ---------- ---------
390.50 390.50 nvarchar
nvarchar bir değişkende RETURNING bir syntax hatası; aynı metin JSON tipindeki bir değişkende DECIMAL(10,2) döndü. RETURNING'siz çağrı ise json girdide de nvarchar döndürüyor.
JSON_MODIFY: Belgeyi Yerinde Değiştirmek
JSON_MODIFY bir JSON metninde tek bir path'i günceller, ekler ya da siler ve yeni metni döndürür. Bir sepet üzerinde:
DECLARE @cart NVARCHAR(MAX) = N'{"customerId":1,"items":[{"bookId":26,"quantity":1}]}';
SET @cart = JSON_MODIFY(@cart, '$.items[0].quantity', 2);
SET @cart = JSON_MODIFY(@cart, 'append $.items', JSON_QUERY(N'{"bookId":42,"quantity":1}'));
SET @cart = JSON_MODIFY(@cart, '$.coupon', 'SUMMER10');
SELECT @cart AS cart;
SET @cart = JSON_MODIFY(@cart, '$.coupon', NULL);
SELECT @cart AS without_coupon;
SELECT JSON_MODIFY(N'{"items":[]}', 'append $.items', N'{"bookId":42,"quantity":1}') AS escaped;
cart
----------------------------------------------------------------------------------------------------
{"customerId":1,"items":[{"bookId":26,"quantity":2},{"bookId":42,"quantity":1}],"coupon":"SUMMER10"}
(1 rows affected)
without_coupon
--------------------------------------------------------------------------------
{"customerId":1,"items":[{"bookId":26,"quantity":2},{"bookId":42,"quantity":1}]}
(1 rows affected)
escaped
--------------------------------------------
{"items":["{\"bookId\":42,\"quantity\":1}"]}
Olmayan bir key'e değer vermek ($.coupon) lax modda key'i ekler; değer olarak NULL vermek key'i siler. append bir array'in sonuna element ekler. Son satır en sık yapılan hata: yeni değer düz bir string olduğunda JSON_MODIFY onu bir JSON string'i olarak kaçışlar (escape eder) ve array'e object değil, içinde tırnaklar kaçışlanmış bir metin girer. Object ya da array eklerken değeri JSON_QUERY ile sarmalarsın; JSON_QUERY'nin sonucu "bu zaten JSON" diye işaretlidir. strict path ile olmayan bir key'i güncellemeye çalışırsan Msg 13608 alırsın; bu da "yalnız var olanı değiştir" kuralını kodda zorlamanın yolu.
JSON Parametresiyle Tek Round Trip'te Yazmak
19. yazıda uygulamadan gelen bir id listesini virgüllü string yerine JSON parametresiyle göndermenin daha sağlam olduğunu söylemiştik. Sebebi: virgüllü string'de ayırıcı, boşluk ve tip sorunlarını sen çözersin; JSON array'inde sınırlar belli ve WITH şeması tipi kontrol eder:
DECLARE @ids NVARCHAR(MAX) = N'[26, 42, 60]';
SELECT b.book_id, b.title, b.list_price
FROM dbo.books AS b
WHERE b.book_id IN (SELECT id FROM OPENJSON(@ids) WITH (id INT '$'))
ORDER BY b.book_id;
book_id title list_price
------- --------------- ----------
26 Dune 390.50
42 Good Omens 340.90
60 Design Patterns 1450.00
Asıl kazanç yazarken. Angular'daki bookstore-web checkout'ta sepeti JSON olarak gönderiyor; siparişi ve kalemlerini tek bir istekte, tek transaction'da yazalım. @cart burada değişken; uygulamada Dapper ya da ADO.NET'ten gelen bir parameter olur:
DECLARE @cart JSON = N'{"customerId":1,"items":[{"bookId":26,"quantity":2,"price":1.00},{"bookId":42,"quantity":1},{"bookId":60,"quantity":1}]}';
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @new_order TABLE (order_id INT);
INSERT INTO dbo.orders (customer_id, ordered_at, status)
OUTPUT inserted.order_id INTO @new_order (order_id)
VALUES (JSON_VALUE(@cart, '$.customerId' RETURNING INT), SYSUTCDATETIME(), 'Pending');
INSERT INTO dbo.order_items (order_id, book_id, quantity, unit_price)
SELECT n.order_id, i.book_id, i.quantity, b.list_price
FROM OPENJSON(@cart, '$.items') WITH (book_id INT '$.bookId', quantity INT '$.quantity') AS i
JOIN dbo.books AS b ON b.book_id = i.book_id AND b.is_active = 1
CROSS JOIN @new_order AS n;
IF @@ROWCOUNT <> (SELECT COUNT(*) FROM OPENJSON(@cart, '$.items'))
THROW 50002, 'Cart contains an unknown or inactive book.', 1;
SELECT oi.order_id, oi.book_id, b.title, oi.quantity, oi.unit_price
FROM dbo.order_items AS oi
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE oi.order_id = (SELECT order_id FROM @new_order)
ORDER BY oi.book_id;
ROLLBACK;
order_id book_id title quantity unit_price
-------- ------- --------------- -------- ----------
380 26 Dune 2 390.50
380 42 Good Omens 1 340.90
380 60 Design Patterns 1 1450.00
Birkaç karar var. Fiyat JSON'dan değil books'tan geliyor: istemcinin gönderdiği "price":1.00 yok sayıldı, çünkü fiyatı tarayıcıya bırakmak birinin Dune'u 1 TL'ye almasına izin vermek demek. Sipariş id'si 15. yazıdaki gibi OUTPUT ... INTO ile alınıyor. JOIN bilinmeyen ya da satıştan kalkmış bir kitabı sessizce düşürür; bunu yakalamak için eklenen row sayısını JSON'daki kalem sayısıyla karşılaştırıyoruz (22. yazıdaki gibi @@ROWCOUNT'u hemen ardından okuyarak). İkinci kalemi satışta olmayan 44 numaralı kitapla değiştirince script "Msg 50002, Level 16, State 1, Line 14 Cart contains an unknown or inactive book." hatasıyla durdu ve XACT_ABORT siparişi de geri aldı; @@TRANCOUNT 0'dı. Örneğin sonundaki ROLLBACK yalnız veritabanını sonraki örnekler için temiz tutmak için. THROW ve transaction'lı script yazmayı bir sonraki yazıda ayrıntılı göreceğiz.
Aynı işi TVP (table-valued parameter; tablo biçiminde gönderilen parametre) ile de yapabilirsin; Dapper serisinin SQL Server'a özgü parametreleri anlatan yazısında gördük. TVP tip güvenliği ve büyük hacimde daha iyi istatistik verir ama veritabanında bir table type tanımlamayı ve uygulamada DataTable ya da benzeri bir yapı kurmayı gerektirir. JSON parametresi ise ek nesne istemez, iç içe veriyi (sipariş + kalemler) tek parametrede taşır ve aynı script'i SSMS'te elle çalıştırmak da kolaydır. Birkaç bin row'a kadar ikisi de iş görür.
JSON'u Index'lemek: Computed Column ve JSON Index
JSON içindeki bir değere göre filtreleme, index yoksa her row'da JSON'u parse etmek demek. Bookstore'un ürün sayfası olaylarını tutan bir table'a 20.000 olay yazalım; yalnız 20'si stock_low:
DROP TABLE IF EXISTS dbo.book_events;
CREATE TABLE dbo.book_events (
event_id INT IDENTITY(1,1) CONSTRAINT PK_book_events PRIMARY KEY,
payload NVARCHAR(MAX) NOT NULL CONSTRAINT CK_book_events_payload CHECK (ISJSON(payload) = 1)
);
INSERT INTO dbo.book_events (payload)
SELECT CONCAT(N'{"type":"', CASE WHEN g.value % 1000 = 0 THEN 'stock_low' ELSE 'viewed' END,
N'","bookId":', 1 + g.value % 77, N',"at":"2026-06-', FORMAT(1 + g.value % 30, '00'), N'"}')
FROM GENERATE_SERIES(1, 20000) AS g;
SELECT JSON_VALUE(payload, '$.type') AS event_type, COUNT(*) AS events FROM dbo.book_events GROUP BY JSON_VALUE(payload, '$.type');
(20000 rows affected)
event_type events
---------- ------
viewed 19980
stock_low 20
SET STATISTICS IO ON;
SELECT COUNT(*) AS low_stock_events FROM dbo.book_events WHERE JSON_VALUE(payload, '$.type') = 'stock_low';
SET STATISTICS IO OFF;
low_stock_events
----------------
20
(1 rows affected)
Table 'book_events'. Scan count 1, logical reads 276, ...
20 row için bütün table okundu: 276 page. Klasik çözüm, aranan ifadeyi bir computed column'a koyup onu index'lemek:
ALTER TABLE dbo.book_events ADD event_type AS JSON_VALUE(payload, '$.type');
CREATE INDEX IX_book_events_event_type ON dbo.book_events (event_type);
Warning! The maximum key length for a nonclustered index is 1700 bytes. The index 'IX_book_events_event_type' has maximum length of 8000 bytes. For some combination of large values, the insert/update operation will fail.
SET STATISTICS IO ON;
SELECT COUNT(*) AS low_stock_events FROM dbo.book_events WHERE JSON_VALUE(payload, '$.type') = 'stock_low';
SET STATISTICS IO OFF;
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT COUNT(*) AS low_stock_events FROM dbo.book_events WHERE JSON_VALUE(payload, '$.type') = 'stock_low';
GO
SET SHOWPLAN_TEXT OFF;
low_stock_events
----------------
20
(1 rows affected)
Table 'book_events'. Scan count 1, logical reads 3, ...
|--Compute Scalar(DEFINE:([Expr1002]=CONVERT_IMPLICIT(int,[Expr1004],0)))
|--Stream Aggregate(DEFINE:([Expr1004]=Count(*)))
|--Index Seek(OBJECT:([sqlseries_ext_23].[dbo].[book_events].[IX_book_events_event_type]), SEEK:([sqlseries_ext_23].[dbo].[book_events].[event_type]=N'stock_low') ORDERED FORWARD)
Sorguyu değiştirmedik; optimizer WHERE'deki JSON_VALUE(payload, '$.type') ifadesinin computed column'un ifadesiyle aynı olduğunu görüp index'i kullandı: 276 yerine 3 logical read. Computed column persisted değil, table'da yer kaplamıyor; değeri yalnız index'te duruyor. Uyarının sebebi JSON_VALUE'nun nvarchar(4000) döndürmesi: 1700 byte'tan uzun bir type gelirse insert hata verir. Microsoft Learn column'u CAST ile dar bir tipe çevirmeyi öneriyor; ama o zaman sorgunun da aynı CAST'i yazması gerekir. Column'u CAST(JSON_VALUE(payload, '$.type') AS VARCHAR(30)) olarak tanımladığımda uyarı kayboldu, çıplak JSON_VALUE'lu sorgu ise index'i bulamayıp yine 276 page okudu; WHERE event_type = 'stock_low' ya da aynı CAST'li ifade 3 page'e indi. Kural: index'lediğin ifade ile sorguda yazdığın ifade birebir aynı olmalı.
SQL Server 2025'te ikinci yol native json column'u ve CREATE JSON INDEX. Microsoft Learn'e göre ikisi de 2025'te genel kullanımda (GA); JSON index table'da clustered primary key ister ve tek bir index belgedeki birden çok path'i kapsar. Aynı olayları json tipinde bir table'a kopyalayıp index'leyelim:
DROP TABLE IF EXISTS dbo.book_events_json;
CREATE TABLE dbo.book_events_json (
event_id INT NOT NULL CONSTRAINT PK_book_events_json PRIMARY KEY,
payload JSON NOT NULL
);
INSERT INTO dbo.book_events_json (event_id, payload) SELECT event_id, payload FROM dbo.book_events;
CREATE JSON INDEX JX_book_events_json_payload ON dbo.book_events_json (payload) FOR ('$.type');
GO
SET STATISTICS IO ON;
SELECT COUNT(*) AS by_json_value FROM dbo.book_events_json WHERE JSON_VALUE(payload, '$.type') = 'stock_low';
SELECT COUNT(*) AS by_json_contains FROM dbo.book_events_json WHERE JSON_CONTAINS(payload, 'stock_low', '$.type') = 1;
SET STATISTICS IO OFF;
by_json_value
-------------
20
(1 rows affected)
Table 'book_events_json'. Scan count 1, logical reads 310, ...
by_json_contains
----------------
20
(1 rows affected)
Table 'book_events_json'. Scan count 0, logical reads 40, ...
JSON_CONTAINS'li sorgu JSON index'i kullandı: table'da 40, index'in kendi internal table'ında 4 logical read. Aynı koşulu JSON_VALUE ... = ile yazınca optimizer index'i seçmedi ve bütün table'ı taradı (310 page); Microsoft Learn bu kalıbın da desteklendiğini yazıyor, ama benim Cumulative Update 10 build'imde bu table'da index kullanılmadı. Bu yüzden JSON index'e geçiyorsan sorgularını JSON_CONTAINS/JSON_PATH_EXISTS ile yaz ve planı mutlaka kendin kontrol et. Tek bir property'yi sık filtreliyorsan computed column index'i 2016'dan beri çalışan, öngörülebilir yol. Entity Framework Core (EF Core) tarafında JSON kolonlar EF Core serisinin EF Core 7 ve EF Core 10 yeniliklerini anlatan yazılarında; EF Core 10, compatibility level 170'te bu kolonları json tipine taşıyor.
Function Özeti ve PostgreSQL Karşılıkları
PostgreSQL sütunundaki ifadeleri kendi PostgreSQL 18 container'ımda çalıştırdım. PostgreSQL'de JSON'un iki tipi var: metni olduğu gibi tutan json ve ikili biçimde tutan jsonb; index'lenebilen ve günlük işte tercih edilen jsonb.
| T-SQL | NULL ve sınır davranışı | PostgreSQL |
|---|---|---|
FOR JSON PATH | NULL key yazılmaz; INCLUDE_NULL_VALUES ile yazılır | json_agg, json_build_object |
OPENJSON ... WITH | Olmayan path NULL; tip uyuşmazsa hata | JSON_TABLE (17+), jsonb_to_recordset |
JSON_VALUE | Lax: yok, scalar değil ya da 4000+ karakter → NULL | ->>, JSON_VALUE (17+); uzunluk sınırı yok |
JSON_QUERY | Scalar'da NULL | ->, JSON_QUERY (17+) |
JSON_MODIFY | Değer NULL ise key silinir | jsonb_set, jsonb - 'key' |
ISJSON(x [, type]) | NULL girdide NULL | x IS JSON [OBJECT] (16+) |
Computed column index, CREATE JSON INDEX | Index key 1700 byte | Expression index, USING gin ve @> |
PostgreSQL'de jsonb bir column'a GIN (Generalized Inverted Index; bir değerin içindeki her parçayı ayrı ayrı index'leyen index türü) index'i kurup payload @> '{"type":"stock_low"}' yazdığımda plan aynı 20.000 row'da Bitmap Index Scan kullandı. 5000 karakterlik değeri JSON_VALUE ile okumak orada sorun çıkarmadı; 4000 sınırı T-SQL'e özgü.
Üretimde Dikkat
JSON'u sınırda kullan: API'ye dönen belgeyi üretmek, uygulamadan gelen toplu veriyi almak, şeması gerçekten değişken olan veriyi (olay payload'u, entegrasyon yanıtı, ürün tipine göre değişen özellikler) saklamak. Sorguladığın, join'lediğin, üzerinde constraint istediğin veri ise column olarak durmalı. Siparişin müşterisini ya da tutarını JSON'a koyarsan foreign key'i, CHECK'i ve statistics'i kaybedersin; Çözüm Desenleri serisinin "Çok Dilli Veri: Ürün Adını TR ve EN Saklamak — Translation Table, JSON Kolon ve Fallback" yazısı JSON kolonun ne zaman doğru seçim olduğunu bir örnek üzerinden tartıyor.
FOR JSON sonucunu uygulamaya döndürürken parçalanmaya dikkat et: sonucu bir değişkene ya da SELECT (... FOR JSON PATH) AS x biçiminde tek bir değere al. Uygulamadan gelen JSON'da tip ve sayı kontrolünü WITH şemasıyla ve row sayısı karşılaştırmasıyla yap, iş kurallarını (fiyat, stok, aktiflik) JSON'a değil veritabanındaki veriye dayandır. JSON'u nvarchar'da tutuyorsan ISJSON constraint'i koy; 2025'e geçtiysen yeni tablolarda json tipini kullan.
Sık Düşülen Tuzaklar
JSON_VALUE'nun 4000 karakterden uzun değerde lax modda sessizceNULLdönmesi; uzun değerler içinOPENJSON ... WITH (x NVARCHAR(MAX)).- Array ya da object'i
JSON_VALUEile okumaya çalışıpNULLalmak; onlarJSON_QUERY'nin işi. JSON_MODIFY'a object'i düz string olarak verip kaçışlanmış metin eklemek; değeriJSON_QUERYile sarmala.- Uzun
FOR JSONsonucunuExecuteScalarile okuyup yalnız ilk 2033 karakteri almak. - Computed column'u
CAST'le tanımlayıp sorguda çıplakJSON_VALUEyazmak; ifadeler aynı değilse index kullanılmaz. - JSON path'teki key'in büyük-küçük harfini yanlış yazmak; hata değil
NULLgelir. - İlişkisel olması gereken veriyi (müşteri, tutar, durum) JSON kolona koyup foreign key ve constraint'leri kaybetmek.
Küçük Bir Deneme
★☆☆ Bir müşterinin bütün siparişlerini, her siparişin altında kalemleriyle, FOR JSON PATH ve ROOT('orders') ile tek bir belge olarak üret. Grace Hopper'ın (müşteri 3) 33 siparişi tek row'a sığıyor mu? Sonucu değişkene alıp LEN ile ölç.
★★☆ Sepet script'ini, aynı kitap JSON'da iki kez geçtiğinde adetleri toplayacak şekilde değiştir (order_items'taki UQ_order_items_order_book constraint'i aynı kitabın iki row olmasına izin vermiyor). Adedi 0 ya da negatif olan bir kalem geldiğinde hangi hatayı aldığına bak.
★★★ book_events'e bookId için de bir computed column index'i ekle ve "18 numaralı kitabın stock_low olayları" sorgusunun planına bak. Sonra aynı sorguyu book_events_json üzerinde JSON_CONTAINS ile yaz; JSON index'i iki path için yeniden oluşturman gerekecek.
Bu yazıyla T-SQL'in JSON tarafını tamamladık. Bir sonraki yazıda tek sorgulardan script'lere geçiyoruz: batch ve GO, değişkenler, IF/WHILE, TRY...CATCH ve THROW ile, hata olduğunda yarım iş bırakmayan bir fiyat güncelleme script'i yazacağız.