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

T-SQL Matematik, Sistem ve Metadata Function'ları: ROUND, NEWID, SCOPE_IDENTITY, @@ROWCOUNT, OBJECT_ID ve Catalog View'lar

Mert Özen 15 Kas 2026 16 dk 6 görüntülenme
T-SQL Matematik, Sistem ve Metadata Function'ları: ROUND, NEWID, SCOPE_IDENTITY, @@ROWCOUNT, OBJECT_ID ve Catalog View'lar

Yeni eklenen row'un id'si için SCOPE_IDENTITY ya da OUTPUT INTO kullanılır; trigger'lı bir table'da @@IDENTITY trigger'ın eklediği row'un id'sini döndürür ve INTO'suz OUTPUT Msg 334 verir. ROUND'un taşması, POWER(2, 0.5)'in 1 dönmesi, AVG(int), decimal precision kuralları, RAND ve ORDER BY NEWID(), @@ROWCOUNT'un sıfırlanması ve index'siz foreign key'leri bulan catalog view sorgusu 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: 9,99'u Bir Basamağa Yuvarlamak

DECIMAL(3,2) tipinde 9,99 TL'lik bir indirim tutarını tek ondalık basamağa yuvarlıyoruz. Sonuç 10,0 mı?

SELECT ROUND(CAST(9.99 AS DECIMAL(3,2)), 1) AS overflow;
Msg 8115, Level 16, State 2, Line 1
Arithmetic overflow error converting expression to data type numeric.

Hata. ROUND'un sonucu girdiyle aynı tipte, yani DECIMAL(3,2): virgülden önce tek basamak. 10,00 bu tipe sığmıyor. Fiyat column'ları genelde geniş tanımlandığı için bunu üretimde nadiren görürsün; ama dar bir ara hesapta, bir staging column'unda ya da bir CHECK'te karşına çıkabilir. Bu yazı T-SQL function kataloğunun son parçası: matematik function'ları ve tamsayı bölmesi, rastgele değer üretmek, yeni eklenen row'un id'sini doğru almak, @@ROWCOUNT gibi sistem function'ları ve şemayı sorguyla keşfetmek. Örnekler sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) veritabanında.

ROUND, CEILING, FLOOR ve Diğer Matematik Function'ları

SELECT ROUND(2.345, 2) AS r2, ROUND(2.345, 2, 1) AS truncated, ROUND(-2.5, 0) AS neg_half, ROUND(2.5, 0) AS pos_half,
       ROUND(1234.5, -2) AS to_hundreds, ROUND(CAST(2.5 AS FLOAT), 0) AS float_half;
r2     truncated  neg_half  pos_half  to_hundreds  float_half
-----  ---------  --------  --------  -----------  ----------
2.350  2.340      -3.0      3.0       1200.0       3.0

ROUND(x, n) yarımları sıfırdan uzağa yuvarlar: 2,5 → 3, -2,5 → -3. Üçüncü argüman 0 dışında bir değer olursa yuvarlamaz, keser: 2,345 → 2,340. Negatif n virgülün soluna yuvarlar; 1234,5'i yüzlere yuvarlamak 1200. Sonucun ölçeği (scale) değişmez: 2,345 yuvarlanınca 2,35 değil 2,350 görünüyor. PostgreSQL'de round(2.5::float8) 2 döndürüyor; float'ta yarımları çifte yuvarlıyor (banker's rounding, yani yarımı en yakın çift sayıya yuvarlama), numeric'te ise SQL Server gibi sıfırdan uzağa. Para hesabını iki sistemde de DECIMAL/numeric ile yapmanın bir sebebi daha.

SELECT CEILING(2.1) AS ceil_pos, CEILING(-2.1) AS ceil_neg, FLOOR(-2.1) AS floor_neg, ABS(-7) AS abs_val,
       POWER(2, 0.5) AS pow_int_base, POWER(2.0, 0.5) AS pow_dec_base, SQRT(2) AS sqrt_val, SIGN(-3.5) AS sign_val, 17 % 5 AS modulo;
