Subquery'ler ve Set Operator'leri: Correlated Subquery, EXISTS, NOT IN Tuzağı ve UNION/INTERSECT/EXCEPT
Subquery bir sorgunun sonucunu başka bir sorguda kullanır; NOT IN ise listede tek bir NULL varsa hiç row döndürmez. Scalar subquery ve 512 hatasını, IN/ANY/ALL'u, correlated subquery'yi, derived table'ı, EXISTS ile NOT EXISTS'i ve UNION, UNION ALL, INTERSECT, EXCEPT'i Bookstore verisinde gerçek çıktı, logical read ve plan karşılaştırmasıyla anlatıyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0, CU10 (Cumulative Update 10)) üzerinde serinin Bookstore veritabanıyla çalıştırılmıştır; PostgreSQL karşılaştırmaları PostgreSQL 18.6 ile denenmiştir.
Önce Tahmin Et
Katalog ekibi, içinde hiç kitap olmayan kategorileri temizlemek istiyor. Görev kolay görünüyor: kitapların kullandığı category_id'leri bul, listede olmayan kategorileri döndür.
SELECT category_id, name
FROM dbo.categories
WHERE category_id NOT IN (SELECT category_id FROM dbo.books);
Kaç row bekliyorsun? Kontrol için her kategorinin kitap sayısı şu:
category_id name books
----------- --------------- -----
1 Fiction 0
2 Classics 29
3 Science Fiction 9
4 Dystopian 4
5 Fantasy 6
6 Mystery 6
7 Poetry 0
8 Non-Fiction 0
9 Science 5
10 Computing 0
11 Programming 9
12 Algorithms 2
13 Children 5
Dört kategori boş: Fiction, Poetry, Non-Fiction ve Computing (üst kategoriler kitabı alt kategorilerinde tutuyor, Poetry gerçekten boş). Sorgunun cevabı ise:
category_id name
----------- ----
Sıfır row. Hata yok, uyarı yok, yalnız boş bir sonuç. Bir rapor bu sorguya dayansaydı "temizlenecek kategori yok" diyecek ve kimse fark etmeyecekti. Neden böyle olduğunu bu yazının ortasında, EXISTS'in yanında çözeceğiz. Önce subquery'nin (alt sorgu) çeşitlerini sırayla kuralım.
Scalar Subquery: Tek Değer Döndürmek Zorunda
Subquery, parantez içine yazılmış ve başka bir sorgunun içinde kullanılan bir SELECT'tir. En basiti scalar subquery: tek row, tek column döndürür ve bir değerin geçebileceği her yere yazılabilir. Ortalama fiyatın iki katından pahalı kitaplar:
SELECT title, list_price
FROM dbo.books
WHERE list_price > (SELECT AVG(list_price) * 2 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
The Pragmatic Programmer 1150.00
Refactoring 1150.00
Ortalama 541,55; iki katı 1083,10. Subquery bir kez hesaplanır ve sonucu sabit gibi kullanılır. Scalar subquery'nin sözleşmesi katıdır: birden çok row dönerse sorgu hata verir. Her kitabın yazarını scalar subquery ile getirmeye çalışalım:
SELECT title,
(SELECT author_id FROM dbo.book_authors AS ba WHERE ba.book_id = b.book_id) AS author_id
FROM dbo.books AS b
WHERE b.book_id BETWEEN 39 AND 45
ORDER BY b.book_id;
title author_id
-------------------------- ---------
The Hobbit 28
The Fellowship of the Ring 28
A Wizard of Earthsea 20
Msg 512, Level 16, State 1, Server b8be943222fa, Line 1
Subquery returned more than 1 value. This is not permitted when the subquery follows =, !=, <, <= , >, >= or when the subquery is used as an expression.
Dikkat et: hata gelmeden önce üç row döndü. SQL Server row'ları üretirken ilk iki yazarlı kitaba (Good Omens) ulaştığı anda durdu. Uygulama tarafında bu, sonucun bir kısmını okuyup ortada exception almak demek. Test verinde her kitabın tek yazarı varsa bu hata production'a kadar saklanır. Scalar subquery yazarken kendine şunu sor: "Bu sorgunun birden çok row döndürmesini engelleyen şey ne?" Cevap bir primary key ya da UNIQUE constraint değilse, ya bir aggregate (MAX, MIN) kullan ya da join'e geç.
IN, ANY ve ALL
Subquery bir liste döndürüyorsa IN ile kullanılır. Bir yıldız veren müşteriler:
SELECT customer_id, first_name, last_name
FROM dbo.customers
WHERE customer_id IN (SELECT customer_id FROM dbo.reviews WHERE rating = 1);
customer_id first_name last_name
----------- ---------- ---------
7 Barbara Liskov
19 Niklaus Wirth
21 Annie Easley
30 Jean Bartik
IN, = ANY ile aynı anlamdadır: değer listedeki herhangi bir element'e eşitse doğru. Standart bunu bütün karşılaştırma operator'lerine genelleştirir: > ANY "en az birinden büyük", > ALL "hepsinden büyük". Classics kategorisindeki (2) bütün kitaplardan pahalı olan kitaplar:
SELECT title, list_price
FROM dbo.books
WHERE list_price > ALL (SELECT b2.list_price FROM dbo.books AS b2 WHERE b2.category_id = 2);
title list_price
------------------------------------------------- ----------
The C Programming Language 950.00
The Pragmatic Programmer 1150.00
Clean Code 890.00
Refactoring 1150.00
Design Patterns 1450.00
Code Complete 1150.00
The Mythical Man-Month 890.00
Domain-Driven Design 950.00
Structure and Interpretation of Computer Programs 1250.00
The Art of Computer Programming 1450.00
The Algorithm Design Manual 1150.00
ALL'un bir tuzağı var: subquery boş dönerse koşul her row için doğru olur. "Hiçbir şeyden büyük değilsin" diyebilecek bir element olmadığı için matematiksel olarak doğru, ama raporda şaşırtıcı. Kitabı olmayan Poetry (7) ile:
SELECT COUNT(*) AS books
FROM dbo.books
WHERE list_price > ALL (SELECT b2.list_price FROM dbo.books AS b2 WHERE b2.category_id = 7);
books
-----
77
77 kitabın hepsi "Poetry'deki bütün kitaplardan pahalı" çıktı. > ALL ifadesini çoğu zaman > (SELECT MAX(...)) olarak yazmak niyeti daha net gösterir; o durumda boş küme MAX NULL döndürür ve koşul hiçbir row için doğru olmaz. Hangisini istediğine sen karar vermelisin, ama farkı bilerek.
Correlated Subquery: Dış Row'a Bakan Sorgu
Şimdiye kadarki subquery'ler dış sorgudan bağımsızdı. Correlated subquery ise dış sorgunun column'una referans verir; mantıksal olarak dış sorgunun her row'u için yeniden değerlendirilir. Kendi kategorisinin ortalamasından %30 pahalı kitaplar:
SELECT b.title, b.category_id, b.list_price
FROM dbo.books AS b
WHERE b.list_price > (SELECT AVG(b2.list_price) * 1.3
FROM dbo.books AS b2
WHERE b2.category_id = b.category_id)
ORDER BY b.category_id, b.list_price DESC;
title category_id list_price
-------------------------- ----------- ----------
Emma 2 680.50
To the Lighthouse 2 660.90
Great Expectations 2 650.00
Tutunamayanlar 2 620.90
Madame Bovary 2 580.50
Don Quixote 2 580.00
The Fellowship of the Ring 5 600.00
The Selfish Gene 9 600.00
Design Patterns 11 1450.00
The Little Prince 13 670.90
b2.category_id = b.category_id satırı bağlantıyı kuruyor: Emma için subquery yalnız Classics'in ortalamasını hesaplıyor, Design Patterns için yalnız Programming'inkini. Kategorisi NULL olan iki kitap listede yok, çünkü b2.category_id = NULL hiçbir zaman doğru olmaz ve subquery NULL ortalama döndürür.
"Her row için yeniden çalışır" ifadesi mantıksal anlamı anlatır, fiziksel uygulamayı değil. Optimizer correlated subquery'leri sık sık join'e ya da tek seferlik bir aggregate'e çevirir. Bunu Üretimde Dikkat bölümünde ölçeceğiz.
Derived Table: FROM'daki Subquery
FROM içinde kullanılan subquery'ye derived table denir. Sonucu bir tablo gibi davranır, bu yüzden alias zorunludur. En sık kullanım, aggregate'in sonucunu filtrelemek ya da başka bir şeyle birleştirmek. 2026'da hiç sipariş vermemiş eski müşteriler:
SELECT t.customer_id, t.orders, t.last_order
FROM (
SELECT customer_id, COUNT(*) AS orders, MAX(ordered_at) AS last_order
FROM dbo.orders
GROUP BY customer_id
) AS t
WHERE t.last_order < '2026-01-01'
ORDER BY t.last_order;
customer_id orders last_order
----------- ------ -------------------
8 4 2025-09-09 21:42:16
28 6 2025-12-19 20:30:04
10 3 2025-12-23 20:06:03
Bu sorguyu HAVING MAX(ordered_at) < '2026-01-01' ile de yazabilirdin. Derived table'ın gücü, ara sonucun column'larına isim verip onları dış sorguda serbestçe kullanabilmen. Bir sonraki yazıda aynı fikrin daha okunaklı hâlini, CTE'yi (Common Table Expression) göreceğiz.
EXISTS ve NOT IN Tuzağı
EXISTS subquery'nin en az bir row döndürüp döndürmediğine bakar; ne döndürdüğüyle ilgilenmez, bu yüzden SELECT 1 yazmak gelenektir. Değeri yalnız TRUE ya da FALSE'tur, asla UNKNOWN değildir. Hiç sipariş vermemiş müşteriler:
SELECT c.customer_id, c.first_name, c.last_name
FROM dbo.customers AS c
WHERE NOT EXISTS (SELECT 1 FROM dbo.orders AS o WHERE o.customer_id = c.customer_id);
customer_id first_name last_name
----------- ---------- ---------
37 Mert Kaya
38 Zeynep Demir
39 Can Sahin
40 Deniz Celik
Bu bir anti-join: "eşi olmayan row'ları getir". Serinin 09. yazısında aynı işi LEFT JOIN ... IS NULL ile de yapmıştık. Şimdi açılıştaki sorguya dönelim. NOT IN aslında bir dizi <> karşılaştırmasının AND ile bağlanmış hâlidir:
category_id NOT IN (2, 3, 4, NULL)
/* şuna eşittir: */
category_id <> 2 AND category_id <> 3 AND category_id <> 4 AND category_id <> NULL
Son terim category_id <> NULL her zaman UNKNOWN'dur. TRUE AND UNKNOWN sonucu UNKNOWN olur, FALSE AND UNKNOWN ise FALSE. Yani bir row ya FALSE ya UNKNOWN üretir ve WHERE yalnız TRUE olan row'ları geçirir. Kitapların iki tanesinin category_id'si NULL olduğu için subquery'nin listesinde bir NULL vardı ve bu, bütün sonucu sildi. Serinin 06. yazısında gördüğümüz üç değerli mantığın en pahalı sonucu bu.
İki düzeltme var. Birincisi NOT EXISTS; NULL'dan etkilenmez çünkü eşleşme aramaz, varlık arar:
SELECT c.category_id, c.name
FROM dbo.categories AS c
WHERE NOT EXISTS (SELECT 1 FROM dbo.books AS b WHERE b.category_id = c.category_id);
category_id name
----------- -----------
1 Fiction
7 Poetry
8 Non-Fiction
10 Computing
İkincisi NOT IN'i korumak ama subquery'den NULL'ları elemek:
SELECT category_id, name
FROM dbo.categories
WHERE category_id NOT IN (SELECT category_id FROM dbo.books WHERE category_id IS NOT NULL);
category_id name
----------- -----------
1 Fiction
7 Poetry
8 Non-Fiction
10 Computing
Ben ilkini tercih ediyorum: NOT EXISTS bugün NULL içermeyen bir column'a yarın nullable olunca bozulmaz. NOT IN'i yalnız sabit bir listeyle (NOT IN ('Cancelled', 'Pending')) ya da NOT NULL olduğundan emin olduğun bir column'la kullan. Bu davranış standardın kendisidir; PostgreSQL 18'de aynı NOT IN + NULL denemesi de sıfır row döndürdü.
Pozitif tarafta IN ile EXISTS arasında sonuç farkı yoktur; NULL burada sorun çıkarmaz, çünkü OR ile bağlanan karşılaştırmalardan biri TRUE olunca sonuç TRUE olur. Hangisini yazacağın okunabilirlik meselesi; optimizer ikisini de aynı semi join'e çevirebilir.
Set Operator'leri: UNION, INTERSECT ve EXCEPT
Subquery bir sorguyu diğerinin içine koyar. Set operator'leri ise iki sorgunun sonucunu yan yana değil, üst üste birleştirir. Kuralları: iki tarafın column sayısı aynı olmalı, tipleri uyumlu olmalı, column adları ilk sorgudan gelir ve ORDER BY yalnız en sona, bütün sonuç için yazılır.
UNION iki sonucu birleştirir ve tekrar edenleri atar; UNION ALL atmaz:
SELECT city FROM dbo.customers WHERE country_code = 'TR'
UNION
SELECT city FROM dbo.customers WHERE country_code = 'TR';
city
--------
NULL
Ankara
Antalya
Bursa
Istanbul
Izmir
Aynı sorguyu iki kez birleştirdik ve 6 row aldık: tekilleştirme hem tekrarları hem de iki taraftaki aynı row'ları sildi. NULL'ı da tek bir değer gibi tekilleştirdiğine dikkat et; set operator'leri, GROUP BY gibi, NULL'ları "ayırt edilemez" sayar. Row sayısındaki fark:
SELECT
(SELECT COUNT(*) FROM (SELECT city FROM dbo.customers UNION ALL SELECT city FROM dbo.customers) AS x) AS union_all_rows,
(SELECT COUNT(*) FROM (SELECT city FROM dbo.customers UNION SELECT city FROM dbo.customers) AS x) AS union_rows;
union_all_rows union_rows
-------------- ----------
80 11
Tekilleştirme bedava değildir: SQL Server bunun için bir sort ya da hash adımı ekler. İki tarafın zaten çakışmadığını biliyorsan (farklı yıllar, farklı ülkeler) her zaman UNION ALL yaz.
INTERSECT iki tarafta da olan row'ları, EXCEPT ilk tarafta olup ikincide olmayanları döndürür. 2025'in ilk yarısında ve 2026'nın ilk yarısında sipariş veren müşteri sayısı:
SELECT COUNT(*) AS loyal_customers
FROM (
SELECT customer_id FROM dbo.orders WHERE ordered_at >= '2025-01-01' AND ordered_at < '2025-07-01'
INTERSECT
SELECT customer_id FROM dbo.orders WHERE ordered_at >= '2026-01-01' AND ordered_at < '2026-07-01'
) AS both_halves;
loyal_customers
---------------
24
Ve 2025'in ilk yarısında sipariş verip 2026'nın ilk yarısında gelmeyenler, yani kaybedilen müşteriler:
SELECT customer_id FROM dbo.orders WHERE ordered_at >= '2025-01-01' AND ordered_at < '2025-07-01'
EXCEPT
SELECT customer_id FROM dbo.orders WHERE ordered_at >= '2026-01-01' AND ordered_at < '2026-07-01'
ORDER BY customer_id;
customer_id
-----------
8
10
28
INTERSECT ve EXCEPT NULL'ları eşit sayar; join ve WHERE'deki = ise saymaz:
SELECT NULL AS x INTERSECT SELECT NULL;
x
----
NULL
İki tarafın uyumsuz olduğu durumlarda aldığın hatalar da öğretici. Column sayısı farklıysa:
SELECT customer_id, email FROM dbo.customers
UNION
SELECT customer_id FROM dbo.orders;
Msg 205, Level 16, State 1, Server b8be943222fa, Line 1
All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.
Sayı aynı ama tipler uyumsuzsa SQL Server data type precedence kuralına göre dönüşüm dener; int, varchar'dan önceliklidir, bu yüzden e-postaları sayıya çevirmeye çalışıp ilk row'da düşer:
SELECT email FROM dbo.customers
UNION ALL
SELECT customer_id FROM dbo.orders;
Msg 245, Level 16, State 1, Server b8be943222fa, Line 1
Conversion failed when converting the varchar value 'ada.lovelace@example.com' to data type int.
Standart ayrıca INTERSECT ALL ve EXCEPT ALL tanımlar: tekrarları sayarak çalışırlar. PostgreSQL ikisini de destekliyor; SQL Server ise desteklemiyor:
SELECT customer_id FROM dbo.orders
EXCEPT ALL
SELECT customer_id FROM dbo.reviews;
Msg 324, Level 15, State 2, Server b8be943222fa, Line 3
The 'ALL' version of the EXCEPT operator is not supported.
Son bir not: birden çok set operator'ü aynı sorguda kullandığında SQL Server önce INTERSECT'i, sonra UNION ve EXCEPT'i soldan sağa değerlendirir. Okuyanı bu kurala güvendirme; parantez kullan. MySQL'de ise INTERSECT ve EXCEPT ancak 8.0.31 ile geldi; eski bir MySQL sürümünü hedefliyorsan bu sorgular hiç çalışmaz.
Üretimde Dikkat
"Correlated subquery yavaştır, her zaman join'e çevir" sık duyulan bir tavsiye. Ölçelim. Aynı soruyu, Hollanda'daki müşterilerin son sipariş tarihini, iki şekilde yazdım ve SET STATISTICS IO ON ile okunan sayfa sayısına baktım:
SET STATISTICS IO ON;
SELECT c.customer_id,
(SELECT MAX(o.ordered_at) FROM dbo.orders AS o WHERE o.customer_id = c.customer_id) AS last_order
FROM dbo.customers AS c
WHERE c.country_code = 'NL';
SET STATISTICS IO OFF;
customer_id last_order
----------- -------------------
19 2026-05-27 19:39:55
39 NULL
Table 'orders'. Scan count 2, logical reads 8, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'customers'. Scan count 1, logical reads 3, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
SET STATISTICS IO ON;
SELECT c.customer_id, MAX(o.ordered_at) AS last_order
FROM dbo.customers AS c
LEFT JOIN dbo.orders AS o ON o.customer_id = c.customer_id
WHERE c.country_code = 'NL'
GROUP BY c.customer_id;
SET STATISTICS IO OFF;
customer_id last_order
----------- -------------------
19 2026-05-27 19:39:55
39 NULL
Table 'orders'. Scan count 2, logical reads 8, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Table 'customers'. Scan count 1, logical reads 3, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Logical read'ler birebir aynı: orders için 8, customers için 3. Sebebi planlarda görülüyor. SET SHOWPLAN_TEXT ON ile iki sorgunun planını aldığımda correlated sürüm şu operator'lerden oluştu:
|--Compute Scalar(DEFINE:([Expr1004]=[Expr1002]))
|--Nested Loops(Left Outer Join, OUTER REFERENCES:([c].[customer_id]))
|--Clustered Index Scan(OBJECT:([sqlt_11].[dbo].[customers].[PK_customers] AS [c]), WHERE:([sqlt_11].[dbo].[customers].[country_code] as [c].[country_code]='NL'))
|--Stream Aggregate(DEFINE:([Expr1002]=MAX([sqlt_11].[dbo].[orders].[ordered_at] as [o].[ordered_at])))
|--Clustered Index Scan(OBJECT:([sqlt_11].[dbo].[orders].[PK_orders] AS [o]), WHERE:([sqlt_11].[dbo].[orders].[customer_id] as [o].[customer_id]=[sqlt_11].[dbo].[customers].[customer_id] as [c].[customer_id]))
Join'li sürümün planı, en üstteki Compute Scalar dışında aynı: optimizer iki yazımı da aynı mantıksal ağaca indirdi. Optimizer sorgunun ne istediğine bakar, nasıl yazıldığına değil; bu, serinin 01. yazısında anlattığımız declarative dilin pratikteki karşılığı. Correlated subquery'nin gerçekten pahalı olduğu durumlar elbette var (bu planda her müşteri için orders taranıyor ve tablo büyüdükçe customer_id üzerinde bir index şart olur), ama o hükmü yazım şekline bakarak değil, plana bakarak verirsin. Planları okumak SQL Server Derinlemesine serisinin konusu.
NOT IN ile NOT EXISTS arasındaki fark yalnız doğruluk değil, plan da farklıdır. Açılıştaki NOT IN sorgusunun planında, NOT EXISTS planında olmayan bir kol var: books tablosunu category_id IS NULL için tarayan bir Row Count Spool ve ikinci bir Left Anti Semi Join. SQL Server, listede NULL olup olmadığını her sorguda ayrıca kontrol etmek zorunda. Column'u NOT NULL yapmak ya da NOT EXISTS yazmak iki sorunu birden çözer.
Uygulamadan gelen uzun IN listelerine dikkat. Elle yazılan kod ya da bir ORM (Object-Relational Mapping) bir koleksiyonu IN (@p0, @p1, ...) olarak gönderdiğinde listenin her farklı uzunluğu farklı bir sorgu metni, yani ayrı bir plan demektir. Entity Framework Core'un (EF Core) bu listeleri nasıl çevirdiği sürümden sürüme değişti; EF Core serisinin yenilikler yazılarında üretilen SQL'i görebilirsin. SQL Server tarafındaki alternatifler table-valued parameter ya da listeyi JSON (JavaScript Object Notation) olarak tek parametrede göndermek.
Sık Düşülen Tuzaklar
NOT IN'i nullable bir column döndüren subquery ile kullanmak. Tek bir NULL bütün sonucu boşaltır; NOT EXISTS yaz.
Scalar subquery'nin tek row döneceğini test verisine bakarak varsaymak. İlk çok yazarlı kitapta 512 hatası gelir; tekliği bir key ya da aggregate garanti etmeli.
> ALL ile boş subquery'nin her şeyi geçireceğini unutmak.
Tekrar etmeyeceğini bildiğin sonuçlar için UNION yazmak. Gereksiz bir tekilleştirme adımı ödersin; UNION ALL kullan.
Set operator'ünün iki tarafına ayrı ORDER BY yazmaya çalışmak. Sıralama yalnız birleşik sonuca uygulanır.
Correlated subquery'yi yazım şekli yüzünden yavaş sanıp okunabilirliği bozan bir join'e çevirmek. Önce ölç: logical read'lere ve plana bak.
Küçük Bir Deneme
★☆☆ Hiç yorum almamış kitapları üç farklı yoldan yaz: NOT EXISTS, LEFT JOIN ... IS NULL ve EXCEPT. Üçünün de aynı sonucu (19 kitap) döndürdüğünü doğrula.
★★☆ Her müşteri için toplam sipariş sayısını ve kendi ülkesinin müşteri başına ortalama sipariş sayısını yan yana döndür. Önce correlated subquery ile, sonra derived table ile yaz ve SET STATISTICS IO ON ile logical read'leri karşılaştır.
★★★ reviews tablosundaki customer_id'leri ve ek olarak bir NULL'ı içeren #reviewers adlı geçici bir tablo oluştur, "hiç yorum yazmamış müşteriler" sorgusunu bu tabloya karşı NOT IN ile çalıştır. Boş sonucu gördükten sonra aynı sorguyu NOT EXISTS ile yaz ve iki planı SET SHOWPLAN_TEXT ON ile karşılaştır.
Derived table'lar iç içe geçtikçe sorgu içten dışa okunmaya başlıyor ve bir noktada takip edilemez hâle geliyor. Bir sonraki yazıda aynı mantığı yukarıdan aşağıya yazmamızı sağlayan CTE'leri, kalıcı bir soyutlama olarak view'ları ve kategori ağacını gezen recursive sorguları ele alıyoruz.