MariaDB ve MySQL’de WITH (CTE) ile Ortak Tablo İfadesi Yazma

Veritabanı sorgularında karmaşıklık arttıkça, SQL kodunun okunabilirliği ve yönetilebilirliği ciddi sorun haline gelir. İç içe geçmiş alt sorgular, tekrar eden kod blokları ve uzayan JOIN zincirleri bakımı zorlaştırır. İşte tam bu noktada WITH ifadesiyle oluşturulan Ortak Tablo İfadeleri (CTE – Common Table Expressions) devreye girer. MariaDB 10.2.1 ve MySQL 8.0 ile birlikte standart olarak desteklenen bu özellik, karmaşık sorguları daha okunabilir, modüler ve yönetilebilir hale getirir.

CTE Nedir ve Neden Kullanılır?

CTE, bir sorgu içinde geçici olarak tanımlanmış, isimlendirilmiş bir sonuç kümesidir. Sanki bir görünüm (view) tanımlıyorsunuz ama bunu kalıcı yapmak yerine yalnızca o sorgu için geçici tutuyorsunuz. Alt sorguların aksine, CTE’ler tanımlandıktan sonra ana sorguda birden fazla kez referans alınabilir. Bu büyük avantaj sağlar.

CTE kullanmanın temel gerekçeleri şunlardır:

  • Okunabilirlik: Karmaşık sorguları mantıksal bloklara böler
  • Tekrar kullanım: Aynı alt sorguyu birden fazla kez yazmak yerine bir kez tanımlarsınız
  • Hata ayıklama kolaylığı: Her CTE bloğunu ayrı ayrı test edebilirsiniz
  • Özyinelemeli sorgular: Hiyerarşik verileri (organizasyon şeması, kategori ağaçları) işlemek için idealdir

Temel CTE Sözdizimi

CTE’nin temel yapısı oldukça sezgiseldir. WITH anahtar kelimesiyle başlar, CTE’ye bir isim verilir, ardından AS ile sorgu tanımlanır.

WITH cte_adi AS (
    SELECT kolon1, kolon2
    FROM tablo
    WHERE kosul
)
SELECT *
FROM cte_adi;

Basit bir örnekle başlayalım. Diyelim ki bir e-ticaret sisteminde 500 TL üzerinde sipariş veren müşterileri bulmak istiyoruz:

WITH yuksek_degerli_siparisler AS (
    SELECT 
        musteri_id,
        SUM(toplam_tutar) AS toplam_harcama
    FROM siparisler
    WHERE siparis_tarihi >= '2024-01-01'
    GROUP BY musteri_id
    HAVING SUM(toplam_tutar) > 500
)
SELECT 
    m.musteri_adi,
    m.email,
    yds.toplam_harcama
FROM musteriler m
INNER JOIN yuksek_degerli_siparisler yds 
    ON m.id = yds.musteri_id
ORDER BY yds.toplam_harcama DESC;

Bu sorguyu CTE olmadan yazsaydık, HAVING koşulunu içeren alt sorguyu doğrudan FROM bloğuna gömmek zorunda kalacaktık ve kod hızla karmaşık bir hal alacaktı.

Birden Fazla CTE Kullanımı

Tek bir WITH bloğunda birden fazla CTE tanımlayabilirsiniz. Bu özellikle çok adımlı hesaplamalar için son derece kullanışlıdır. CTE’leri virgülle ayırmanız yeterlidir.

Gerçek dünya senaryosu olarak bir insan kaynakları sistemini ele alalım. Departman bazında ortalama maaşları hesaplayıp, bu ortalamanın üzerinde kazanan çalışanları listelemek isteyelim:

WITH departman_ortalamalari AS (
    SELECT 
        departman_id,
        AVG(maas) AS ort_maas,
        COUNT(*) AS calisan_sayisi
    FROM calisanlar
    WHERE aktif = 1
    GROUP BY departman_id
),
yuksek_maasli_calisanlar AS (
    SELECT 
        c.id,
        c.ad,
        c.soyad,
        c.maas,
        c.departman_id
    FROM calisanlar c
    INNER JOIN departman_ortalamalari da 
        ON c.departman_id = da.departman_id
    WHERE c.maas > da.ort_maas
)
SELECT 
    d.departman_adi,
    ymc.ad,
    ymc.soyad,
    ymc.maas,
    doa.ort_maas AS departman_ortalamasi,
    ROUND((ymc.maas - doa.ort_maas), 2) AS ortalama_farki
FROM yuksek_maasli_calisanlar ymc
INNER JOIN departmanlar d ON ymc.departman_id = d.id
INNER JOIN departman_ortalamalari doa ON ymc.departman_id = doa.departman_id
ORDER BY d.departman_adi, ymc.maas DESC;

Dikkat edin: ikinci CTE olan yuksek_maasli_calisanlar, birinci CTE olan departman_ortalamalari‘nı referans alıyor. CTE’ler sıralı olarak tanımlanır ve sonraki CTE’ler önceki CTE’lere referans verebilir.

Sütun İsimlendirme ile CTE

CTE tanımında sütun isimlerini açıkça belirtmek mümkündür. Bu özellikle hesaplanan sütunlar veya birleştirilmiş sorgularda kullanışlıdır:

WITH aylik_satislar (yil, ay, toplam_satis, siparis_adedi) AS (
    SELECT 
        YEAR(siparis_tarihi),
        MONTH(siparis_tarihi),
        SUM(tutar),
        COUNT(*)
    FROM siparisler
    GROUP BY 
        YEAR(siparis_tarihi), 
        MONTH(siparis_tarihi)
)
SELECT 
    yil,
    ay,
    toplam_satis,
    siparis_adedi,
    ROUND(toplam_satis / siparis_adedi, 2) AS ortalama_siparis_tutari,
    LAG(toplam_satis) OVER (ORDER BY yil, ay) AS onceki_ay_satis,
    ROUND(
        ((toplam_satis - LAG(toplam_satis) OVER (ORDER BY yil, ay)) 
        / LAG(toplam_satis) OVER (ORDER BY yil, ay)) * 100, 
    2) AS degisim_yuzdesi
FROM aylik_satislar
ORDER BY yil, ay;

Bu sorguda sütun isimlerini CTE tanımında belirtip, ana sorguda pencere fonksiyonlarıyla (LAG) aylık satış değişimini hesaplıyoruz. MariaDB 10.2+ ve MySQL 8.0+ bu tür pencere fonksiyonlarını destekler.

Özyinelemeli CTE (Recursive CTE)

Özyinelemeli CTE’ler, hiyerarşik verileri işlemek için güçlü bir araçtır. Organizasyon şemaları, kategori ağaçları, bill of materials (BOM) yapıları bunların klasik kullanım alanlarıdır.

Bir organizasyon hiyerarşisini düşünelim. Her çalışanın bir yonetici_id alanı var ve kök düğüm için bu alan NULL:

WITH RECURSIVE org_hiyerarsisi AS (
    -- Temel durum (anchor member): Kök çalışanlar
    SELECT 
        id,
        ad,
        soyad,
        unvan,
        yonetici_id,
        0 AS seviye,
        CAST(ad AS CHAR(1000)) AS hiyerarsi_yolu
    FROM calisanlar
    WHERE yonetici_id IS NULL

    UNION ALL

    -- Özyinelemeli durum: Alt çalışanlar
    SELECT 
        c.id,
        c.ad,
        c.soyad,
        c.unvan,
        c.yonetici_id,
        oh.seviye + 1,
        CONCAT(oh.hiyerarsi_yolu, ' > ', c.ad)
    FROM calisanlar c
    INNER JOIN org_hiyerarsisi oh ON c.yonetici_id = oh.id
)
SELECT 
    REPEAT('  ', seviye) AS girinti,
    ad,
    soyad,
    unvan,
    seviye,
    hiyerarsi_yolu