ceil_pos  ceil_neg  floor_neg  abs_val  pow_int_base  pow_dec_base  sqrt_val            sign_val  modulo
--------  --------  ---------  -------  ------------  ------------  ------------------  --------  ------
3         -2        -3         7        1             1.4           1.4142135623730951  -1.0      2

CEILING yukarı, FLOOR aşağı yuvarlar; negatif sayılarda "yukarı" sıfıra doğru demek: CEILING(-2.1) -2. "Kaç koli gerekir" hesabı (CEILING(adet / 1.0 / koli_kapasitesi)) tipik kullanımı. POWER'ın tuzağı sonuç tipinin birinci argümandan gelmesi: POWER(2, 0.5) 1 döndürdü, çünkü 2 bir INT ve √2 = 1,414 INT'e kesildi. POWER(2.0, 0.5) ise 1,4 döndürdü, çünkü 2.0 DECIMAL(2,1): tek ondalık basamak. Doğru sonucu istiyorsan tabanı FLOAT ya da yeterince geniş bir DECIMAL yap. SQRT, LOG, EXP gibi function'lar her zaman FLOAT döndürür.

Tamsayı Bölmesi ve Decimal Precision

SELECT 7 / 2 AS int_div, 7 / 2.0 AS dec_div, 7 % 2 AS remainder, CAST(7 AS DECIMAL(10,2)) / 3 AS dec_by_int;
int_div  dec_div   remainder  dec_by_int
-------  --------  ---------  ----------
3        3.500000  1          2.333333

İki INT'in bölümü INT'tir ve kesilir: 7 / 2 = 3. Bookstore'da en sık rastlanan hali yorum puanının ortalaması, çünkü reviews.rating bir TINYINT ve AVG girdinin tipinde sonuç döndürür:

SELECT AVG(rating) AS avg_int, AVG(CAST(rating AS DECIMAL(5,2))) AS avg_dec, AVG(rating * 1.0) AS avg_times_one
FROM dbo.reviews
WHERE book_id = 26;
avg_int  avg_dec   avg_times_one
-------  --------  -------------
4        4.400000  4.400000

Dune'un ortalaması 4,4 ama AVG(rating) 4 dedi; kitap kartında "4 yıldız" ile "4,4 yıldız" arasındaki fark bu. PostgreSQL'de avg tamsayı girdide numeric döndürür, bu tuzak orada yok. DECIMAL işlemlerinde ise sonucun precision ve scale'ini SQL Server kurallarla hesaplar:

DECLARE @a DECIMAL(10,2) = 1234.56, @b DECIMAL(10,2) = 3;
SELECT @a * @b AS product, @a / @b AS quotient,
       SQL_VARIANT_PROPERTY(@a * @b, 'Precision') AS p_prec, SQL_VARIANT_PROPERTY(@a * @b, 'Scale') AS p_scale,
       SQL_VARIANT_PROPERTY(@a / @b, 'Precision') AS q_prec, SQL_VARIANT_PROPERTY(@a / @b, 'Scale') AS q_scale;
product    quotient           p_prec  p_scale  q_prec  q_scale
---------  -----------------  ------  -------  ------  -------
3703.6800  411.5200000000000  21      4        23      13

Çarpımda precision'lar ve scale'ler toplanır: DECIMAL(10,2) * DECIMAL(10,2) → DECIMAL(21,4). Bölmede scale büyür: DECIMAL(23,13). Sonuç 38 basamağı aşacaksa SQL Server scale'den keser ve en az 6 basamak bırakır:

DECLARE @x DECIMAL(38,10) = 1, @y DECIMAL(38,10) = 3;
SELECT @x / @y AS big_div, SQL_VARIANT_PROPERTY(@x / @y, 'Scale') AS scale;
big_div  scale
-------  -----
.333333  6

