SELECT, WHERE ve ORDER BY: Mantıksal Sorgu İşleme Sırası, TOP ve OFFSET/FETCH
SQL sorguyu FROM, WHERE, GROUP BY, HAVING, SELECT, DISTINCT, ORDER BY, TOP sırasıyla değerlendirir; bu yüzden SELECT'teki alias WHERE'de görünmez ama ORDER BY'da görünür. SELECT * ve DISTINCT'in bedelini, ORDER BY'sız sıranın garanti olmadığını, TOP WITH TIES'ı, OFFSET/FETCH ile sayfalamada tie-breaker olmadan bir index eklemenin bile sayfayı değiştirdiğini ve keyset pagination'ı gösteriyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, PostgreSQL karşılaştırmaları PostgreSQL 18.6 üzerinde çalıştırılmıştır.
Önce Tahmin Et: Alias'ı WHERE'de Kullanabilir misin?
Kataloğa yüzde 10 vergi eklenmiş fiyatı hesaplayan bir column koyuyoruz, ona bir isim veriyoruz ve vergili fiyatı 1000 TL'yi geçen kitapları istiyoruz. C#'ta bir değişkeni tanımladıktan sonra aşağıdaki satırlarda kullanabilirsin; SQL'de de öyle mi?
SELECT title, list_price, list_price * 1.10 AS price_with_tax
FROM dbo.books
WHERE price_with_tax > 1000
ORDER BY price_with_tax DESC;
Msg 207, Level 16, State 1, Server b8be943222fa, Line 1
Invalid column name 'price_with_tax'.
Hata, WHERE'deki alias'tan. Daha ilginci, aynı alias'ı kullanan ORDER BY hata vermiyor: WHERE'deki ifadeyi list_price * 1.10 > 1000 diye açık yazınca sorgu çalışıyor ve ORDER BY price_with_tax DESC sorunsuz sıralıyor:
SELECT title, list_price, list_price * 1.10 AS price_with_tax
FROM dbo.books
WHERE list_price * 1.10 > 1000
ORDER BY price_with_tax DESC;
title list_price price_with_tax
------------------------------------------------- ---------- --------------
Design Patterns 1450.00 1595.0000
The Art of Computer Programming 1450.00 1595.0000
Structure and Interpretation of Computer Programs 1250.00 1375.0000
The Algorithm Design Manual 1150.00 1265.0000
Code Complete 1150.00 1265.0000
The Pragmatic Programmer 1150.00 1265.0000
Refactoring 1150.00 1265.0000
Domain-Driven Design 950.00 1045.0000
The C Programming Language 950.00 1045.0000
(9 rows affected)
Alias SELECT'te tanımlanıyor, WHERE'de görünmüyor, ORDER BY'da görünüyor. Bu bir tutarsızlık değil, SQL'in mantıksal sorgu işleme sırasının doğal sonucu. Bu yazıda o sırayı, SELECT, WHERE, DISTINCT ve ORDER BY'ı, ardından da serinin ilk gerçek uygulama sorgusunu yazacağız: "en yeni 20 kitap, 3. sayfa".
Mantıksal Sorgu İşleme Sırası
Bir SELECT cümlesini SELECT kelimesiyle yazmaya başlarsın, ama SQL Server onu o sırayla değerlendirmez. Microsoft'un SELECT dokümanı mantıksal işleme sırasını şöyle veriyor: FROM, ON, JOIN, WHERE, GROUP BY, WITH CUBE/WITH ROLLUP, HAVING, SELECT, DISTINCT, ORDER BY, TOP. Standart OFFSET/FETCH de ORDER BY'ın parçası olarak en sonda yer alır.

