İçeriğe geç
Çılgın Yazılım

Arayüz ve Tablo İleri

İç İçe Kategoriler (Ağaç Yapı)

Adjacency list ve recursive CTE ile sınırsız derinlikte kategori ağacı: tek sorguda okuma, sürükle-bırak taşıma, döngü ve derinlik koruması, alt ağaç ürün sayaçları.

  • PHP 8
  • PDO
  • MySQL 8
  • Recursive CTE
  • Ajax
  • Bootstrap 5
php-nested-categories/kunye.json
seviye
"İleri"
dosya
24
satir
~12.826
boyut
"1.8 MB"
veritabani
"cy_tree"
lisans
"MIT"
inceleme
132
begeni
2
yayin
""

Ekran görüntüleri 4 görsel

Bu örnek ne yapıyor?

  • Adjacency list modeli: her satır parent_id tutar, bir dalı taşımak tek UPDATE
  • Recursive CTE ile tüm ağaç, derinlik ve yol bilgisiyle birlikte tek sorguda okunur
  • sort_path sayesinde derinlik öncelikli sıralama veritabanında üretilir
  • Alt ağaç ürün sayaçları (doğrudan / toplam) aynı sorguda gelir
  • Döngü koruması: bir kategori kendi alt kategorisinin altına taşınamaz
  • Yabancı anahtar döngüyü göremez; kontrol uygulama katmanında ve transaction içinde yapılır
  • Derinlik koruması hedefin seviyesine değil, taşınan dalın yüksekliğine de bakar
  • Slug ebeveyn altında benzersizdir; farklı dallarda aynı ad çakışma değildir
  • Taşımada slug hedefte çakışırsa yeniden üretilir, taşıma reddedilmez
  • Üç silme stratejisi: engelle, çocukları bir üste taşı, tüm dalı sil
  • Her stratejinin kaç kaydı etkileyeceği silme modalında önceden yazar
  • Ürünü olan kategori hiçbir stratejide silinemez (ON DELETE RESTRICT)
  • Sürükle-bırak dış kütüphane olmadan, tarayıcının HTML5 DnD API'siyle yazıldı
  • Bırakma noktasının üst yarısı "araya gir", alt yarısı "alt kategori yap" demektir
  • Arama filtrelemez, vurgular ve eşleşenlere giden dalları açar (Türkçe harf duyarsız)
  • Paylaşılabilir derin bağlantı (#kategori-11), CSV dışa aktarım ve mobilde sıfır yatay kaydırma

Nerede işe yarar?

  • E-ticaret kategori ağacı ve menü yönetimi
  • Çok seviyeli site menüsü / navigasyon düzenleyici
  • Departman, birim ve organizasyon şeması
  • Klasör ve belge ağacı (doküman yönetimi)
  • İç içe yorum ve forum başlığı yapısı
  • Bölge / il / ilçe gibi coğrafi hiyerarşiler

Gereksinimler

  • PHP 8.0+ (pdo_mysql, mbstring) · MySQL 8.0+ veya MariaDB 10.2.2+ (recursive CTE ZORUNLU) · Apache/mod_rewrite

Nasıl kurulur?

  1. MySQL sürümünüzü kontrol edin: SELECT VERSION(); — recursive CTE için 8.0+ gerekir.
  2. Depoyu indirin ya da ZIP olarak açın.
  3. Veritabanını kurun: mysql -u root -p < cy_tree.sql (dosya veritabanını kendisi oluşturur).
  4. Projeyi bir web sunucusu altına koyun ya da php -S 127.0.0.1:8000 ile çalıştırın.
  5. Tarayıcıda açın; 35 kategori ve 55 ürün dolu bir ağaç gelir.
  6. Satırları sürükleyerek taşıyın; döngü ve derinlik kurallarını deneyin.
  7. Canlıya alırken system/config.local.php.example dosyasını config.local.php olarak kopyalayıp künyeyi oraya yazın.

Veritabanı şeması projedeki cy_tree.sql dosyasında.

Nasıl çalışıyor?

Bu Proje Nedir?

Kategori ağacı her projede karşınıza çıkar ve neredeyse her seferinde aynı üç soruyla birlikte gelir:

  1. Nasıl saklayacağım? — parent_id mi, lft/rgt mi, ayrı bir ilişki tablosu mu?
  2. Tüm alt ağacı nasıl getireceğim? — özyinelemeli PHP döngüsüyle 40 sorgu atmadan
  3. Kullanıcı bir dalı kendi altına taşırsa ne olacak?

Üçüncüsü en sinsisidir. parent_id'yi yanlış bir değere yazmak hiçbir hata üretmez: yabancı anahtar memnundur, çünkü gösterilen id gerçekten vardır. Ama o an ağaç köke bağlı olmayan bir halkaya dönüşür; kökten inen sorgu oraya hiç ulaşamadığı için dal ekrandan tamamen kaybolur ve veritabanında yetim bir döngü olarak kalır. Kimse bir şey fark etmez — ta ki "ürünlerin yarısı nerede?" diye sorulana kadar.

Bu proje üçünü de cevaplıyor: adjacency list ile saklıyor (yazması ucuz), recursive CTE ile tek sorguda okuyor (MySQL 8 sayesinde artık pahalı değil) ve her taşımada döngü ile derinliği sunucuda doğruluyor.

Bir de dördüncü soruyu cevaplıyor: "Bu kategoride kaç ürün var?" Cevap iki farklı sayıdır ve ikisi de doğrudur — doğrudan bağlı olanlar, ve alt ağacın tamamı. Menülerde gösterilmesi gereken ikincisidir; ekranda ikisi birden durur.

Kimler için uygun?

  • Kategori, menü, departman, yorum veya klasör ağacı kuracaklar
  • parent_id ile başlayıp "tüm alt ağacı nasıl getiririm?" duvarına toslayanlar
  • Nested set / closure table arasında karar vermeye çalışanlar
  • Sürükle-bırak sıralamayı dış kütüphanesiz yazmak isteyenler
  • Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir tasarım kalıbı arayanlar
Klonla, cy_tree.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.

Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.


Dört Kritik Karar

1) Adjacency list — ve neden nested set değil