10 ondalık basamaklı iki sayının bölümü 6 basamakta kaldı. Döviz kuru ya da birim fiyat gibi hassas hesaplarda "ne kadar geniş o kadar iyi" diye her şeyi DECIMAL(38, x) yapmak bu yüzden ters teper; ihtiyacın kadar precision seç ve ara sonuçları açıkça CAST et.

RAND, NEWID ve Rastgele Seçim

RAND() sorgu başına bir kez hesaplanır; bütün row'lar aynı değeri alır. Row başına rastgele sayı için her row'da değişen NEWID()'den bir seed türetmek gerekir:

SELECT TOP (3) book_id, RAND() AS rand_value, RAND(CHECKSUM(NEWID())) AS rand_per_row
FROM dbo.books
ORDER BY book_id;
book_id  rand_value           rand_per_row
-------  -------------------  -------------------
1        0.57200777273679648  0.70284360746822694
2        0.57200777273679648  0.33864401226013674
3        0.57200777273679648  0.77666590896452226

Aynı seed aynı değeri verir: RAND(42) iki kez çağrıldığında iki kez 0,71435594503450972 döndürdü. Testlerde tekrarlanabilir veri üretmek için kullanışlı.

"Bir rastgele kitap" için en çok önerilen yol ORDER BY NEWID(). Plan ne yaptığını gösteriyor:

SET SHOWPLAN_TEXT ON;
GO
SELECT TOP (1) book_id, title FROM dbo.books WHERE is_active = 1 ORDER BY NEWID();
GO
SET SHOWPLAN_TEXT OFF;
  |--Sort(TOP 1, ORDER BY:([Expr1002] ASC))
       |--Compute Scalar(DEFINE:([Expr1002]=newid()))
            |--Clustered Index Scan(OBJECT:([sqlseries_ext_22].[dbo].[books].[PK_books]), WHERE:([sqlseries_ext_22].[dbo].[books].[is_active]=(1)))