FROM org_hiyerarsisi
ORDER BY hiyerarsi_yolu;

Özyinelemeli CTE’lerin yapısı iki bölümden oluşur:

  • Temel durum (anchor member): Özyinelemenin başladığı ilk sorgu. UNION ALL‘dan önce gelir.
  • Özyinelemeli durum (recursive member): CTE’nin kendisine referans veren, UNION ALL‘dan sonra gelen sorgu.

MariaDB ve MySQL özyinelemeli CTE’lerde sonsuz döngüyü önlemek için @@cte_max_recursion_depth sistem değişkeni kullanır. Varsayılan değer 1000’dir. Gerekirse artırabilirsiniz:

SET SESSION cte_max_recursion_depth = 5000;

Gerçek Dünya Senaryosu: Stok Yönetim Sistemi

Bir stok yönetim sistemi için kapsamlı bir örnek yapalım. Düşük stoklu ürünleri, son 30 günlük satış hızını ve yeniden sipariş önerilerini hesaplayalım:

WITH son_30_gun_satislari AS (
    SELECT 
        urun_id,
        SUM(miktar) AS satilan_miktar,
        COUNT(DISTINCT DATE(satis_tarihi)) AS satis_gunu_sayisi
    FROM satis_detaylari sd
    INNER JOIN satislar s ON sd.satis_id = s.id
    WHERE s.satis_tarihi >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
    GROUP BY urun_id
),
gunluk_satis_hizi AS (
    SELECT 
        urun_id,
        satilan_miktar,
        satis_gunu_sayisi,
        ROUND(satilan_miktar / NULLIF(satis_gunu_sayisi, 0), 2) AS gunluk_hiz
    FROM son_30_gun_satislari
),
kritik_stok AS (
    SELECT 
        u.id AS urun_id,
        u.urun_adi,
        u.stok_miktari,
        u.minimum_stok,
        u.tedarik_suresi_gun,
        gsh.gunluk_hiz,
        ROUND(gsh.gunluk_hiz * u.tedarik_suresi_gun, 0) AS tedarik_suresi_tuketim
    FROM urunler u
    LEFT JOIN gunluk_satis_hizi gsh ON u.id = gsh.urun_id
    WHERE u.stok_miktari <= u.minimum_stok * 1.2
)
SELECT 
    urun_adi,
    stok_miktari,
    minimum_stok,
    COALESCE(gunluk_hiz, 0) AS gunluk_satis_hizi,
    tedarik_suresi_gun,
    tedarik_suresi_tuketim,
    GREATEST(
        minimum_stok - stok_miktari + COALESCE(tedarik_suresi_tuketim, 0), 
        0
    ) AS onerilens_siparis_miktari,
    CASE 
        WHEN stok_miktari = 0 THEN 'KRİTİK - STOK YOK'
        WHEN stok_miktari < minimum_stok THEN 'ACİL SİPARİŞ'
        ELSE 'DİKKAT - DÜŞÜK STOK'
    END AS durum
FROM kritik_stok
ORDER BY 
    CASE 
        WHEN stok_miktari = 0 THEN 1
        WHEN stok_miktari < minimum_stok THEN 2
        ELSE 3
    END,
    stok_miktari ASC;

Bu sorgu üç aşamalı bir analiz yapıyor: önce son 30 günün satışlarını topluyor, günlük satış hızını hesaplıyor, ardından kritik stok seviyesindeki ürünleri belirleyip önerilen sipariş miktarını hesaplıyor.

CTE ile Veri Güncelleme

CTE’ler yalnızca SELECT sorgularıyla sınırlı değildir. UPDATE, DELETE ve hatta INSERT işlemlerinde de kullanılabilirler. Bu özellik özellikle karmaşık güncelleme senaryolarında hayat kurtarır.