Ağaç saklamanın üç klasik yolu vardır:

ModelOkumaYazmaBedeli
Adjacency list (bu proje)Recursive CTE gerekirBir dalı taşımak tek UPDATEMySQL 8.0+ şartı
Nested set (lft/rgt)Çok hızlı, düz BETWEENTek ekleme tablonun yarısını güncellerSık değişen ağaçta felaket
Closure tableÇok hızlıGüvenli ama iki tabloN düğüm için O(N·derinlik) satır

Nested set'in cazibesi okuma hızıdır; ama bir yönetim ekranında ağaç sürekli değişir ve her sürükle-bırakta yüzlerce satır güncellemek kabul edilebilir değildir. MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını —"tüm alt ağacı getir"— ortadan kaldırdığı için ödünleşim artık nettir.

WITH RECURSIVE tree AS (
    SELECT id, parent_id, name, 0 AS depth,
           CAST(LPAD(position, 5, '0') AS CHAR(500)) AS sort_path
    FROM categories WHERE parent_id IS NULL
    UNION ALL
    SELECT c.id, c.parent_id, c.name, t.depth + 1,
           CONCAT(t.sort_path, '.', LPAD(c.position, 5, '0'))
    FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY sort_path

sort_path bu sorgunun en işlevsel parçasıdır: her seviyenin sırası 5 haneye sıfırlanıp birleştirilir (00000.00002.00001). Bu dizeye göre sıralamak, ağacın derinlik öncelikli doğru sırasını —yani ekranda göründüğü sırayı— veritabanında üretir. PHP'de yeniden sıralamak, aynı işi ikinci kez yapmak olurdu.

2) Döngü koruması: yabancı anahtarın göremediği hata

parent_id'yi kendi torununa yazmak hiçbir kısıtı ihlal etmez — gösterilen id gerçekten vardır. Ama ağaç o an köke bağlı olmayan bir halkaya döner:

Bilgisayar → Bileşenler → İşlemci → Bilgisayar → …

Kökten inen CTE bu halkaya hiç ulaşamaz; dal ekrandan tamamen kaybolur. Korumak için taşımadan önce hedefin, taşınan düğümün torunu olup olmadığına bakıyoruz:

if (isset(descendant_ids($db, $id)[$newParent])) {
    json_error('Bir kategori kendi alt kategorisinin altına taşınamaz (döngü oluşurdu).', 422);
}

Kontrol transaction'ın İÇİNDE yapılır. Dışarıda yapılsaydı iki eşzamanlı taşıma birbirini geçersiz kılabilirdi: ikisi de kendi kontrolünü geçer, ikisi de yazılır ve ortaya kontrolün engellemeye çalıştığı döngü çıkardı.

3) Derinlik: hedefin seviyesi değil, dalın yüksekliği

Naif kontrol hedefin derinliğine bakar. Doğrusu taşınan alt ağacın kendi yüksekliğini de hesaba katar:

$newDepth = node_depth($db, $newParent) + subtree_height($db, $id);
if ($newDepth > MAX_DEPTH) { … }

3 seviyelik bir dalı 3. seviyeye taşımak, dalın en alt yaprağını 6. seviyeye indirir. Yalnızca hedefe bakan bir kontrol bunu kaçırır ve sınır bir işe yaramaz. Hata mesajı da bu yüzden somuttur: "bu dal 7. seviyeye inerdi".

4) Slug: global değil, ebeveyn altında benzersiz

elektronik/aksesuar ile telefon/aksesuar aynı anda var olabilmelidir — URL yolları farklıdır, ortada çakışma yoktur. Global benzersizlik dayatmak kullanıcıyı aksesuar-2 gibi adlara zorlardı.

UNIQUE KEY `uq_cat_parent_slug` (`parent_id`, `slug`)

Bunun bir bedeli vardır ve ilk sürümde bu bedel bir hataya dönüşmüştü: taşıma slug çakışması üretebilir. telefon/aksesuar düğümünü elektronik altına taşımak, orada zaten bir aksesuar varsa 23000 hatası verir ve kullanıcı hiçbir açıklaması olmayan bir 500 görür. Doğru davranış taşımayı reddetmek değil, hedefte çakışmayan bir slug üretmektir:

$slug = $oldParent === $newParent
    ? $cur['slug']
    : unique_slug($db, $cur['slug'], $newParent, $id);   // → "aksesuar-2"

Aynı sorun reparent silme stratejisinde de vardır — yukarı çıkan her çocuk için slug yeniden üretilir.


Neler Var?

Ağaç çekirdeği

  • Adjacency list + recursive CTE ile tek sorguda okuma
  • sort_path ile derinlik öncelikli sıralama veritabanında
  • Alt ağaç ürün sayaçları (doğrudan / toplam) yine aynı sorguda
  • Döngü koruması — transaction içinde doğrulanır
  • Derinlik koruması — dalın yüksekliği hesaba katılır
  • Slug ebeveyn altında benzersiz; taşımada otomatik yenilenir
  • position değerleri her işlemden sonra 0'dan sıkıştırılır
  • Üç silme stratejisi: engelle / bir üste taşı / tüm dalı sil
  • Ürünü olan kategori hiçbir stratejide silinemez (ON DELETE RESTRICT)

Arayüz ve altyapı

  • Dış kütüphanesiz sürükle-bırak (HTML5 DnD API)
  • Bırakma noktasının yarısına göre "araya gir" / "altına gir"
  • Aç-kapat; açık/kapalı dallar tazelemeler arasında korunur
  • Arama filtrelemez, vurgular ve dalları açar (Türkçe duyarsız)
  • Sayaç şeridi; derinlik sınırına dayanınca uyarı rengine geçer
  • Ağacı CSV indir (girintili ad + tam yol sütunu)
  • Paylaşılabilir derin bağlantı: #kategori-11
  • Form modalı (prompt() yok), silme modalı, detay modalı
  • Çift gönderim koruması, toast bildirimleri, aria-live
  • Üç ayrı hız sınırı kovası (read / write / export)
  • Mobil: 767 / 480px eşikleri, yatay kaydırma yok

Güvenlik: Neyi, Nasıl Kapattık?

AçıkTipik hatalı kodBu projede
SQL Injection"... WHERE parent_id = ".$_POST['pid']Tüm sorgular prepared statement, EMULATE_PREPARES = false. Silme stratejisi DELETE_STRATEGIES beyaz listesinden geçer; id'ler filter_input(..., FILTER_VALIDATE_INT) ile doğrulanır.
XSS (ağaç satırlarında)$li.html(node.name)Kategori adı doğrudan kullanıcı verisidir. Sunucuda e(), istemcide esc(). Ağaç satırları dizgi birleştirerek kuruluyor — tam da bu yüzden her değer kaçışlanır.
CSRF(genelde hiç yok)Oturuma bağlı 32 baytlık token, her istekte, hash_equals() ile sabit zamanlı doğrulama. Token'sız istek → HTTP 403.
Döngü (veri bozulması)UPDATE ... SET parent_id = ?Yabancı anahtar bunu engellemez. Taşımadan önce torun kontrolü yapılır ve kontrol transaction içindedir.
Sınırsız derinlik(hiç kontrol yok)MAX_DEPTH; hedefin derinliği artı taşınan dalın yüksekliği.
Benzersizlik çakışmasıHer 23000'i aynı saymaSlug çakışması önlenir (taşımada ve reparent'ta yeniden üretilir), hataya bırakılmaz.
Yanlışlıkla toplu silmeSessiz CASCADEStrateji kullanıcıya sorulur ve kaç kaydı etkileyeceği önceden yazılır. Ürünü olan kategori hiçbir stratejide silinmez.
Yarım kalan taşımaArdışık UPDATE'lerTaşıma ve sıra sıkıştırma tek transaction; biri başarısız olursa hepsi geri alınır.
Bozuk kodlamanın yanıtı yutmasıjson_encode() → false → boş gövdeJSON_INVALID_UTF8_SUBSTITUTE: tek bozuk bayt yüzünden 35 düğümün tamamı kaybolmaz.
CSV formül enjeksiyonuHücreyi olduğu gibi yazmak=, +, -, @ ile başlayan hücrelerin başına tek tırnak konur — =1+1 adlı bir kategori hiçbir kuralı çiğnemez ama Excel'de çalışan bir hücreye dönüşürdü.
Kaynak tüketimiSınırsız istekRATE_LIMIT_READ / WRITE / EXPORT — ayrı kovalar; ucuz bir okuma isteği pahalı bir işlemin hakkını yemez.
Bilgi sızıntısıEkrana basılan MySQL hatalarıAPP_DEBUG sunucu adından türetilir; canlıda otomatik false, detay error_log()'a gider.
Kurulum dosyasının indirilmesi/cy_tree.sql → HTTP 200.htaccess: .sql, .md, .json, .log … kapalı (README dosyaları bilinçli istisnadır).

Yapılandırma

Tüm ayarlar system/config.php içindedir.

SabitVarsayılanNe işe yarar
MAX_DEPTH5En fazla derinlik (kök = 0). Ekleme ve taşımada uygulanır
DELETE_STRATEGIESblock, cascade, reparentİzin verilen silme stratejileri (beyaz liste)
DELETE_DEFAULTblockSilme modalında açılışta seçili gelen strateji
RATE_LIMIT_READ[120, 60]Ağaç okuma isteği sınırı
RATE_LIMIT_WRITE[80, 60]Ekleme / taşıma / silme sınırı
RATE_LIMIT_EXPORT[10, 60]CSV dışa aktarım sınırı (en pahalısı)

Derinlik sınırını değiştirmek

MAX_DEPTH değerini büyütmek yeterlidir; hem ekleme hem taşıma aynı sabiti okur. Sınırsız derinlik teknik olarak mümkündür ama önerilmez: menü ve breadcrumb'lar pratikte bir tavan ister, ve sınırsız derinlik tek bir hatalı taşımanın ağacı okunamaz hâle getirmesine izin verir.

Silme stratejisini sabitlemek

Kullanıcıya sormak istemiyorsanız DELETE_STRATEGIES dizisini tek elemana indirin:

define('DELETE_STRATEGIES', ['block']);
define('DELETE_DEFAULT', 'block');

Sunucu beyaz listeyi zorlar; modaldeki diğer seçenekler gönderilse bile kabul edilmez.

Şifreyi koda yazmayın

Canlı veritabanı künyesi config.php'ye yazılmaz: o dosya depoda durur ve her dağıtımda depodaki sürümle değiştirilir. Bunun yerine yanına config.local.php koyun (.gitignore içindedir):

cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurun

En kısa yol ise depo kökündeki .env dosyasıdır — aynı korumaya sahiptir
(.gitignore içindedir, deploy silmez) ama tek satırda kurulur:

cp .env.example .env        # Windows: copy .env.example .env
# sonra DB_* satırlarını doldurun

Ortam değişkeni de kullanabilirsiniz:

# Linux / macOS
DB_HOST=127.0.0.1 DB_NAME=cy_tree DB_USER=uygulama DB_PASS=gizli php -S 127.0.0.1:8000

# Windows (PowerShell)
$env:DB_PASS="••••••••"; php -S 127.0.0.1:8000

Öncelik sırası: config.local.php → .env → ortam değişkeni → yerel varsayılan.


Kendi Projenize Eklemek

Ağaç katmanını başka bir varlığa (menü, departman, klasör, yorum) taşımak için:

1 — Tabloyu kopyalayın, adını değiştirin

categories tablosunun yapısı varlıktan bağımsızdır. Değişmesi gereken tek şey addır; üç indeksi olduğu gibi taşıyın:

KEY        `idx_menu_parent_pos`  (`parent_id`, `position`),
UNIQUE KEY `uq_menu_parent_slug`  (`parent_id`, `slug`),
CONSTRAINT `fk_menu_parent` FOREIGN KEY (`parent_id`)
    REFERENCES `menu_items` (`id`) ON DELETE CASCADE

2 — function.php içindeki tablo adlarını değiştirin

fetch_tree, descendant_ids, node_depth, subtree_height, unique_slug, normalize_positions — hepsi categories adını kullanır. Tek yerde sabit tanımlayıp oradan okumak da mümkündür.

3 — Sayaç sütununu kendi ilişkinize bağlayın

fetch_tree() içindeki direct ve totals CTE'leri products tablosunu sayar. Sizde bu "menüdeki bağlantı sayısı" ya da "departmandaki personel" olabilir. İhtiyacınız yoksa iki CTE'yi de silin; sorgu yine çalışır.

4 — Derinlik ve silme kurallarını gözden geçirin

Menü ağacında MAX_DEPTH = 3 makuldür; dosya ağacında sınırsıza yakın. Silme stratejisi de değişir: menüde reparent, dosya sisteminde cascade daha doğrudur.

Atlamayın: Döngü kontrolünü ve transaction'ı taşımayı unutursanız, projeyi kopyalamış ama onu değerli kılan tek şeyi almamış olursunuz.

Çılgın Yazılım Tasarım Kalıbı

assets/css/cilginyazilim.css dosyası bu projeye değil markaya aittir. Diğer örnek projelerde de aynı görsel dili kullanabilmek için ayrı bir dosya olarak tutulur; bu örneğe özgü her şey (ağaç, bırakma göstergeleri, sayaç rozetleri, araç çubuğu) assets/css/style.css içindedir.

Hazır bileşenler

SınıfNe işe yarar
.cy-card / .cy-card__header / __body / __footerGradyan başlıklı ana kart
.cy-brand / .cy-brand__mark / __title / __subtitleLogo kutusu + başlık bloğu
.cy-btn + --primary \--onbrand \--glassMarka butonları
.cy-badge + --glass \--softRozetler
.cy-modal / .cy-detailGradyan başlıklı modal ve etiket/değer listesi
.cy-toast + --success \--danger \--infoBildirim balonları
.cy-topbar / .cy-footer-noteÜst şerit ve alt bilgi bloğu

Bu örneğe özgü (style.css): .cy-tree / .cy-tree__row / __toggle / __count, .drop-before / .drop-into, .cy-stats / .cy-stat, .cy-toolbar / .cy-search, .cy-breadcrumb / .cy-crumb, .cy-choice, .cy-btn-icon.

Renkleri değiştirmek

Tüm bileşenler CSS değişkenlerinden beslenir. Tek yeri değiştirmek yeter:

:root {
    --cy-brand-900: #061321;   /* Logodaki en koyu lacivert */
    --cy-brand-600: #0b5cb5;   /* Ana marka mavisi          */
    --cy-accent:    #0ea5e9;   /* Vurgu rengi               */
    --cy-gradient:  linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}

Koyu tema

Kullanıcının işletim sistemi koyu temadaysa otomatik devreye girer. Zorlamak isterseniz:

<html data-cy-theme="dark">   <!-- veya "light" -->

Dosya Yapısı

nested-categories/
├── index.php                 → Arayüz. Veritabanına DOKUNMAZ; tek kart + üç modal.
├── .env.example              → Veritabanı bilgileri (isteğe bağlı) — .gitignore içinde
├── cy_tree.sql               → Şema + 35 kategori + 55 ürün (NOW() - INTERVAL)
├── .htaccess                 → Dizin listeleme kapalı, .sql/.md kapalı (README istisna)
│
├── system/
│   ├── config.php            → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│   ├── config.local.php      → (siz oluşturursunuz) canlı künye — .gitignore'da
│   ├── config.local.php.example
│   ├── function.php          → CSRF, hız sınırı, CTE sorguları, slug, CSV
│   ├── ajax.php              → 8 uç nokta
│   └── .htaccess             → BEYAZ LİSTE: yalnızca ajax.php dışarıya açık
│
├── assets/
│   ├── css/
│   │   ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│   │   ├── style.css         → Yalnızca bu örneğe özgü stiller
│   │   └── bootstrap.min.css
│   ├── js/
│   │   ├── tree.js           → Ağaç, sürükle-bırak, arama, modallar (9 bölüm)
│   │   ├── jquery-3.7.0.js
│   │   └── bootstrap.bundle.js
│   └── images/logo.png
│
├── docs/screenshots/         → README'de kullanılan görseller
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE                   → MIT

Nasıl Çalışıyor?

┌─ OKUMA:  action=tree ────────────────────────────────────────────────┐
│  fetch_tree() — TEK SORGU, üç CTE zinciri:                           │
│                                                                      │
│    tree     → kökten aşağı gezer; depth, sort_path, name_path üretir │
│    direct   → kategori başına DOĞRUDAN ürün sayısı (GROUP BY)        │
│    closure  → ata→torun çiftleri (kategori kendisinin de atasıdır)   │
│    totals   → closure + direct → ALT AĞAÇ toplamları                 │
│                                                                      │
│  ORDER BY sort_path  →  ekranda göründüğü sıra, veritabanında üretilir│
│  ↳ döner: DÜZ liste (iç içe JSON değil)                              │
└──────────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─ İSTEMCİ: ağacı kurar ───────────────────────────────────────────────┐
│  buildTree(): parent_id'ye göre grupla (O(n)) → özyinelemeli <ul>    │
│  Sıralama zaten doğru; gruplama sırayı bozmaz.                       │
│  Açık/kapalı dallar `collapsed` kümesinde saklanır ve her            │
│  tazelemeden sonra geri uygulanır — kullanıcı yerini kaybetmesin.    │
└──────────────────────────────────────────────────────────────────────┘
                              │  sürükle-bırak
                              ▼
┌─ YAZMA:  action=move ────────────────────────────────────────────────┐
│  BEGIN                                                               │
│    SELECT … FOR UPDATE            ← satırı kilitle                   │
│    Kural 1: hedef == kaynak?           → 422                         │
│    Kural 2: hedef, kaynağın torunu mu? → 422  (DÖNGÜ)                │
│    Kural 3: node_depth(hedef) + subtree_height(kaynak) > MAX_DEPTH?  │
│                                        → 422  (DERİNLİK)             │
│    Slug hedefte çakışıyor mu?          → unique_slug() ile yenile    │
│    UPDATE parent_id, slug, position = 999999                         │
│    normalize_positions(eski ebeveyn)   ← boşlukları kapat            │
│    Kardeşleri sırayla al, düğümü index'e sok, position'ları yaz      │
│  COMMIT                                                              │
└──────────────────────────────────────────────────────────────────────┘

Sorumluluk dağılımı

KatmanDosyaSorumluluk
Sunumindex.phpYalnızca HTML iskeleti. Veri yok, sorgu yok.
Arayüz mantığıassets/js/tree.jsAğaç kurma, DnD, arama, modallar, kaçışlama
Uç noktalarsystem/ajax.phpİstek doğrulama, kurallar, transaction, JSON
Alan mantığısystem/function.phpCTE sorguları, slug, sıra sıkıştırma, CSV
Ayarsystem/config.phpSabitler, PDO, APP_DEBUG türetimi

AJAX API Referansı

Tüm istekler system/ajax.php adresine gider. export dışında POST ve CSRF token zorunludur.

tree — tüm ağaç

{
  "success": true,
  "max_depth": 5,
  "nodes": [
    {"id": 1, "parent_id": null, "name": "Elektronik", "slug": "elektronik",
     "position": 0, "depth": 0, "path": "Elektronik",
     "direct_count": 0, "total_count": 31, "child_count": 4}
  ]
}

Liste düzdür ve sort_path'e göre sıralıdır; iç içe yapıyı istemci kurar.

stats — sayaç şeridi

{"success": true, "total": 35, "roots": 5, "max_depth": 4, "leaves": 23, "products": 55}

detail — tek kategori

İstek: id

{
  "success": true,
  "category": {
    "id": 11, "name": "Bileşenler", "slug": "bilesenler",
    "path": "Elektronik / Bilgisayar / Bileşenler",
    "depth": 2, "position": 2,
    "child_count": 3, "sub_cats": 5,
    "direct_count": 0, "total_count": 11,
    "created_at": "25.07.2026 19:26", "is_root": false, "can_nest": true
  }
}

add · rename

add: parent_id (boş = kök), name → {"id": 41, "slug": "giyilebilir-teknoloji"}
rename: id, name → {"slug": "yeni-ad"}

Slug addan otomatik üretilir; aynı ebeveyn altında çakışırsa sonuna numara eklenir (giyilebilir-teknoloji-2). Ad değişmemişse rename hiçbir şey yazmaz ve unchanged: true döner.

Geçersiz ad → 422 ve errors.name.

move — sürükle-bırak

İstek: id, parent_id (boş = kök), position

Başarılıysa {"success": true, "slug": "aksesuar-2"} — slug hedefte çakıştıysa yenilenmiş hâli döner.

Üç hata durumu, hepsi 422:

{"description": "Bir kategori kendi altına taşınamaz."}
{"description": "Bir kategori kendi alt kategorisinin altına taşınamaz (döngü oluşurdu)."}
{"description": "Taşıma en fazla 5 seviye derinliğe izin verir; bu dal 7. seviyeye inerdi.",
 "would_be_depth": 7}

delete — strateji ile

İstek: id, strategy (block \| reparent \| cascade)

İki 409 durumu vardır ve ikisi de arıza değil, kuralın çalışmasıdır:

{"description": "Bu kategorinin 4 alt kategorisi var. Önce onları taşıyın, ya da başka bir silme stratejisi seçin.", "children": 4}
{"description": "Bu kategoride 3 ürün var. Önce ürünleri başka bir kategoriye taşıyın.", "products": 3}

cascade seçilirse ürün kontrolü tüm alt ağacı kapsar.

export — CSV (GET)

?action=export → UTF-8 BOM'lu, ; ayraçlı CSV.

#;Seviye;Ad;Slug;Yol;"Alt kategori";"Doğrudan ürün";"Toplam ürün";Oluşturma
1;0;Elektronik;elektronik;Elektronik;4;0;31;"21.07.2026 19:15"
5;1;"    Bilgisayar";bilgisayar;"Elektronik / Bilgisayar";3;0;16;"23.07.2026 19:15"

Ad sütunu seviyeye göre girintilidir; "Yol" sütunu düz bir tabloda hiyerarşiyi taşıyabilen tek şeydir.

HTTP durum kodları

KodAnlamı
200İşlem başarılı
400Geçersiz parametre (örn. hatalı ID)
403CSRF token geçersiz veya oturum düşmüş
404Kategori bulunamadı
405POST dışı istek
409Silme engellendi — alt kategorisi ya da ürünü var
422Döngü, derinlik aşımı ya da geçersiz ad
429Hız sınırı aşıldı (retry_after saniye döner)
500Sunucu / veritabanı hatası (recursive CTE desteklenmiyor olabilir)

Veritabanı Şeması