Bütün table taranıyor, her row için bir GUID (globally unique identifier; her üretildiğinde farklı olan 128 bit'lik kimlik değeri) üretiliyor ve sıralanıyor; tek bir row için. 77 kitapta sorun değil, birkaç milyon row'da her çağrıda tam tarama demek. Bookstore'un ana sayfasındaki "günün kitabı" için ayrıca bir iş kuralı var: gün boyunca aynı kitap görünmeli. Rastgelelik yerine tarihten deterministik bir sıra numarası türetiyoruz:

DECLARE @day DATE = '2026-06-20';
SELECT b.book_id, b.title
FROM dbo.books AS b
WHERE b.is_active = 1
ORDER BY b.book_id
OFFSET ABS(CHECKSUM(@day)) % (SELECT COUNT(*) FROM dbo.books WHERE is_active = 1) ROWS FETCH NEXT 1 ROWS ONLY;
book_id  title
-------  ---------
69       Peter Pan

20 Haziran'da Peter Pan; aynı sorgu '2026-06-21' ile Charlotte's Web'i (70) döndürdü. Sonuç gün boyunca sabit, cache'lenebilir ve sorgu bütün table'ı sıralamıyor. OFFSET büyük tablolarda yine row'ları atlayarak ilerler; çok büyük bir katalogda id aralığından rastgele bir değer seçip WHERE book_id >= @x ile ilk row'u almak daha ucuz, ama id'lerdeki boşluklar dağılımı bozar.

GUID üretmenin iki yolu var. NEWID() tamamen rastgele bir GUID üretir. NEWSEQUENTIALID() ise aynı makinede artan değerler üretir ve yalnız bir column'un DEFAULT'unda kullanılabilir:

DROP TABLE IF EXISTS dbo.guid_test;
CREATE TABLE dbo.guid_test (
    id_random     UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
    id_sequential UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID(),
    n INT NOT NULL
);
INSERT INTO dbo.guid_test (n) VALUES (1), (2), (3);
SELECT n, LEFT(CAST(id_random AS CHAR(36)), 18) AS random_prefix, LEFT(CAST(id_sequential AS CHAR(36)), 18) AS sequential_prefix
FROM dbo.guid_test
ORDER BY n;
n  random_prefix       sequential_prefix
-  ------------------  ------------------
1  18FFEA6F-2B21-4400  A7554E3E-F36B-1410
2  058C7260-5D93-48C9  A8554E3E-F36B-1410
3  AACBA6DD-1960-4374  A9554E3E-F36B-1410

Rastgele GUID'lerin arasında bir düzen yok; sıralı olanlar ilk byte'ta birer birer artıyor (A7, A8, A9). Clustered index'in anahtarı GUID olacaksa bu fark page split (index sayfasının dolup ikiye bölünmesi) ve parçalanma açısından önemli; ayrıntısı SQL Server Derinlemesine serisinde. Microsoft Learn iki uyarı yapıyor: sıradaki değer tahmin edilebildiği için gizlilik önemliyse (ör. URL'de görünen bir id) NEWSEQUENTIALID kullanılmamalı, ve Windows yeniden başladıktan sonra değerler daha düşük bir aralıktan başlayabilir. Function'ı sorguda doğrudan çağırmaya çalışırsan "The newsequentialid() built-in function can only be used in a DEFAULT expression ..." (Msg 302) hatası alırsın. PostgreSQL 18 bu iş için uuidv7()'yi getirdi: zaman damgasıyla başlayan, sıralanabilir bir UUID (GUID'in standartlardaki adı); uuid_extract_version(uuidv7()) 7 döndürüyor.

SCOPE_IDENTITY, @@IDENTITY, IDENT_CURRENT ve OUTPUT

15. yazıda yeni eklenen row'ların id'lerini OUTPUT ile almıştık. Tek row eklerken çok kullanılan üç function daha var ve farkları bir trigger ekleyince ortaya çıkıyor. orders'a her insert'te bir audit kaydı yazan bir trigger kuralım; audit table'ının identity'si 5000'den başlıyor:

DROP TABLE IF EXISTS dbo.order_audit;
CREATE TABLE dbo.order_audit (
    audit_id   INT IDENTITY(5000, 1) PRIMARY KEY,
    order_id   INT          NOT NULL,
    action     VARCHAR(10)  NOT NULL,
    audited_at DATETIME2(0) NOT NULL DEFAULT SYSUTCDATETIME()
);
GO
CREATE OR ALTER TRIGGER dbo.tr_orders_audit ON dbo.orders AFTER INSERT AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.order_audit (order_id, action) SELECT order_id, 'insert' FROM inserted;
END;
GO
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:00', 'Pending');
SELECT SCOPE_IDENTITY() AS scope_identity, @@IDENTITY AS at_at_identity, IDENT_CURRENT('dbo.orders') AS ident_current_orders;
ROLLBACK;
scope_identity  at_at_identity  ident_current_orders
--------------  --------------  --------------------
380             5000            380

SCOPE_IDENTITY() aynı scope'taki (aynı batch, procedure ya da trigger) son identity değerini verir: siparişin id'si, 380. @@IDENTITY oturumdaki son identity değerini scope'a bakmadan verir: trigger'ın audit table'ına yazdığı 5000. Trigger'ı eklemeden önce iki function aynı değeri döndürüyordu; yani @@IDENTITY kullanan kod, biri tabloya trigger eklediği gün sessizce yanlış id döndürmeye başlar. IDENT_CURRENT('dbo.orders') ise oturumdan ve scope'tan bağımsız, table'ın son ürettiği değeri verir; aynı anda başka bir oturum da sipariş ekliyorsa onun id'sini görebilirsin. Uygulama kodunda tek row için doğru seçim SCOPE_IDENTITY(), birden çok row için OUTPUT.

OUTPUT'un da trigger'la ilgili bir kısıtı var. İstemciye doğrudan döndürülen OUTPUT trigger'lı bir table'da çalışmaz:

BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status)
OUTPUT inserted.order_id, inserted.status
VALUES (1, '2026-06-30 10:00', 'Pending'), (3, '2026-06-30 10:05', 'Pending');
ROLLBACK;
Msg 334, Level 16, State 1, Line 2
The target table 'dbo.orders' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause.

Çözüm OUTPUT ... INTO ile sonucu bir table variable'a yazıp oradan okumak:

DECLARE @new TABLE (order_id INT, customer_id INT);
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status)
OUTPUT inserted.order_id, inserted.customer_id INTO @new (order_id, customer_id)
VALUES (1, '2026-06-30 10:00', 'Pending'), (3, '2026-06-30 10:05', 'Pending');
SELECT order_id, customer_id FROM @new ORDER BY order_id;
SELECT @@TRANCOUNT AS open_transactions;
ROLLBACK;
SELECT @@TRANCOUNT AS after_rollback, IDENT_CURRENT('dbo.orders') AS ident_after_rollback;
order_id  customer_id
--------  -----------
381       1
382       3

(2 rows affected)
open_transactions
-----------------
1

(1 rows affected)
after_rollback  ident_after_rollback
--------------  --------------------
0               382

Entity Framework Core (EF Core) serisinin EF Core 7 yeniliklerini anlatan yazısında gördüğümüz HasTrigger ayarının sebebi bu hata: EF Core kaydederken OUTPUT kullanıyor ve trigger'lı table'ı ona bildirmen gerekiyor. Son satıra da dikkat: iki transaction'ı da geri aldık, ama IDENT_CURRENT 382'de kaldı. Identity değerleri rollback ile geri verilmez; sonraki sipariş 383 alacak ve 380–382 hiç kullanılmamış olacak. Identity'deki boşlukların normal olması ve SEQUENCE ile farkı bu serinin ilerideki günlük DDL (Data Definition Language; şemayı tanımlayan CREATE, ALTER, DROP komutları) yazısının konusu.

@@ROWCOUNT, @@TRANCOUNT, ERROR_ Function'ları ve Oturum Bilgisi

@@ROWCOUNT son statement'ın etkilediği row sayısını verir; "son statement" kelimesi önemli:

UPDATE dbo.books SET list_price = list_price WHERE category_id = 4;
IF @@ROWCOUNT = 0 PRINT 'nothing updated';
SELECT @@ROWCOUNT AS rowcount_after_if;
(4 rows affected)
rowcount_after_if
-----------------
0

UPDATE 4 row etkiledi, IF bunu doğru okudu, ama IF'in kendisi de bir statement ve @@ROWCOUNT'u sıfırladı. PRINT da aynı şeyi yapar: PRINT'ten sonra okunan @@ROWCOUNT 0 çıktı. Değeri birden fazla yerde kullanacaksan statement'ın hemen ardından bir değişkene al: DECLARE @updated INT = @@ROWCOUNT; 4 değerini korudu. 15. yazıdaki "önce SELECT, sonra @@ROWCOUNT kontrolü" pattern'i bu kurala dayanıyor. @@TRANCOUNT ise açık transaction sayısını verir; yukarıda BEGIN TRAN'dan sonra 1, ROLLBACK'ten sonra 0. Bir script'in sonunda @@TRANCOUNT 0 değilse bir transaction açık kalmış demektir.

ERROR_ ailesi yalnız CATCH bloğunun içinde değer döndürür:

BEGIN TRY
    SELECT 1 / 0;
END TRY
BEGIN CATCH
    SELECT ERROR_NUMBER() AS err_no, ERROR_SEVERITY() AS severity, ERROR_LINE() AS err_line, ERROR_MESSAGE() AS err_message;