Örneğin, belirli kriterlere göre müşteri segmentlerini güncelleyelim:

WITH musteri_segmentasyon AS (
    SELECT 
        m.id,
        SUM(s.toplam_tutar) AS toplam_harcama,
        COUNT(s.id) AS siparis_sayisi,
        MAX(s.siparis_tarihi) AS son_siparis_tarihi,
        DATEDIFF(CURDATE(), MAX(s.siparis_tarihi)) AS son_siparis_gecen_gun
    FROM musteriler m
    LEFT JOIN siparisler s ON m.id = s.musteri_id
    GROUP BY m.id
)
UPDATE musteriler m
INNER JOIN musteri_segmentasyon ms ON m.id = ms.id
SET m.segment = CASE
    WHEN ms.toplam_harcama >= 10000 AND ms.son_siparis_gecen_gun <= 90 THEN 'ALTIN'
    WHEN ms.toplam_harcama >= 3000 AND ms.son_siparis_gecen_gun <= 180 THEN 'GUMUS'
    WHEN ms.siparis_sayisi >= 1 AND ms.son_siparis_gecen_gun <= 365 THEN 'BRONZ'
    ELSE 'PASIF'
END,
m.son_guncelleme = NOW();

Not: MariaDB bu sözdizimini destekler. MySQL 8.0’da da çalışır ancak bazı durumlarda sözdizimi hafifçe farklılaşabilir.

CTE vs Alt Sorgular: Performans Değerlendirmesi

Bir sysadmin olarak “CTE mi yoksa alt sorgu mu kullanmalıyım?” sorusuyla kesinlikle karşılaşacaksınız. Birkaç önemli noktayı bilmek gerekiyor:

Materyalizasyon (Materialization) davranışı:

  • MariaDB, CTE’leri varsayılan olarak materyalize eder. Yani CTE sonucu geçici bir tabloya yazılır ve birden fazla referansta tekrar hesaplanmaz. Bu, CTE’nin birden fazla yerde kullanıldığı durumlarda performans avantajı sağlar.
  • MySQL 8.0, optimizer’ın kararına göre ya materyalize eder ya da CTE’yi inline (satır içi) olarak çalıştırır.

Ne zaman CTE tercih edilmeli:

  • Aynı alt sorguyu birden fazla kez referans almanız gerektiğinde
  • Özyinelemeli sorgularda (başka alternatif yok)
  • Kodun okunabilirliği öncelikliyken
  • Hata ayıklama sürecinde adım adım test ederken

Ne zaman alt sorgu veya view tercih edilmeli:

  • Çok basit, tek kullanımlık sorgularda alt sorgu yeterlidir
  • Farklı sorgularda tekrar kullanılacak karmaşık sorgular için kalıcı view daha uygun olabilir

EXPLAIN ile CTE Sorgu Analizi

CTE sorgularınızın nasıl çalıştığını anlamak için EXPLAIN kullanmak önemlidir:

EXPLAIN WITH departman_ozeti AS (
    SELECT 
        departman_id,
        COUNT(*) AS calisan_sayisi,
        AVG(maas) AS ort_maas
    FROM calisanlar
    GROUP BY departman_id
)
SELECT 
    d.departman_adi,
    dos.calisan_sayisi,
    dos.ort_maas
FROM departmanlar d
INNER JOIN departman_ozeti dos ON d.id = dos.departman_id
ORDER BY dos.ort_maas DESC;

MariaDB 10.5+ ve MySQL 8.0’da EXPLAIN ANALYZE kullanarak gerçek çalışma sürelerini ve satır tahminlerini de görebilirsiniz. Bu bilgi sorgu optimizasyonu için son derece değerlidir.