CREATE TABLE `categories` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `parent_id`  INT UNSIGNED NULL DEFAULT NULL,
  `name`       VARCHAR(100) NOT NULL,
  `slug`       VARCHAR(120) NOT NULL,
  `position`   INT UNSIGNED NOT NULL DEFAULT 0,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_cat_parent_pos` (`parent_id`, `position`),
  UNIQUE KEY `uq_cat_parent_slug` (`parent_id`, `slug`),
  CONSTRAINT `fk_cat_parent` FOREIGN KEY (`parent_id`)
    REFERENCES `categories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE `products` (
  `id`          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `category_id` INT UNSIGNED NOT NULL,
  `name`        VARCHAR(150) NOT NULL,
  `price`       DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  `created_at`  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_prod_category` (`category_id`),
  CONSTRAINT `fk_prod_category` FOREIGN KEY (`category_id`)
    REFERENCES `categories` (`id`) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
KararNeden
parent_id NULL = kökAyrı bir "kök" satırı tutmaktansa NULL kullanmak, WHERE parent_id IS NULL ile kökleri tek koşulda verir ve sahte bir satırı her sorguda dışlamak gerekmez
uq_cat_parent_slug (parent_id, slug)elektronik/aksesuar ile telefon/aksesuar aynı anda var olabilmelidir. Global benzersizlik kullanıcıyı aksesuar-2 gibi adlara zorlardı
idx_cat_parent_pos (parent_id, position)Kardeşleri sıralı getiren her sorgunun dayandığı indeks. Recursive CTE'nin her adımı bu indeksi kullanır
position sıkıştırılır (0,1,2…)Boşluklu bırakmak (10, 20, 30) ilk bakışta cazip ama zamanla çakışır ve "aradaki boşluk bitti" durumunda yine yeniden numaralandırma gerekir
ON DELETE CASCADE (kategori→kategori)cascade stratejisi seçildiğinde alt ağacı veritabanı temizler; uygulama kodunun özyinelemeli silme yazmasına gerek kalmaz
ON DELETE RESTRICT (ürün→kategori)Kategori bir etikettir, ürün ise veridir. Etiketi silmek veriyi yok etmemeli — veritabanı bunu uygulama koduna güvenmeden garanti eder
delimiter/order gibi adlardan kaçınmaposition yerine order yazmak, her sorguda backtick zorunluluğu getirirdi (ORDER ayrılmış kelimedir)
InnoDBTransaction ve yabancı anahtar desteği — taşımanın atomik olmasının ön şartı

Sık Sorulanlar

Neden nested set (lft/rgt) kullanmıyorsunuz? Okuması daha hızlı değil mi?

Okuması daha hızlı, yazması felakettir. Nested set'te tek bir kategori eklemek, o noktadan sonraki bütün satırların lft/rgt değerlerini güncellemeyi gerektirir — 500 düğümlük bir ağaçta ortaya bir ekleme için 250 satırlık UPDATE çıkar. Sürükle-bırakla sürekli değişen bir yönetim ekranında bu yanlış ödünleşimdir.

MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını kapattığı için karar artık nettir. Ağacınız neredeyse hiç değişmiyor ve günde milyonlarca kez okunuyorsa nested set hâlâ savunulabilir; o zaman bile closure table daha dengeli bir seçenektir.

Recursive CTE olmayan bir MySQL'de (5.7) ne yapabilirim?

Üç seçenek var:

  1. PHP'de özyineleme: tüm tabloyu tek sorguyla çekin (SELECT * FROM categories) ve ağacı bellekte kurun. Birkaç bin düğüme kadar tamamen makuldür ve aslında tek sorgudur — CTE'den tek farkı işin nerede yapıldığıdır.
  2. path sütunu: her satıra 1/5/11/ gibi bir materyalize yol yazın; alt ağaç WHERE path LIKE '1/5/%' olur. Ucuz okuma, ama taşımada tüm alt ağacın yolunu güncellemek gerekir.
  3. Closure table: ata-torun çiftlerini ayrı tabloda tutun.

Bu proje bilinçli olarak birincisini değil CTE'yi seçti, çünkü anlatmak istediği şey tam olarak MySQL 8'in bu işi artık veritabanında yapabildiğidir.

Döngü kontrolünü atlarsam gerçekten ne olur?

Hiçbir hata almazsınız — sorun tam olarak budur. Yabancı anahtar memnundur, çünkü parent_id var olan bir id'yi gösterir. Ama o dal artık kökten erişilemez: WHERE parent_id IS NULL ile başlayan CTE halkaya hiç ulaşamaz, dolayısıyla dal ekrandan tamamen kaybolur.

Veritabanında hâlâ durur, sorguyla bulunabilir, ama arayüzde yoktur. Genellikle aylar sonra "şu 40 ürün nerede?" diye sorulduğunda fark edilir. Bu yüzden kontrol hem yapılır hem de transaction içinde yapılır.

Bir kategoriyi silince ürünleri ne oluyor?

Hiçbir şey — çünkü silme gerçekleşmiyor. Ürünü olan bir kategori hiçbir stratejide silinemez; uygulama önce sayar ve anlaşılır bir mesajla 409 döner, veritabanı da ON DELETE RESTRICT ile aynı kuralı bağımsız olarak zorlar.

cascade stratejisinde kontrol tüm alt ağacı kapsar: "Bu dalda 16 ürün var" der ve hiçbir şeye dokunmaz. Önce ürünleri başka bir kategoriye taşımanız gerekir.

Arama neden ağacı filtrelemiyor?

Çünkü ağaçta konum bilginin kendisidir. aksesuar araması iki sonuç bulur; ikisini de listeleyip bağlamdan koparmak "hangisi?" sorusunu cevapsız bırakırdı. Eşleşenleri vurgulayıp onlara giden dalları açmak, hem sonucu hem de sonucun nerede olduğunu aynı anda gösterir.

Arama Türkçe duyarsızdır: İŞLEMCİ yazmak İşlemciyi bulur. JavaScript'in toLowerCase()'i Türkçe I/İ çiftinde beklendiği gibi davranmadığı için harfler tek tek eşlenir (bkz. fold()).

Sürükle-bırak için neden bir kütüphane kullanmadınız?

Gerekmedi. Tarayıcının HTML5 Drag & Drop API'si (dragstart / dragover / drop) bu iş için yeterlidir ve tüm dosya 80 satır kadar tutar. Bir kütüphane eklemek 50–200 KB, bir bağımlılık ve bir sürüm yükümlülüğü getirirdi.

İki incelik vardır ve ikisi de kodda yorumlanmıştır: dragover içinde preventDefault() çağırmazsanız drop hiç tetiklenmez; iç içe listelerde dragstart olayını stopPropagation() ile durdurmazsanız olay atalara kabarır ve yanlış düğüm taşınır.

"Doğrudan / toplam" sayısını her satır için ayrı sorguyla mı hesaplıyorsunuz?

Hayır — 35 düğümde 35 sorgu ederdi. Ata-torun çiftleri tek bir recursive CTE ile üretilip (closure) doğrudan sayılarla birleştiriliyor (totals); ağacın tamamı, sayaçlarıyla birlikte tek sorguda geliyor.

Ağacınız on binlerce düğüme çıkarsa bu sayıları bir sütunda önbelleğe almak (cached_total) ve yalnızca değişimde güncellemek mantıklı olur — ama o noktaya gelmeden yapmak erken optimizasyondur.


Canlı Ortama Alırken

  • [ ] MySQL sürümünün 8.0+ olduğunu doğrulayın (SELECT VERSION();)
  • [ ] APP_DEBUG zaten ortamdan türetiliyor — yine de canlıda false olduğunu doğrulayın
  • [ ] system/config.local.php oluşturun; künyeyi config.php'ye yazmayın
  • [ ] Veritabanı için root yerine sınırlı yetkili bir kullanıcı oluşturun
  • [ ] HTTPS kullanın; session.cookie_secure = 1 ve session.cookie_httponly = 1 ayarlayın
  • [ ] Giriş sistemi ekleyin — kimlik doğrulaması olmayan bir ağaç düzenleme ekranı, herkese açık bir yazma noktasıdır
  • [ ] MAX_DEPTH değerini kendi menü/breadcrumb tasarımınıza göre gözden geçirin
  • [ ] Silme stratejilerini daraltmayı düşünün (DELETE_STRATEGIES); cascade her projede istenmez
  • [ ] Nginx kullanıyorsanız .htaccess çalışmaz; .sql ve .md erişimini sunucu yapılandırmasından kapatın:

```nginx
location ~* \.(sql|log|ini|bak)$ { deny all; }
```

  • [ ] Ağaç on binlerce düğüme çıkacaksa sayaçları önbelleğe almayı planlayın

Sorun Giderme

BelirtiÇözüm
Ağaç hiç yüklenmiyor, 500 dönüyorEn olası sebep: MySQL 8.0'ın altında bir sürüm, recursive CTE desteklenmiyor. SELECT VERSION(); ile kontrol edin — uygulama APP_DEBUG açıkken bu ipucunu mesaja ekler.
"Veritabanına bağlanılamadı"MySQL çalışmıyor veya DB_* bilgileri hatalı. XAMPP panelinden MySQL'i başlatın.
Sürükleyince hiçbir şey olmuyordrop olayı dragover içinde preventDefault() çağrılmazsa tetiklenmez. Kodu değiştirdiyseniz o satırı geri koyun.
Yanlış düğüm taşınıyordragstart içinde stopPropagation() eksik. İç içe <li>'lerde olay atalara kabarır ve dragId en dıştaki kökle ezilir.
HTTP 422 alıyorumBu bir arıza değil: ya döngü (kendi torununun altına taşıma) ya da derinlik aşımı. Mesaj hangisi olduğunu söyler.
HTTP 409 alıyorumSilme engellendi: kategorinin alt kategorileri (strateji block) ya da ürünleri var. Başka bir strateji seçin veya önce içeriği taşıyın.
HTTP 403 dönüyorOturum düşmüş — sayfayı yenileyin. Sunucuda session.save_path yazılabilir olmalıdır.
Türkçe karakterler bozukVeritabanı utf8mb4 değil ya da cy_tree.sql SET NAMES utf8mb4 olmadan içe aktarılmış. Dosyayı yeniden yükleyin.
Aynı ada sahip iki kategori var, biri -2 slug aldıBeklenen davranış: slug aynı ebeveyn altında benzersizdir. Farklı dallarda aynı slug serbesttir.
CSV Excel'de bozuk görünüyorDosya UTF-8 BOM'lu ve ; ayraçlıdır. "Metni Sütunlara Dönüştür" ile açtıysanız ayracı ; seçin.
$ is not definedJavaScript yükleme sırası bozulmuş. jQuery her zaman en başta gelmelidir.

Yol Haritası

  • [ ] Klavyeyle taşıma (ok tuşları) — sürükle-bırakın erişilebilir karşılığı
  • [ ] Çoklu seçim ve toplu taşıma
  • [ ] Kategori birleştirme (iki dalı tek dala indirgeme, ürünleri taşıyarak)
  • [ ] Sayaçların önbelleğe alınması (cached_total sütunu + tetikleyici)
  • [ ] Ağacı JSON olarak dışa/içe aktarma
  • [ ] Kategoriye açıklama, görsel ve SEO alanları
  • [ ] Kullanıcı girişi ve rol tabanlı yetkilendirme
  • [ ] PHPUnit testleri (descendant_ids, node_depth, subtree_height, unique_slug)

Katkı

Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.

📦 Depo: github.com/CilginYazilim/nested-categories

Nasıl katkı sağlarım?Nereden
🐛 Hata bildirIssues
💡 Özellik önerIssues
🔧 Kod gönderPull Requests
❓ Soru sorDiscussions

Katkı ölçütleri

  • Kod açıklamalı olsun. Bu projenin temel amacı öğretmek; yorumsuz kod PR'ı geri döner.
  • Döngü ve derinlik kontrollerini transaction dışına çıkarmayın. Eşzamanlı iki taşımada tam olarak engellemeye çalıştıkları şey oluşur.
  • Yeni bir yazma işlemi eklerken normalize_positions() çağrısını ve transaction'ı unutmayın.
  • Tasarım değişikliklerini style.css üzerinden yapın; cilginyazilim.css markaya aittir ve diğer projelerle ortaktır.
  • Yeni bir dış kütüphane eklemeden önce issue açıp tartışalım — proje bilinçli olarak bağımlılıksızdır.

Lisans

MIT — ticari kullanım dahil serbesttir.

Önce bir deneyin

cilginyazilim.com tarafından ❤ ile geliştirildi

Kaynak kod ağaçtan bir dosya seçin

nested-categories
Dosya seçilmedi

İncelemek istediğiniz dosyayı ağaçtan seçin.

Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).

Sık sorulan sorular

Neden nested set (lft/rgt) yerine adjacency list kullanılıyor?

Nested set okumada hızlıdır ama yazmada felakettir: tek bir kategori eklemek, o noktadan sonraki bütün satırların lft/rgt değerlerini güncellemeyi gerektirir. 500 düğümlük bir ağaçta bir ekleme 250 satırlık UPDATE eder. Sürükle-bırakla sürekli değişen bir yönetim ekranında bu yanlış ödünleşimdir. MySQL 8'in recursive CTE'si adjacency list'in tek zayıf noktasını —"tüm alt ağacı getir"— ortadan kaldırdığı için karar artık nettir.

Döngü koruması olmazsa gerçekten ne olur?

Hiçbir hata almazsınız; sorun tam olarak budur. parent_id'yi kendi torununa yazmak hiçbir kısıtı ihlal etmez, çünkü gösterilen id gerçekten vardır. Ama ağaç o an köke bağlı olmayan bir halkaya döner. Kökten inen sorgu o halkaya hiç ulaşamadığı için dal ekrandan tamamen kaybolur; veritabanında durmaya devam eder ama arayüzde yoktur. Genellikle aylar sonra "şu ürünler nerede?" diye sorulduğunda fark edilir.

Derinlik kontrolü neden hedefin seviyesine bakmakla yetinmiyor?

Çünkü taşınan şey tek bir düğüm değil, bir alt ağaçtır. Üç seviyelik bir dalı üçüncü seviyeye taşımak, o dalın en alt yaprağını altıncı seviyeye indirir. Yalnızca hedefe bakan bir kontrol bunu kaçırır ve sınır işe yaramaz hâle gelir. Bu örnekte hedefin derinliği ile taşınan dalın kendi yüksekliği toplanır ve hata mesajı somut yazılır: "bu dal 7. seviyeye inerdi".

Slug neden global değil de ebeveyn altında benzersiz?

Çünkü "elektronik/aksesuar" ile "telefon/aksesuar" aynı anda var olabilmelidir; URL yolları farklı olduğu için ortada çakışma yoktur. Global benzersizlik dayatmak kullanıcıyı "aksesuar-2" gibi anlamsız adlara zorlardı. Bunun bir bedeli vardır: bir dalı başka bir ebeveynin altına taşımak slug çakışması üretebilir. Doğru davranış taşımayı reddetmek değil, hedefte çakışmayan bir slug üretmektir — kullanıcının niyeti dalı taşımaktı, slug bir yan üründür.

Bir kategoriyi silince ürünlerine ne olur?

Hiçbir şey, çünkü silme gerçekleşmez. Ürünü olan bir kategori hiçbir stratejide silinemez; uygulama önce sayar ve anlaşılır bir mesajla HTTP 409 döner, veritabanı da ON DELETE RESTRICT ile aynı kuralı bağımsız olarak zorlar. "Tüm dalı sil" stratejisinde kontrol alt ağacın tamamını kapsar. Kategori bir etikettir, ürün ise veridir; etiketi silmek veriyi yok etmemelidir.

MySQL 5.7 kullanıyorum, recursive CTE yok. Ne yapabilirim?

Üç seçenek var. Birincisi tüm tabloyu tek sorguyla çekip ağacı PHP'de kurmak; birkaç bin düğüme kadar tamamen makuldür ve yine tek sorgudur, tek fark işin nerede yapıldığıdır. İkincisi her satıra "1/5/11/" gibi materyalize bir yol sütunu yazmak; okuması ucuzdur ama taşımada tüm alt ağacın yolunu güncellemek gerekir. Üçüncüsü closure table kullanmaktır. Bu proje bilinçli olarak CTE'yi seçti, çünkü anlatmak istediği şey MySQL 8'in bu işi artık veritabanında yapabilmesidir.

Bu örneğin daha gelişmişi projenizde mi lazım?

İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.

Projenizi Anlatın

Yorumlar

Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.

Soru sorun veya yorum yazın

Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.

Yayınlanmaz; yalnızca gerektiğinde size ulaşmak için.

En az 10 karakter.

İlgili kod örnekleri

Başlangıç

arayuz-ve-tablo/php-pdo-ajax-crud

PHP PDO Ajax CRUD Örneği

  • PHP 8
  • PDO
  • MySQL
  • Ajax
  • +2

292 inceleme 90 canlı açılış 2 beğeni 2 yorum

Orta

arayuz-ve-tablo/surukle-birak-gorev-panosu

Sürükle-Bırak Görev Panosu

  • PHP 8
  • PDO
  • MySQL
  • Ajax
  • +2

268 inceleme 77 canlı açılış 1 beğeni 2 yorum

Tüm kod örnekleri