END CATCH;
SELECT ERROR_NUMBER() AS outside_catch;
err_no  severity  err_line  err_message
------  --------  --------  ---------------------------------
8134    16        2         Divide by zero error encountered.

(1 rows affected)
outside_catch
-------------
NULL

TRY...CATCH, THROW ve transaction'lı hata yönetimini serinin ilerideki script yazma yazısında ayrıntılı göreceğiz. Oturumun kim ve nerede olduğunu soran function'lar da log ve audit kayıtlarında işe yarar:

SELECT DB_NAME() AS db_name, SUSER_SNAME() AS login_name, USER_NAME() AS user_name, APP_NAME() AS app_name;
db_name           login_name  user_name  app_name
----------------  ----------  ---------  --------
sqlseries_ext_22  sa          dbo        SQLCMD

SUSER_SNAME() login'i, USER_NAME() veritabanındaki kullanıcıyı verir; sa login'i her veritabanında dbo'dur. APP_NAME() connection string'deki Application Name'den gelir; uygulamanın connection string'ine "Bookstore.Api" gibi bir ad koyarsan yavaş sorguları ve kilitlenmeleri hangi uygulamanın yaptığını DMV'lerden (dynamic management view; sunucunun anlık durumunu gösteren sistem view'ları) okuyabilirsin. Bu değer istemcinin beyanıdır; güvenlik kararı için kullanma.

Metadata: OBJECT_ID, COL_LENGTH, Catalog View'lar ve INFORMATION_SCHEMA

Şemayı tanımanın en hızlı yolu onu sorgulamak. OBJECT_ID bir nesnenin id'sini, yoksa NULL döndürür ve ikinci argümanla tip kontrolü yapar; COL_LENGTH bir column'un byte cinsinden uzunluğunu verir:

SELECT OBJECT_NAME(OBJECT_ID('dbo.books', 'U')) AS as_table, OBJECT_ID('dbo.books', 'V') AS as_view,
       OBJECT_ID('dbo.no_such_table') AS missing, COL_LENGTH('dbo.books', 'title') AS title_bytes,
       COL_LENGTH('dbo.books', 'isbn') AS isbn_bytes, COL_LENGTH('dbo.books', 'no_such_column') AS missing_column;
as_table  as_view  missing  title_bytes  isbn_bytes  missing_column
--------  -------  -------  -----------  ----------  --------------
books     NULL     NULL     400          13          NULL

title bir NVARCHAR(200); COL_LENGTH karakter değil byte saydığı için 400. IF OBJECT_ID('dbo.order_audit', 'U') IS NULL ve IF COL_LENGTH('dbo.books', 'subtitle') IS NULL tekrar çalıştırılabilir DDL script'lerinin temel kontrolleri; o konu da ilerideki DDL yazısında.

Ayrıntı için iki kaynak var: SQL Server'a özgü catalog view'lar (sys.tables, sys.columns, sys.indexes, sys.foreign_keys…) ve standarttaki INFORMATION_SCHEMA view'ları. books'un üç column'unu INFORMATION_SCHEMA'dan okuyalım:

SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'books' AND COLUMN_NAME IN ('title', 'isbn', 'list_price')
ORDER BY ORDINAL_POSITION;
TABLE_NAME  COLUMN_NAME  DATA_TYPE  CHARACTER_MAXIMUM_LENGTH  IS_NULLABLE
----------  -----------  ---------  ------------------------  -----------
books       isbn         char       13                        NO
books       title        nvarchar   200                       NO
books       list_price   decimal    NULL                      NO

INFORMATION_SCHEMA uzunluğu karakter olarak (200), sys.columns.max_length ise byte olarak (400) veriyor. INFORMATION_SCHEMA PostgreSQL ve MySQL'de de var ve taşınabilir script'ler için iyi bir başlangıç; ama index, partition, compression gibi ürüne özgü bilgileri içermez. Onlar için catalog view'lar gerekir. order_items'ın index'leri ve key column'ları:

SELECT i.name AS index_name, i.type_desc, i.is_unique, i.is_primary_key,
       STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) AS key_columns
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.object_id = OBJECT_ID('dbo.order_items') AND ic.is_included_column = 0
GROUP BY i.name, i.type_desc, i.is_unique, i.is_primary_key
ORDER BY i.name;
index_name                 type_desc     is_unique  is_primary_key  key_columns
-------------------------  ------------  ---------  --------------  -----------------
PK_order_items             CLUSTERED     1          1               order_item_id
UQ_order_items_order_book  NONCLUSTERED  1          0               order_id, book_id

sp_help 'dbo.books' aynı bilgilerin bir kısmını tek komutta, ama sekiz ayrı result set olarak döndürür; elle bakmak için pratik, script'te işlemek için değil.

Bookstore için işe yarar bir sorgu: index'i olmayan foreign key'ler. SQL Server primary key ve unique constraint için otomatik index oluşturur, foreign key için oluşturmaz. Index'siz bir foreign key'de parent row silinirken ya da join yapılırken child table baştan sona taranır:

SELECT OBJECT_NAME(fk.parent_object_id) AS table_name, fk.name AS fk_name, c.name AS fk_column,
       OBJECT_NAME(fk.referenced_object_id) AS referenced_table
FROM sys.foreign_keys AS fk
JOIN sys.foreign_key_columns AS fkc ON fkc.constraint_object_id = fk.object_id
JOIN sys.columns AS c ON c.object_id = fkc.parent_object_id AND c.column_id = fkc.parent_column_id
WHERE NOT EXISTS (
    SELECT 1
    FROM sys.index_columns AS ic
    WHERE ic.object_id = fkc.parent_object_id
      AND ic.column_id = fkc.parent_column_id
      AND ic.key_ordinal = 1
)
ORDER BY table_name, fk_name;
table_name    fk_name                  fk_column           referenced_table
------------  -----------------------  ------------------  ----------------
book_authors  FK_book_authors_authors  author_id           authors
books         FK_books_categories      category_id         categories
categories    FK_categories_parent     parent_category_id  categories
order_items   FK_order_items_books     book_id             books
orders        FK_orders_customers      customer_id         customers
payments      FK_payments_orders       order_id            orders
reviews       FK_reviews_customers     customer_id         customers

Veritabanındaki 11 foreign key'den 7'sinin column'u hiçbir index'in ilk key column'u değil. order_items.order_id listede yok, çünkü UQ_order_items_order_book index'i onunla başlıyor; book_authors.book_id de primary key'in ilk column'u olduğu için yok. Sorgu tek column'lu foreign key'ler için yazıldı; çok column'lu foreign key'lerde column'ların sırasını da karşılaştırmak gerekir. Hangi index'lerin gerçekten eklenmesi gerektiği bir tasarım kararı ve SQL Server Derinlemesine serisinin index yazılarında ele alınıyor; bu sorgu yalnız aday listesini çıkarıyor.

Function Özeti ve PostgreSQL Karşılıkları

PostgreSQL sütunundaki ifadeleri kendi PostgreSQL 18 container'ımda çalıştırdım:

T-SQLNULL ve sınır davranışıPostgreSQL
ROUND(x, n [, 1])Tip girdinin; sığmazsa Msg 8115round, trunc; float'ta çifte yuvarlar
POWER(x, y)Tip x'in; INT tabanda kesilirpower, numeric ya da float
INT / INT, AVG(int)Tamsayı sonucu/ tamsayı; avg numeric döndürür
RAND()Sorgu başına tek değerrandom(), row başına
NEWID(), NEWSEQUENTIALID()Sıralı olan yalnız DEFAULT'tagen_random_uuid(), uuidv7() (18)
SCOPE_IDENTITY()Scope'ta insert yoksa NULLRETURNING, lastval()
@@ROWCOUNTHer statement'ta sıfırlanırGET DIAGNOSTICS ... ROW_COUNT (PL/pgSQL)
OBJECT_ID(name, type)Yoksa NULLto_regclass(name)
sys.*, INFORMATION_SCHEMAUzunluk byte / karakter farkıpg_catalog, information_schema

Üretimde Dikkat

Para hesaplarında tip seçimi function seçiminden önce gelir: DECIMAL kullan, INT bölmesini ve AVG(int)'i tanı, ara sonuçların precision'ını SQL_VARIANT_PROPERTY ile kontrol et. Yuvarlamayı tek bir yerde ve iş kuralına göre yap; satır bazında yuvarlayıp toplamak ile toplayıp yuvarlamak farklı sonuç verir ve faturada kuruş farkı olarak görünür.

Yeni id'yi her zaman SCOPE_IDENTITY() ya da OUTPUT ... INTO ile al. @@IDENTITY ve IDENT_CURRENT bugün doğru çalışsa da yarın eklenecek bir trigger ya da eş zamanlı bir oturumla yanlış değer döndürür. Rastgele seçimi büyük table'larda ORDER BY NEWID() ile yapma; deterministik bir seçim kuralı ya da önceden hesaplanmış bir aday listesi daha ucuz. Uygulamanın connection string'ine bir Application Name koy; sorun anında hangi uygulamanın hangi oturumu açtığını görmek zaman kazandırır.

Sık Düşülen Tuzaklar

  • Trigger'lı bir table'da @@IDENTITY ile yeni id'yi almak; trigger'ın eklediği row'un id'si gelir.
  • Trigger'lı table'da INTO'suz OUTPUT kullanmak (Msg 334).
  • @@ROWCOUNT'u IF, PRINT ya da başka bir statement'tan sonra okumak; değeri hemen bir değişkene al.
  • AVG(rating) ve 7 / 2 gibi tamsayı işlemlerinde kesilen ondalığı fark etmemek; POWER(2, 0.5)'in 1 döndürmesi.
  • ROUND'un sonucunun girdinin tipinde kalması ve sınırda taşması (Msg 8115).
  • Büyük bir table'da ORDER BY NEWID(); tek row için bütün table taranır ve sıralanır.
  • Row başına rastgele değer için RAND() kullanmak; sorgu başına bir kez hesaplanır.

Küçük Bir Deneme

★☆☆ Her kitabın ortalama puanını hem AVG(rating) hem AVG(rating * 1.0) ile hesapla ve tam sayıya yuvarlandığında iki sonucun farklı yıldız sayısı gösterdiği kitapları listele.

★★☆ customers table'ına bir audit trigger'ı ekle ve yeni bir müşteri ekleyen bir script'te SCOPE_IDENTITY(), @@IDENTITY ve OUTPUT ... INTO ile alınan id'leri karşılaştır. Script'i transaction içinde çalıştırıp sonunda geri al ve IDENT_CURRENT'in ne gösterdiğine bak.

★★★ Index'siz foreign key sorgusunu çok column'lu foreign key'leri de doğru değerlendirecek şekilde genişlet: foreign key'in bütün column'ları, bir index'in ilk n key column'unda aynı sırayla bulunmalı. Test için iki column'lu bir foreign key'i olan küçük bir table çifti oluştur.

Bu yazıyla T-SQL'in günlük function kataloğunu tamamladık: string, tarih ve saat, dönüşüm ve NULL, matematik, sistem ve metadata. Bir sonraki yazıda bu function'ların JSON'la buluştuğu yere geçiyoruz: FOR JSON ile sorgu sonucunu JSON'a çevirmek, OPENJSON ile JSON'u row'lara açmak, JSON_VALUE, JSON_QUERY ve JSON_MODIFY.