CTE performansını etkileyen birkaç faktöre dikkat edin:

  • İndeks kullanımı: CTE içindeki sorgular da indekslerden faydalanır. WHERE koşullarınızda indeksli sütunları kullanın.
  • Gereksiz kolonlar: CTE’de yalnızca ihtiyacınız olan kolonları seçin, SELECT * kullanmaktan kaçının.
  • Özyinelemeli CTE derinliği: Çok derin özyinelemeler bellek ve performans sorunlarına yol açabilir.

Pratik İpuçları ve Yaygın Hatalar

Üretim ortamında CTE kullanırken dikkat edilmesi gereken noktalar:

Döngü tuzağından kaçınma: Özyinelemeli CTE’lerde sonsuz döngü riski vardır. Hiyerarşik verinizde döngüsel referanslar varsa (A -> B -> C -> A gibi) sorgunuz çöker. Bunu önlemek için derinlik sınırı veya ziyaret edildi kontrolü ekleyin:

WITH RECURSIVE kategori_agaci AS (
    SELECT 
        id,
        kat_adi,
        ust_kategori_id,
        1 AS derinlik,
        CAST(id AS CHAR(200)) AS ziyaret_edilen
    FROM kategoriler
    WHERE ust_kategori_id IS NULL
    
    UNION ALL
    
    SELECT 
        k.id,
        k.kat_adi,
        k.ust_kategori_id,
        ka.derinlik + 1,
        CONCAT(ka.ziyaret_edilen, ',', k.id)
    FROM kategoriler k
    INNER JOIN kategori_agaci ka ON k.ust_kategori_id = ka.id
    WHERE ka.derinlik < 10
      AND FIND_IN_SET(k.id, ka.ziyaret_edilen) = 0
)
SELECT * FROM kategori_agaci ORDER BY derinlik, kat_adi;

Diğer önemli ipuçları:

  • CTE isimlerini anlamlı seçin: cte1, cte2 gibi isimler kodu anlaşılmaz yapar. aylik_satis_ozeti, aktif_musteriler gibi açıklayıcı isimler kullanın.
  • CTE sayısını makul tutun: 10-15 CTE’yi aşan sorgular bakımı zorlaştırır. Bu noktada stored procedure veya view kullanmayı değerlendirin.
  • Geçici tablolarla kıyaslama: Çok karmaşık CTE zincirlerinde ara sonuçları geçici tablolara yazmak performans açısından daha iyi sonuç verebilir. Üretimde her ikisini de test edin.
  • MariaDB versiyon kontrolü: CTE’ler MariaDB 10.2.2’den itibaren desteklenir. Özyinelemeli CTE için de aynı versiyon gereklidir. Versiyon kontrolü için SELECT VERSION(); kullanın.

Sonuç

CTE’ler, MariaDB ve MySQL dünyasında veritabanı geliştiricileri ve sysadminler için gerçekten vazgeçilmez bir araç haline gelmiştir. Karmaşık raporlama sorgularını modüler bloklara ayırmak, hiyerarşik verileri zahmetsizce işlemek ve kod tekrarını ortadan kaldırmak için sunduğu olanaklar benzersizdir.

Özellikle şu durumlarda CTE’ye geçmek işleri önemli ölçüde kolaylaştırır: iç içe alt sorgulardan oluşan, okuması güç SQL dosyalarıyla boğuştuğunuzda, organizasyon şeması veya kategori ağacı gibi hiyerarşik verileri sorgulamanız gerektiğinde ve aynı hesaplama mantığını birden fazla noktada kullandığınızda.

Üretim ortamında CTE kullanmadan önce her zaman EXPLAIN ile sorgu planını kontrol edin, özyinelemeli CTE’lerde derinlik sınırını ve döngüsel referans kontrolünü ihmal etmeyin. Performans kritik sorgularda CTE ile geçici tablo yaklaşımını karşılaştırın ve ortamınıza en uygun çözümü seçin. Temiz, okunabilir SQL yazmak yalnızca geliştirici deneyimini iyileştirmekle kalmaz, uzun vadede bakım maliyetlerini de ciddi ölçüde düşürür.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir