T-SQL Script Yazmak: Batch ve GO, Değişkenler, IF/WHILE, TRY...CATCH, THROW ve Transaction'lı Script
Güvenli bir T-SQL script'i SET XACT_ABORT ON, önce doğrulama, TRY...CATCH içinde transaction ve CATCH'te ROLLBACK ile THROW kullanır; XACT_ABORT OFF iken hata sonrası transaction commit edilebilir kalır ve yarım iş kalıcı olabilir. SET ile SELECT atama farkı, GO'nun araç komutu olması, CATCH'in yakalamadığı Msg 208, ROLLBACK THROW tuzağı ve 20'lik parçalarla tekrar çalıştırılabilir fiyat güncellemesi 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, sqlcmd örnekleri SQL Server 2025 image'ındaki mssql-tools18 sqlcmd ile, PostgreSQL karşılaştırmaları PostgreSQL 18.7 üzerinde çalıştırılmıştır.
Önce Tahmin Et: SET mi, SELECT mi?
İki değişkene de 0 verip, olmayan bir kitabın (999) fiyatını biri SET, diğeri SELECT ile okuyoruz. İkisi de aynı şeyi mi gösterir?
DECLARE @price_set DECIMAL(10,2) = 0, @price_select DECIMAL(10,2) = 0;
SET @price_set = (SELECT list_price FROM dbo.books WHERE book_id = 999);
SELECT @price_select = list_price FROM dbo.books WHERE book_id = 999;
SELECT @price_set AS with_set, @price_select AS with_select;
with_set with_select
-------- -----------
NULL .00
SET bir subquery'nin sonucunu atar; subquery row döndürmezse sonuç NULL'dır. SELECT @x = ... ise her row için atama yapar; row yoksa hiç atama yapmaz ve değişken eski değerinde kalır. Bir döngüde aynı değişkeni tekrar tekrar dolduruyorsan bu fark ciddi bir hataya dönüşür: "kitap bulunamadı" durumunu bir önceki turun değeriyle işlersin. Birden çok row olduğunda da davranışlar ayrılıyor:
DECLARE @title NVARCHAR(200);
SELECT @title = title FROM dbo.books WHERE category_id = 4;
DECLARE @n INT = @@ROWCOUNT;
SELECT @title AS last_title, @n AS rows_read;
SET @title = (SELECT title FROM dbo.books WHERE category_id = 4);
SELECT 'after SET' AS step, @title AS title;
last_title rows_read
------------------- ---------
The Handmaid's Tale 4
(1 rows affected)
Msg 512, Level 16, State 1, Line 5
Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.
step title
--------- -------------------
after SET The Handmaid's Tale
(1 rows affected)
Dystopian kategorisinde dört kitap var; SELECT atama dört kez yaptı ve değişkende son okunan row kaldı. Hangi row'un "son" olduğu ORDER BY olmadan garanti değil. SET ise birden fazla değeri kabul etmedi ve Msg 512 verdi. Hatanın ardından batch durmadı: bir sonraki SELECT çalıştı ve @title eski değerinde kaldı. Kural: tek değer bekliyorsan SET @x = (subquery) yaz, birden fazla değişkeni tek okumada dolduracaksan SELECT kullan ama anahtarın tekil olduğundan emin ol. Bu yazı tek sorgulardan script'lere geçiyor: batch ve GO, değişkenler, IF/WHILE, cursor, TRY...CATCH, THROW ve transaction'lı script, sqlcmd değişkenleri. Sonunda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) fiyat güncelleme script'ini parça parça, hata olduğunda yarım iş bırakmayan ve tekrar çalıştırılabilen bir hâle getireceğiz.
Batch ve GO
Batch, istemcinin sunucuya tek seferde gönderdiği statement grubudur; sunucu onu tek parça olarak derler ve çalıştırır. GO ise T-SQL değil: sqlcmd, SSMS (SQL Server'ın masaüstü yönetim aracı) ve Visual Studio Code'un SQL Server eklentisi gibi araçların tanıdığı bir ayırıcı. Araç script'i GO satırlarından böler ve her parçayı ayrı bir batch olarak gönderir. Bunun ilk sonucu değişkenlerin ömrü:
DECLARE @batch_size INT = 20;
SELECT @batch_size AS in_first_batch;
GO
SELECT @batch_size AS in_second_batch;
in_first_batch
--------------
20
(1 rows affected)
Msg 137, Level 15, State 2, Line 1
Must declare the scalar variable "@batch_size".
Değişken batch'le birlikte ölür. Batch'ler arasında değer taşımak istiyorsan bir table'a (ya da temp table'a) yazman ya da sqlcmd değişkeni kullanman gerekir. İkinci sonuç hataların etki alanı. Derleme hatası bütün batch'i durdurur; çalışma anında oluşan hataların çoğu ise yalnız o statement'ı:
SELECT COUNT(*) AS books FROM dbo.books;
SELEC 1;
GO
SELECT COUNT(*) AS books FROM dbo.books;
SELECT COUNT(*) FROM dbo.no_such_table;
SELECT 'still running' AS step;
Msg 102, Level 15, State 1, Line 2
Incorrect syntax near 'SELEC'.
books
-----
77
(1 rows affected)
Msg 208, Level 16, State 1, Line 2
Invalid object name 'dbo.no_such_table'.
İlk batch'te yazım hatası 2. satırda, ama 1. satırdaki SELECT de çalışmadı: batch derlenemedi. İkinci batch'te ilk sorgu çalıştı, olmayan table'a gelince Msg 208 verdi ve batch'in geri kalanı (still running) çalışmadı. Bunun sebebi deferred name resolution: SQL Server olmayan bir table'a referansı derleme anında kabul eder, statement çalışacağı zaman yeniden derlemeye çalışır ve orada vazgeçer. Yukarıdaki Msg 512 ise batch'i durdurmamıştı. Hangi hatanın neyi durdurduğunu ezberlemek yerine script'ini XACT_ABORT ve TRY...CATCH ile kuracağız; ama önce bazı statement'ların kendi batch'lerini istediğini bil:
DROP VIEW IF EXISTS dbo.v_active_books;
CREATE VIEW dbo.v_active_books AS SELECT book_id, title FROM dbo.books WHERE is_active = 1;
Msg 111, Level 15, State 9, Line 2
'CREATE VIEW' must be the first statement in a query batch.
CREATE VIEW, CREATE PROCEDURE, CREATE FUNCTION ve CREATE TRIGGER batch'in ilk statement'ı olmalı; araçta araya bir GO koyarsın. Uygulama kodunda ise GO diye bir şey yok. Migration script'ini olduğu gibi SqlCommand'a ya da Dapper'a verirsen sunucu GO'yu sıradan bir kelime olarak görür:
EXEC sp_executesql N'DROP VIEW IF EXISTS dbo.v_active_books;
GO
CREATE VIEW dbo.v_active_books AS SELECT book_id, title FROM dbo.books WHERE is_active = 1;';
GO
EXEC sp_executesql N'SELECT COUNT(*) FROM dbo.books
GO';
Msg 102, Level 15, State 1, Line 2
Incorrect syntax near 'GO'.
Msg 111, Level 15, State 9, Line 3
'CREATE VIEW' must be the first statement in a query batch.
--
77
İlki hata verdi; bu iyi, en azından fark edersin. İkincisi daha sinsi: GO, dbo.books'a verilmiş bir table alias'ı olarak okundu ve sorgu sessizce çalıştı. Uygulamadan script çalıştırman gerekiyorsa script'i GO satırlarından kendin böl ya da bunu yapan bir araç (DbUp, Flyway, SQL Database Projects) kullan; bu araçlar SQL Server Derinlemesine serisinin ilerideki şema değişikliği yazısında. GO'nun bir de sayı alan biçimi var; batch'i o kadar kez çalıştırır ve test verisi üretirken işe yarar.
IF, WHILE ve Cursor
IF tek bir statement'ı ya da bir BEGIN...END bloğunu koşula bağlar. Script'lerde en sık kullanılan biçimi IF EXISTS: "iş zaten yapıldıysa bir daha yapma". Satıştaki her kitabın bir stok row'u olmalı; eksik olanları ekleyen ve iki kez çalıştırılınca zarar vermeyen bir parça:
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM dbo.books AS b
WHERE b.is_active = 1
AND NOT EXISTS (SELECT 1 FROM dbo.stock_items AS s WHERE s.book_id = b.book_id))
BEGIN
INSERT INTO dbo.stock_items (book_id, quantity_on_hand)
SELECT b.book_id, 0
FROM dbo.books AS b
WHERE b.is_active = 1
AND NOT EXISTS (SELECT 1 FROM dbo.stock_items AS s WHERE s.book_id = b.book_id);
PRINT CONCAT(@@ROWCOUNT, ' stock rows created');
END
ELSE
PRINT 'every active book has a stock row';
GO 2
8 stock rows created
every active book has a stock row
GO 2 aynı batch'i iki kez çalıştırdı: ilk turda 70–77 numaralı kitaplar için 8 stok row'u eklendi, ikinci turda yapılacak iş kalmamıştı. Tekrar çalıştırılabilir (idempotent) script'in özü bu: script önce durumu kontrol eder, yalnız eksik olanı yapar. SET NOCOUNT ON "rows affected" mesajlarını kapatır; büyük script'lerde çıktıyı okunur tutar.
WHILE T-SQL'in tek döngüsü; BREAK döngüden çıkar, CONTINUE bir sonraki tura geçer. Set-based düşünmeyi anlatan bir seride döngü görmek garip gelebilir, ama iki meşru kullanımı var: büyük bir değişikliği parçalara bölmek (aşağıdaki fiyat script'i) ve her nesne için ayrı bir statement çalıştırmak. İkincisi cursor'ın da tipik kullanım yeri:
DECLARE @name SYSNAME, @sql NVARCHAR(400), @n INT;
DECLARE tables_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT name FROM sys.tables WHERE name IN ('books', 'order_items', 'orders') ORDER BY name;
OPEN tables_cursor;
FETCH NEXT FROM tables_cursor INTO @name;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @sql = N'SELECT @n = COUNT(*) FROM dbo.' + QUOTENAME(@name) + N';';
EXEC sp_executesql @sql, N'@n INT OUTPUT', @n = @n OUTPUT;
PRINT CONCAT(@name, N': ', @n);
FETCH NEXT FROM tables_cursor INTO @name;
END;
CLOSE tables_cursor;
DEALLOCATE tables_cursor;
books: 77
order_items: 680
orders: 379
LOCAL FAST_FORWARD cursor'ı yalnız bu batch'te görünen, salt okunur ve ileri yönlü yapar; özel bir sebebin yoksa varsayılan seçimin bu olsun. Table adını dynamic SQL'e QUOTENAME ile koyduk (19. yazı). Ama bu örnekte cursor'a gerek yoktu; aynı bilgi catalog view'larda (22. yazı) hazır duruyor:
SELECT t.name AS table_name, SUM(p.rows) AS row_count
FROM sys.tables AS t
JOIN sys.partitions AS p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
WHERE t.name IN ('books', 'order_items', 'orders')
GROUP BY t.name
ORDER BY t.name;
table_name row_count
----------- ---------
books 77
order_items 680
orders 379
Cursor'a uzanmadan önce sor: bu iş tek bir UPDATE, INSERT ... SELECT ya da window function'lı bir sorguyla yapılabilir mi? Row row işlem, set-based karşılığından genelde kat kat yavaştır. Cursor'ın gerçekten gerektiği yerler her row için bir stored procedure çağırmak, her nesne için ayrı bir DDL (şemayı değiştiren CREATE, ALTER, DROP komutları) ya da bakım komutu çalıştırmak gibi, işin doğası gereği tek tek yapılan şeyler. Row'ları geçici olarak tutmak için table variable ve temp table farkı SQL Server Derinlemesine serisinin "tempdb, Temp Table ve Table Variable: Hangisi Ne Zaman?" yazısında.
TRY...CATCH Neyi Yakalar, Neyi Yakalamaz?
TRY bloğunda severity'si (hatanın ciddiyet derecesi) 10'dan büyük ve bağlantıyı koparmayan bir hata olursa kontrol CATCH bloğuna geçer. 22. yazıda ERROR_NUMBER, ERROR_MESSAGE ve ERROR_LINE'ın yalnız CATCH içinde değer döndürdüğünü görmüştük. Önemli olan, neyin yakalanmadığını bilmek:
BEGIN TRY
SELECT COUNT(*) AS c FROM dbo.no_such_table;
END TRY
BEGIN CATCH
SELECT 'caught' AS step, ERROR_NUMBER() AS err_no;
END CATCH;
SELECT 'after' AS step;
GO
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) AS c FROM dbo.no_such_table;';
END TRY
BEGIN CATCH
SELECT 'caught' AS step, ERROR_NUMBER() AS err_no, ERROR_MESSAGE() AS msg;
END CATCH;
Msg 208, Level 16, State 1, Line 2
Invalid object name 'dbo.no_such_table'.
step err_no msg
------ ------ ----------------------------------------
caught 208 Invalid object name 'dbo.no_such_table'.
Aynı Msg 208, ilk batch'te CATCH'e hiç uğramadı ve after da çalışmadı. Microsoft Learn'ün açıkladığı kural: derleme hataları ve object adı çözümleme hataları, TRY...CATCH ile aynı seviyede oluşursa yakalanmaz; hata bir alt seviyede (sp_executesql, çağrılan bir stored procedure) oluşursa yakalanır. İkinci batch'te tam olarak bu oldu. Deploy script'lerinde "table yoksa CATCH loglar" diye düşünüyorsan yanılıyorsun; varlık kontrolünü OBJECT_ID ile önceden yap (bir sonraki yazı).
Severity'si 10 ve altındaki mesajlar da hata sayılmaz:
BEGIN TRY
RAISERROR('just a warning', 10, 1);
SELECT 'after severity 10' AS step;
END TRY
BEGIN CATCH
SELECT 'caught' AS step;
END CATCH;
just a warning
step
-----------------
after severity 10
Bu davranış ilerleme mesajları için kullanışlı: severity 0 ile RAISERROR bir bilgi mesajı gönderir, akışı bozmaz. Bunlara ek olarak istemcinin gönderdiği iptal (attention, örneğin command timeout) ve KILL da CATCH'e düşmez; o durumda transaction'ın açık kalmamasını yalnız XACT_ABORT garanti eder.
XACT_ABORT, XACT_STATE ve THROW
Transaction açıkken bir hata olduğunda transaction'ın ne durumda kaldığı XACT_ABORT ayarına bağlı. İki UPDATE'ten ilki başarılı, ikincisi CK_books_list_price'ı ihlal ediyor:
SET XACT_ABORT OFF;
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.books SET list_price = list_price + 10 WHERE book_id = 26;
UPDATE dbo.books SET list_price = -1 WHERE book_id = 28;
COMMIT;
END TRY
BEGIN CATCH
SELECT 'xact_abort off' AS mode, XACT_STATE() AS xact_state, @@TRANCOUNT AS trancount, ERROR_NUMBER() AS err_no;
IF XACT_STATE() <> 0 ROLLBACK;
END CATCH;
GO
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRAN;
UPDATE dbo.books SET list_price = list_price + 10 WHERE book_id = 26;
UPDATE dbo.books SET list_price = -1 WHERE book_id = 28;
COMMIT;
END TRY
BEGIN CATCH
SELECT 'xact_abort on' AS mode, XACT_STATE() AS xact_state, @@TRANCOUNT AS trancount, ERROR_NUMBER() AS err_no;
IF XACT_STATE() <> 0 ROLLBACK;
END CATCH;
SELECT list_price FROM dbo.books WHERE book_id = 26;
mode xact_state trancount err_no
-------------- ---------- --------- ------
xact_abort off 1 1 547
(1 rows affected)
mode xact_state trancount err_no
------------- ---------- --------- ------
xact_abort on -1 1 547
(1 rows affected)
list_price
----------
390.50
XACT_STATE() üç değer döndürür: 0 açık transaction yok, 1 transaction açık ve commit edilebilir, -1 transaction açık ama commit edilemez ("doomed"; yalnız okuma ve ROLLBACK yapılabilir). XACT_ABORT OFF iken 547 hatasından sonra transaction hâlâ commit edilebilir durumdaydı: CATCH'te biri COMMIT yazsaydı Dune'a yapılan 10 TL zam kalıcı olur, Foundation'ınki olmazdı. Yarım iş tam olarak bu. XACT_ABORT ON iken aynı hata transaction'ı commit edilemez hâle getirdi ve tek doğru yol geri almak oldu; son satırda Dune'un fiyatı değişmemiş. Commit edilemez bir transaction'ı commit etmeye çalıştığımda "Msg 3930 ... The current transaction cannot be committed and cannot support operations that write to the log file. Roll back the transaction." hatasını aldım. Bu yüzden CATCH'teki kalıp hep aynı: IF XACT_STATE() <> 0 ROLLBACK; (ya da IF @@TRANCOUNT > 0 ROLLBACK;), ardından THROW;.
THROW'un bir tuzağı var: kendinden önceki statement noktalı virgülle bitmeli. Noktalı virgülü unutunca:
BEGIN TRAN;
BEGIN TRY
UPDATE dbo.books SET list_price = -1 WHERE book_id = 26;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK
THROW;
END CATCH;
Msg 102, Level 15, State 1, Line 7
Incorrect syntax near 'THROW'.
Parser ROLLBACK THROW'u tek statement olarak okumaya çalıştı ve batch hiç derlenmedi. T-SQL'de noktalı virgül çoğu yerde isteğe bağlı, ama THROW'dan, CTE'den (WITH ile tanımlanan, sorgu içinde isimli geçici sonuç kümesi) ve MERGE'ün sonundan önce zorunlu; hepsinin sonuna koymayı alışkanlık hâline getir. XACT_ABORT'un transaction log tarafındaki etkisi ve savepoint'ler SQL Server Derinlemesine serisinin transaction'ları ve transaction log'u anlatan yazısında; THROW ile RAISERROR farkı ve stored procedure iskeleti aynı serinin "Stored Procedure'ler: Parametreler, Hata Yönetimi, Yetki Zinciri ve Güvenli Dynamic SQL" yazısında. Kısaca: yeni kodda hatayı THROW ile ilet; RAISERROR'u ilerleme mesajları için tut.
Bookstore: Fiyat Güncellemesini Batch'lere Bölmek
Satın alma ekibi tedarikçinin yeni fiyat listesini bir staging table'a yüklüyor; script bu fiyatları books'a uyguluyor. Listeyi %8 zamla dolduralım ve bilerek bir hatalı row ekleyelim:
DROP TABLE IF EXISTS dbo.price_updates;
CREATE TABLE dbo.price_updates (
book_id INT NOT NULL CONSTRAINT PK_price_updates PRIMARY KEY,
new_price DECIMAL(10,2) NOT NULL
);
INSERT INTO dbo.price_updates (book_id, new_price)
SELECT book_id, ROUND(list_price * 1.08, 0) FROM dbo.books;
UPDATE dbo.price_updates SET new_price = -1 WHERE book_id = 50;
SELECT COUNT(*) AS staged, SUM(CASE WHEN new_price < 0 THEN 1 ELSE 0 END) AS invalid FROM dbo.price_updates;
staged invalid
------ -------
77 1
Milyonlarca row'luk bir katalogda bu güncellemeyi tek UPDATE ile yapmak transaction log'u şişirir ve uzun süre kilit tutar (15. yazıdaki DELETE TOP (5000) önerisi). Script'i id aralıklarına bölüyoruz; her parça kendi transaction'ında commit ediliyor, ilerleme anında yazılıyor, hata olursa o parça geri alınıp script hangi aralıkta durduğunu söyleyerek bitiyor. Gerçek bir katalogda parça büyüklüğü 5.000–10.000 olur; 77 kitapta birden çok parça görebilmek için 20 kullanıyoruz:
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @batch_size INT = 20, @batch_no INT = 0, @from_id INT, @to_id INT = 0, @rows INT, @msg NVARCHAR(2048);
WHILE 1 = 1
BEGIN
SET @from_id = @to_id + 1;
SELECT @to_id = MAX(book_id)
FROM (SELECT TOP (@batch_size) book_id FROM dbo.price_updates WHERE book_id >= @from_id ORDER BY book_id) AS next_batch;
IF @to_id IS NULL BREAK;
SET @batch_no += 1;
BEGIN TRY
BEGIN TRAN;
UPDATE b
SET list_price = p.new_price
FROM dbo.books AS b
JOIN dbo.price_updates AS p ON p.book_id = b.book_id
WHERE b.book_id BETWEEN @from_id AND @to_id
AND b.list_price <> p.new_price;
SET @rows = @@ROWCOUNT;
COMMIT;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK;
SET @msg = CONCAT(N'Batch ', @batch_no, N' (book_id ', @from_id, N'-', @to_id, N') failed: ', ERROR_MESSAGE());
THROW 50010, @msg, 1;
END CATCH;
RAISERROR(N'Batch %d: book_id %d-%d, %d rows updated', 0, 1, @batch_no, @from_id, @to_id, @rows) WITH NOWAIT;
END;
Batch 1: book_id 1-20, 20 rows updated
Batch 2: book_id 21-40, 20 rows updated
Msg 50010, Level 16, State 1, Line 25
Batch 3 (book_id 41-60) failed: The UPDATE statement conflicted with the CHECK constraint "CK_books_list_price". The conflict occurred in database "sqlseries_ext_24", table "dbo.books", column 'list_price'.
SELECT COUNT(*) AS updated FROM dbo.books AS b JOIN dbo.price_updates AS p ON p.book_id = b.book_id WHERE b.list_price = p.new_price;
updated
-------
40
Script'in her satırının bir gerekçesi var:
- Parçalar
OFFSETile değil, son işlenen id'den devam eden bir aralıkla (keyset) seçiliyor; böylece her parça index'te kaldığı yerden başlar ve öncekileri tekrar okumaz. SELECT @to_id = MAX(...)row bulamasa da bir değer döndürür: aggregate her zaman tek row verir ve sonuçNULLolur. Açılıştaki "row yoksa atama yapılmaz" tuzağı burada bu yüzden işlemiyor ve döngüBREAKile bitiyor.b.list_price <> p.new_pricescript'i tekrar çalıştırılabilir yapıyor: zaten güncellenmiş row'lara dokunmuyor.@@ROWCOUNThemenUPDATE'in ardından bir değişkene alınıyor (22. yazı).CATCHönce geri alıyor, sonra hatayı bağlamıyla birlikte yeni bir mesajla fırlatıyor. Orijinal hata numarası (547) kayboluyor, ama metni mesajın içinde; numaranın aynen gitmesi gerekiyorsa bağlamıPRINTile yazıp parametresizTHROW;kullan.- İlerleme
RAISERROR ... WITH NOWAITile yazılıyor.PRINTmesajları buffer'da birikir ve istemciye geç ulaşabilir;WITH NOWAITmesajı hemen gönderir, uzun bir script'in nerede olduğunu SSMS'in Messages sekmesinde canlı izlersin.
Sonuç: ilk iki parçanın 40 row'u commit edildi, üçüncü parça bütünüyle geri alındı. "Ya hep ya hiç" isteseydik bütün döngüyü tek transaction'a alırdık; parçalara bölmenin bedeli, bir hata sonrasında veritabanının kısmen güncellenmiş kalması. Script'in tekrar çalıştırılabilir olması bunu yönetilebilir yapıyor. Daha da iyisi, hatalı veriyi hiçbir parça başlamadan yakalamak:
IF EXISTS (SELECT 1 FROM dbo.price_updates WHERE new_price <= 0)
BEGIN
SELECT book_id, new_price FROM dbo.price_updates WHERE new_price <= 0;
THROW 50011, N'price_updates contains non-positive prices; nothing was applied.', 1;
END;
PRINT N'staging data is valid';
book_id new_price
------- ---------
50 -1.00
(1 rows affected)
Msg 50011, Level 16, State 1, Line 4
price_updates contains non-positive prices; nothing was applied.
sqlcmd Değişkenleri, :r ve :on error exit
Script'i tekrar kullanılabilir yapmanın son adımı onu dosyaya koyup parametreleştirmek. sqlcmd (ve SSMS'teki sqlcmd modu) iki nokta üst üste ile başlayan kendi komutlarını tanır: :setvar bir değişken tanımlar ve $(Ad) ile kullanılır, :r başka bir dosyayı o noktaya ekler, :on error exit ilk hatada script'i durdurur. Yukarıdaki döngüyü price-update.sql dosyasına kaydettim ve tek farkla, @batch_size INT = 20 yerine @batch_size INT = $(BatchSize) yazdım. Hatalı fiyatı düzeltip yeniden çalıştıran deploy dosyası:
:on error exit
:setvar BatchSize 20
UPDATE dbo.price_updates SET new_price = 345.00 WHERE book_id = 50;
GO
:r price-update.sql
GO
SELECT COUNT(*) AS updated FROM dbo.books AS b JOIN dbo.price_updates AS p ON p.book_id = b.book_id WHERE b.list_price = p.new_price;
(1 rows affected)
Batch 1: book_id 1-20, 0 rows updated
Batch 2: book_id 21-40, 0 rows updated
Batch 3: book_id 41-60, 20 rows updated
Batch 4: book_id 61-77, 17 rows updated
updated
-------
77
Komut sqlcmd -S ... -d BookstoreDb -i deploy-prices.sql. İlk iki parça 0 row güncelledi, çünkü işleri önceki çalıştırmada bitmişti; script kaldığı yerden devam etmiş oldu. Değişkenler sqlcmd'nin kendi metin değiştirmesi: sunucuya gitmeden önce $(BatchSize) yerine 20 yazılıyor. Bu yüzden T-SQL değişkenlerinin aksine batch sınırlarını aşarlar, ama kullanıcıdan gelen bir değeri bu yolla script'e koymak SQL injection'a kapı açar; yalnız güvendiğin, deploy'u yapan kişinin verdiği değerler için kullan. -v BatchSize=5000 ile komut satırından da verebilirsin.
:on error exit olmadan sqlcmd hata veren batch'ten sonraki batch'lere devam eder: yukarıdaki ilk denemede Msg 50010'dan sonraki SELECT çalışmış ve 40 sonucunu vermişti. Aynı deploy dosyasıyla bilerek hatalı bir fiyat gönderdiğimde ise sqlcmd üçüncü parçadaki hatadan sonra durdu, son SELECT çalışmadı ve process'in çıkış kodu 1 oldu. Deploy'u bir CI/CD pipeline'ında çalıştırıyorsan bu çıkış kodu pipeline'ı durduran şey; :on error exit ya da -b parametresini unutursan başarısız bir migration "başarılı" görünür.
PostgreSQL'de Hata ve Transaction
Aynı iki UPDATE'i kendi PostgreSQL 18 container'ımda bir transaction içinde çalıştırdım. İkinci UPDATE check constraint'i ihlal edince transaction'ın geri kalanında her komut "current transaction is aborted, commands ignored until end of transaction block" hatası verdi. PostgreSQL'de hata sonrası transaction'ın davranışı XACT_ABORT ON'a benziyor ve ayarla kapatılamıyor; tek çıkış ROLLBACK. TRY...CATCH'in karşılığı PostgreSQL'in prosedürel dilindeki (PL/pgSQL) bir bloğun EXCEPTION WHEN check_violation THEN kısmı; bu kısım çalıştığında bloğun yaptığı değişiklikler otomatik olarak geri alınıyor (Dune'un fiyatı 390,50'de kaldı), çünkü blok örtük bir savepoint açıyor. RAISE NOTICE ilerleme mesajı, RAISE EXCEPTION ise THROW karşılığı. psql'de :on error exit'in karşılığı \set ON_ERROR_STOP on; hatadan sonra psql 3 çıkış koduyla durdu.
Üretimde Dikkat
Elle ya da pipeline'dan çalıştırılan her veri değiştirme script'inin iskeleti aynı olsun: SET NOCOUNT ON, SET XACT_ABORT ON, önce doğrulama (IF EXISTS ... THROW), sonra iş; işin içinde BEGIN TRY/BEGIN TRAN, CATCH'te IF XACT_STATE() <> 0 ROLLBACK; ve THROW. Script sonunda @@TRANCOUNT 0 olmalı. Büyük değişiklikleri parçalara böl ve her parçayı tekrar çalıştırılabilir yaz; parça büyüklüğünü bir değişkende tut ki üretimde log büyümesine ve kilitlenmeye bakıp ayarlayabilesin.
İlerlemeyi RAISERROR ... WITH NOWAIT ile yaz ve mesajlarda aralık ve row sayısı olsun; gece yarısı yarıda kalan bir script'te "nerede kaldı" sorusunun cevabı bu satırlar. Pipeline'da sqlcmd'yi -b ya da :on error exit ile çalıştır. Uygulamaya GO'lu script gönderme; migration aracını kullan ya da script'i kendin böl.
Sık Düşülen Tuzaklar
SELECT @x = ...row döndürmediğinde değişkenin eski değerinde kalması; birden çok row'da da sırası belirsiz son değeri alması.CATCH'te transaction'ı açık bırakmak ya daXACT_ABORT OFFiken commit edilebilir kalan yarım transaction'ı commit etmek.THROW'dan önceki statement'ın sonuna noktalı virgül koymamak (Msg 102).- Olmayan bir table'a referansın aynı seviyedeki
CATCH'e düşeceğini sanmak (Msg 208 yakalanmaz). - Bir değişkenin
GO'dan sonra da yaşadığını sanmak (Msg 137). GO'lu script'i uygulama kodundan göndermek; ya syntax hatası alırsın ya daGOsessizce alias olur.- Set-based yazılabilecek bir işi cursor ile row row yapmak.
- sqlcmd'yi
:on error exit/-bolmadan pipeline'da çalıştırıp başarısız bir script'i başarılı saymak.
Küçük Bir Deneme
★☆☆ Fiyat script'ini PRINT ile ilerleme yazacak şekilde değiştir ve her parçadan sonra WAITFOR DELAY '00:00:02' ekle. SSMS'te Messages sekmesinde PRINT ile RAISERROR ... WITH NOWAIT mesajlarının ne zaman göründüğünü karşılaştır.
★★☆ Script'e bir log table'ı ekle: her parça için başlangıç ve bitiş id'si, güncellenen row sayısı ve zaman. Hata olduğunda CATCH'te log'a hata mesajını da yaz. Dikkat: log yazısını ROLLBACK'ten önce mi sonra mı yapman gerekiyor?
★★★ Script'i "ya hep ya hiç" moduna alan bir sqlcmd değişkeni ekle (:setvar AllOrNothing 1): 1 olduğunda bütün parçalar tek transaction'da çalışsın ve hata olunca hiçbir fiyat değişmesin. Bu modda log table'ına yazdığın satırlara ne oluyor?
Script'lerin bir türü daha var: şemayı değiştiren deploy script'leri. Bir sonraki ve serinin son yazısında IDENTITY ile SEQUENCE'ı, computed column'ları, CREATE OR ALTER, DROP ... IF EXISTS ve varlık kontrolleriyle iki kez çalıştırılınca hata vermeyen DDL script'lerini göreceğiz.