Her adım bir öncekinin ürettiği sonuç üzerinde çalışır ve yalnız o ana kadar tanımlanmış isimleri görür. SELECT 5. adımda; alias'lar orada doğuyor. WHERE 2. adımda çalıştığı için price_with_tax diye bir şey henüz yok. ORDER BY 7. adımda, alias'ı görüyor. Aynı kural HAVING için de geçerli:
SELECT category_id, COUNT(*) AS book_count
FROM dbo.books
GROUP BY category_id
HAVING book_count >= 5;
Msg 207, Level 16, State 1, Server b8be943222fa, Line 4
Invalid column name 'book_count'.
Doğrusu HAVING COUNT(*) >= 5. Bu kural bir T-SQL tuhaflığı değil; PostgreSQL de SELECT 1 * 2 AS x WHERE x > 1 için column "x" does not exist diyor. Bu sıra mantıksal bir modeldir: sonucu tanımlar, motorun fiziksel olarak hangi işi önce yaptığını değil. Optimizer bir filtreyi join'den önce uygulayabilir ya da sıralamayı bir index'ten hazır alabilir; ama sonuç her zaman bu sırayla değerlendirilmiş gibi olmak zorunda. Window function'ları neden WHERE'de kullanamadığını da 13. yazıda yine bu sırayla açıklayacağız.
SELECT: Column Listesi, SELECT * ve DISTINCT
SELECT * hızlı keşif için kullanışlı, uygulama kodunda ise risk:
SELECT TOP (1) * FROM dbo.books;
book_id isbn title subtitle category_id language_code published_year page_count list_price listed_on is_active
------- ------------- ------------------- -------- ----------- ------------- -------------- ---------- ---------- ---------- ---------
1 9798900000015 Pride and Prejudice NULL 2 en 1813 356 230.90 2024-06-01 1
(1 rows affected)
On bir column geldi. Liste ekranı bunların üçünü kullanıyorsa diğer sekizi ağdan boşuna geçiyor. Daha önemlisi, table'a yarın bir column eklendiğinde ya da column'ların sırası değiştiğinde SELECT *'ın sonucu da değişir; column'ları sıra numarasıyla okuyan kod sessizce yanlış veriyi okur. Bir de index açısı var: yalnız ihtiyacın olan column'ları istediğinde, optimizer bunları kapsayan dar bir index'ten okuyabilir. Bu yüzden uygulama sorgularında column listesini açıkça yaz. Entity Framework Core (EF Core) kullanıyorsan aynı işi projection yapar; EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısında görmüştük.
DISTINCT, sonuçtaki tekrar eden row'ları atar. Müşterilerimiz hangi ülkelerden?
SELECT DISTINCT country_code
FROM dbo.customers
ORDER BY country_code;
country_code
------------
DE
GB
NL
TR
(4 rows affected)
DISTINCT bedava değil: tekrarları bulmak için sunucu ya sonucu sıralar ya da bir hash table kurar, ikisi de bellek ve CPU (Central Processing Unit) ister. "Join'den sonra row'lar çoğaldı, DISTINCT ekleyip düzelttim" diye yazılan sorgu genelde yanlış bir join'in üstünü örter; bunun gerçek sebebini 9. yazıda fan-out olarak göreceğiz. DISTINCT'in mantıksal sıradaki yeri bir kural daha doğuruyor: ORDER BY yalnız SELECT listesindeki ifadelere göre sıralayabilir, çünkü tekrarlar atıldıktan sonra listede olmayan bir column'un hangi row'dan geleceği belli değil:
SELECT DISTINCT country_code
FROM dbo.customers
ORDER BY created_at;
Msg 145, Level 15, State 1, Server b8be943222fa, Line 1
ORDER BY items must appear in the select list if SELECT DISTINCT is specified.
WHERE ile Filtrelemek
WHERE her row için bir koşul değerlendirir ve yalnız sonucu doğru olan row'ları bir sonraki adıma geçirir. Programming kategorisinde, satışta olan ve 1000 TL'den ucuz kitaplar:
SELECT book_id, title, list_price
FROM dbo.books
WHERE category_id = 11
AND is_active = 1
AND list_price < 1000
ORDER BY list_price, book_id;
book_id title list_price
------- -------------------------- ----------
58 Clean Code 890.00
62 The Mythical Man-Month 890.00
56 The C Programming Language 950.00
63 Domain-Driven Design 950.00
(4 rows affected)
AND, OR'dan önce değerlendirilir; ikisini karıştırdığın her koşulda parantezi açıkça yaz. WHERE a = 1 OR b = 2 AND c = 3 aslında a = 1 OR (b = 2 AND c = 3) demek ve bu, "a ya da b olsun, ama c şart" diyen birinin kastettiği şey değil. "Sonucu doğru olan" ifadesinin altını çizdim, çünkü üçüncü bir ihtimal var: NULL ile yapılan karşılaştırmalar "bilinmiyor" döndürür ve WHERE onları da eler. Üç değerli mantık bir sonraki yazının konusu.
ORDER BY, TOP ve WITH TIES
SQL'in belki en önemli kuralı: ORDER BY yazmadığın sürece sonucun sırası garanti değildir. ORDER BY'sız bir TOP çalışır ama "hangi üç row" sorusunun cevabı yoktur:
SELECT TOP (3) book_id, title
FROM dbo.books;
book_id title
------- -------------------
1 Pride and Prejudice
2 Emma
3 Middlemarch
(3 rows affected)
Bugün book_id sırasıyla geldi, çünkü sunucu table'ı clustered index sırasıyla taradı. Bu bir sözleşme değil, bir uygulama ayrıntısı; paralel bir plan, yeni bir index ya da farklı bir veri dağılımı başka üç row getirebilir. ORDER BY ile sıralasan bile eşit değerler arasındaki sıra yine belirsizdir. En pahalı dört kitabı isteyelim:
SELECT TOP (4) title, list_price
FROM dbo.books
ORDER BY list_price DESC;
title list_price
------------------------------------------------- ----------
Design Patterns 1450.00
The Art of Computer Programming 1450.00
Structure and Interpretation of Computer Programs 1250.00
The Pragmatic Programmer 1150.00
(4 rows affected)
Dördüncü sırada The Pragmatic Programmer var, ama 1150 TL'lik dört kitap var ve hangisinin seçileceğini hiçbir şey belirlemiyor. "Dördüncüyle aynı fiyattaki herkes gelsin" diyorsan WITH TIES:
SELECT TOP (4) WITH TIES title, list_price
FROM dbo.books
ORDER BY list_price DESC;
title list_price
------------------------------------------------- ----------
Design Patterns 1450.00
The Art of Computer Programming 1450.00
Structure and Interpretation of Computer Programs 1250.00
The Algorithm Design Manual 1150.00
Code Complete 1150.00
Refactoring 1150.00
The Pragmatic Programmer 1150.00
(7 rows affected)
Dört istedik, yedi geldi. İki sorguyu yan yana koyunca bir şey daha görünüyor: The Pragmatic Programmer ilk sorguda 1150'lik kitapların başındaydı, burada sonunda. Eşitler arasındaki sıra plandan plana değişebiliyor. Standartta WITH TIES'ın karşılığı FETCH FIRST 4 ROWS WITH TIES; SQL Server 2025 bu yazımda Incorrect syntax near the keyword 'WITH' diyor; PostgreSQL ise 13. sürümden beri destekliyor ve aynı yedi kitabı döndürüyor.
OFFSET/FETCH ile Sayfalama ve Tie-Breaker
Şimdi Bookstore'un ana sayfası: en yeni kitaplar, sayfa başına 20 kitap, 3. sayfa. OFFSET/FETCH standartta SQL:2008'den, SQL Server'da 2012'den beri var:
SELECT book_id, title, listed_on
FROM dbo.books
ORDER BY listed_on DESC
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;
book_id title listed_on
------- --------------------------- ----------
37 Fahrenheit 451 2024-09-19
36 Brave New World 2024-09-16
35 Nineteen Eighty-Four 2024-09-09
34 Hyperion 2024-08-21
33 Neuromancer 2024-07-12
32 The War of the Worlds 2024-07-06
31 The Time Machine 2024-06-27
30 The Left Hand of Darkness 2024-06-01
29 I, Robot 2024-06-01
28 Foundation 2024-06-01
27 Dune Messiah 2024-06-01
26 Dune 2024-06-01
25 One Thousand and One Nights 2024-06-01
24 Beowulf 2024-06-01
23 Animal Farm 2024-06-01
22 Les Miserables 2024-06-01
21 The Picture of Dorian Gray 2024-06-01
20 Dracula 2024-06-01
19 Frankenstein 2024-06-01
18 To the Lighthouse 2024-06-01
(20 rows affected)
Doğru görünüyor. Ama kataloğun ilk 30 kitabı aynı gün, 2024-06-01'de toplu olarak eklenmişti ve sayfa bu 30 kitabın ortasından kesiliyor. Hangi 13'ünün bu sayfaya düşeceğini ORDER BY listed_on DESC belirlemiyor. Bunu kanıtlamak için hiçbir veriyi değiştirmeden yalnız bir index ekleyelim, sonra aynı sorguyu tekrar çalıştıralım:
CREATE INDEX IX_books_listed_on_desc ON dbo.books (listed_on DESC) INCLUDE (title);
book_id title listed_on
------- ---------------------- ----------
37 Fahrenheit 451 2024-09-19
36 Brave New World 2024-09-16
35 Nineteen Eighty-Four 2024-09-09
34 Hyperion 2024-08-21
33 Neuromancer 2024-07-12
32 The War of the Worlds 2024-07-06
31 The Time Machine 2024-06-27
1 Pride and Prejudice 2024-06-01
2 Emma 2024-06-01
3 Middlemarch 2024-06-01
4 Jane Eyre 2024-06-01
5 Wuthering Heights 2024-06-01
6 Great Expectations 2024-06-01
7 A Tale of Two Cities 2024-06-01
8 Moby-Dick 2024-06-01
9 Anna Karenina 2024-06-01
10 War and Peace 2024-06-01
11 Crime and Punishment 2024-06-01
12 The Brothers Karamazov 2024-06-01
13 Madame Bovary 2024-06-01
(20 rows affected)
Aynı sorgu, aynı veri, başka 13 kitap. Optimizer artık sıralamayı index'ten hazır alıyor ve index eşit tarihleri book_id artan sırada tutuyor. Production'da bu, bir DBA (Database Administrator) gece bir index eklediğinde kullanıcının 3. sayfada gördüğü kitapların değişmesi demek; daha kötüsü, sayfalar arasında bazı kitaplar iki kez görünür, bazıları hiç görünmez. Çözüm, sıralamayı benzersiz bir column'la bitirmek, yani bir tie-breaker eklemek:
DECLARE @page INT = 3, @page_size INT = 20;
SELECT book_id, title, listed_on
FROM dbo.books
ORDER BY listed_on DESC, book_id DESC
OFFSET (@page - 1) * @page_size ROWS FETCH NEXT @page_size ROWS ONLY;
Bu sorgu index olsa da olmasa da aynı 20 row'u döndürüyor: 37'den 31'e yedi kitap ve 2024-06-01 tarihli kitaplardan book_id 30'dan 18'e kadar olan on üçü. Artık sıra tam tanımlı; (listed_on, book_id) ikilisi her row için benzersiz. Toplam sayfa sayısını ayrı bir sorgu verir:
SELECT COUNT(*) AS total_books, CEILING(COUNT(*) / 20.0) AS page_count
FROM dbo.books;
total_books page_count
----------- ----------
77 4
(1 rows affected)
T-SQL OFFSET'i yalnız ORDER BY ile kabul ediyor; ORDER BY'sız yazınca Incorrect syntax near '40' hatası alırsın. Bu iyi bir kısıtlama: sırasız bir sonucu sayfalamanın anlamı yok. PostgreSQL ve MySQL'de aynı iş LIMIT 20 OFFSET 40 ile yapılır ve ikisi de ORDER BY'sız LIMIT'e izin verir; tie-breaker kuralı orada da aynen geçerli.
Keyset Pagination'a Giriş
OFFSET'in yapısal bir sorunu var: 1000. sayfayı göstermek için sunucu ilk 19.980 row'u okuyup atmak zorunda. Sayfa numarası büyüdükçe sorgu pahalılaşır. Ayrıca kullanıcı sayfalar arasında gezerken yeni bir kitap eklenirse her şey bir row kayar ve bir kitap iki sayfada görünür. Keyset pagination (seek method) "kaçıncı sayfa" yerine "en son nerede kaldım" sorusunu sorar. 3. sayfanın son row'u (2024-06-01, 18) idi; 4. sayfa bu ikiliden sonra gelenlerdir:
DECLARE @last_listed_on DATE = '2024-06-01', @last_book_id INT = 18;
SELECT TOP (20) book_id, title, listed_on
FROM dbo.books
WHERE listed_on < @last_listed_on
OR (listed_on = @last_listed_on AND book_id < @last_book_id)
ORDER BY listed_on DESC, book_id DESC;
book_id title listed_on
------- ---------------------- ----------
17 Mrs Dalloway 2024-06-01
16 Ulysses 2024-06-01
15 The Great Gatsby 2024-06-01
14 Don Quixote 2024-06-01
13 Madame Bovary 2024-06-01
12 The Brothers Karamazov 2024-06-01
11 Crime and Punishment 2024-06-01
10 War and Peace 2024-06-01
9 Anna Karenina 2024-06-01
8 Moby-Dick 2024-06-01
7 A Tale of Two Cities 2024-06-01
6 Great Expectations 2024-06-01
5 Wuthering Heights 2024-06-01
4 Jane Eyre 2024-06-01
3 Middlemarch 2024-06-01
2 Emma 2024-06-01
1 Pride and Prejudice 2024-06-01
(17 rows affected)
Son sayfa, kalan 17 kitap. (listed_on, book_id) üzerinde bir index varsa sunucu doğrudan o noktaya atlar ve sayfa numarası ne olursa olsun maliyet aynı kalır. Bedeli, "17. sayfaya git" gibi rastgele erişimin kaybolması. Standart bu koşulu row value karşılaştırmasıyla çok daha kısa yazmaya izin veriyor: WHERE (listed_on, book_id) < (DATE '2024-06-01', 18). PostgreSQL 18.6 bunu destekliyor ve küçük bir deneme table'ında 17 ve 16'yı doğru sırayla döndürüyor; SQL Server ise An expression of non-boolean type specified in a context where a condition is expected diyerek reddediyor, o yüzden T-SQL'de OR'lu uzun yazım şart. Bu konunun API (Application Programming Interface) tarafı ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Pagination, Filtering ve Sorting: Büyük Listeleri Güvenle Döndürmek" yazısında, derin karşılaştırması da Solution Pattern serisinin "Pagination Stratejileri Derinlemesine: Offset, Keyset ve Opaque Cursor Token" yazısında.
Üretimde Dikkat
Sıralama pahalı bir işlem. Sorgunun ORDER BY'ını karşılayan bir index yoksa sunucu bütün aday row'ları belleğe alıp sıralar ve bellek yetmezse tempdb'ye taşar. Sayfalama sorgularında ORDER BY column'ları ile tie-breaker'ı aynı sırayla içeren bir index, hem sıralamayı hem de keyset'in atlamasını ucuzlatır; index tasarımı "SQL Server Derinlemesine" serisinde. Kullanıcının seçtiği sıralama column'unu sorguya eklerken dinamik SQL kuruyorsan column adını asla doğrudan string'e koyma; izin verilen column'ların bir listesinden seç. Değerler için parameter kullan; bu konu OWASP (Open Worldwide Application Security Project) serisinin "OWASP A05: Injection (1) — SQL Injection ve Command Injection" yazısında. Son olarak, sayfa boyutuna bir üst sınır koy; page_size=100000 gönderen bir istemci bütün katalogu tek istekte çeker.
Sık Düşülen Tuzaklar
- Alias'ı
WHERE,GROUP BYya daHAVING'de kullanmaya çalışmak. AliasSELECT'te doğar, ondan önceki adımlar onu görmez. ORDER BYolmadan sıra beklemek;TOP'uORDER BY'sız kullanmak.- Sayfalamada benzersiz olmayan bir column'a göre sıralamak. Tie-breaker olmadan bir index eklemek bile sayfaların içeriğini değiştirir.
ORDER BY 2gibi sıra numarasıyla sıralamak;SELECTlistesine bir column eklendiğinde sıralama sessizce değişir.- Uygulama kodunda
SELECT *: gereksiz veri, kırılgan column sırası, kullanılamayan index'ler. - Yanlış join'in çoğalttığı row'ları
DISTINCTile gizlemek. - Derin sayfalarda
OFFSET'in maliyetini unutmak; büyük ve sık değişen listelerde keyset pagination düşün.
Küçük Bir Deneme
★☆☆ En pahalı 5 kitabı başlık ve fiyatla listele; eşit fiyatlılar arasında başlığa göre alfabetik sırala. Sonra TOP (5) WITH TIES ile tekrar çalıştır ve row sayısının neden değiştiğini açıkla.
★★☆ "En çok sipariş verilen müşteriler" listesini 10'arlı sayfalarla yazmak için orders'dan müşteri bazında sayım yapan bir sorgu kur ve 2. sayfayı getir. Tie-breaker'ı ne seçtin, neden?
★★★ Keyset pagination sorgusunu geriye doğru (önceki sayfa) çalışacak şekilde yeniden yaz. İpucu: karşılaştırma yönünü ve ORDER BY'ı ters çevir, sonucu kullanıcıya göstermeden önce tekrar sırala.
Bu yazıda WHERE'in yalnız "doğru" olanı geçirdiğini birkaç kez vurguladık. Bir sonraki yazıda üçüncü ihtimale, NULL'a ve üç değerli mantığa bakacağız: = NULL neden hiçbir zaman doğru olmaz, COALESCE ile ISNULL arasındaki tip farkı, LIKE ve IN, ve BETWEEN'in tarih aralıklarında gün sonunu kaçırması.