İç İç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ı.
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 İş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?
- MySQL sürümünüzü kontrol edin: SELECT VERSION(); — recursive CTE için 8.0+ gerekir.
- Depoyu indirin ya da ZIP olarak açın.
- Veritabanını kurun: mysql -u root -p < cy_tree.sql (dosya veritabanını kendisi oluşturur).
- Projeyi bir web sunucusu altına koyun ya da php -S 127.0.0.1:8000 ile çalıştırın.
- Tarayıcıda açın; 35 kategori ve 55 ürün dolu bir ağaç gelir.
- Satırları sürükleyerek taşıyın; döngü ve derinlik kurallarını deneyin.
- 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?
İç İçe Kategoriler (Ağaç Yapı)
PHP PDO · MySQL Recursive CTE · AJAX · Bootstrap 5 · Çılgın Yazılım Tasarım Kalıbı
Sınırsız derinlikte kategori ağacı: tek sorguda oku, sürükleyerek taşı, döngüye izin verme.
🇹🇷 Türkçe · 🇬🇧 English
Canlı Demo
Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.
▲ Görsele tıklayarak demoyu açabilirsinizDemoda 60 saniyede neleri deneyebilirsiniz?
| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|---|---|
| 1 | Elektronik satırındaki 0 / 31 sayısına bakın | Soldaki sayı o kategoriye doğrudan bağlı ürün, sağdaki alt ağacın tamamı. "Elektronik (0)" yazan bir menü kimseye bir şey anlatmaz; doğru sayı ikincisidir |
| 2 | Dizüstü'nü sürükleyip Masaüstü'nün üst yarısına bırakın | Aynı seviyede yer değiştirir. Üst yarı "araya gir", alt yarı "alt kategori yap" demektir — bırakma noktasının yarısı anlamı belirler |
| 3 | Bilgisayar'ı sürükleyip kendi torunu İşlemci'nin üstüne bırakın | HTTP 422 ve net bir mesaj. Bu engel olmasaydı ağaç köke bağlı olmayan bir halkaya dönüşür, dal ekrandan tamamen kaybolurdu |
| 4 | Bilgisayar'ı Android altına taşımayı deneyin | Yine 422: "bu dal 7. seviyeye inerdi". Sunucu yalnızca hedefin derinliğine değil, taşınan dalın yüksekliğine de bakar |
| 5 | Arama kutusuna aksesuar yazın | İki sonuç bulunur ve ikisi de görünür kalır. Arama filtrelemez, vurgular — ağaçta konum bilginin kendisidir; "hangi Aksesuar?" sorusunu ancak konum cevaplar |
| 6 | İki Aksesuar satırının slug'larına bakın | İkisi de /aksesuar. Slug global değil, ebeveyn altında benzersizdir; farklı dallarda aynı ad çakışma değildir |
| 7 | Ses Sistemleri'ni silmeye çalışın (🗑) | Üç strateji sunulur: engelle, bir üste taşı, tüm dalı sil — ve her birinin kaç kaydı etkileyeceği önceden yazar |
| 8 | Dizüstü'nü silmeye çalışın | HTTP 409: içinde 3 ürün var. Hiçbir stratejide ürünü olan kategori silinmez; veritabanı bunu ON DELETE RESTRICT ile de garanti eder |
| 9 | 👁 Göz butonuna basın | Kökten itibaren tam yol, seviye ve dört sayaç açılır. Adres çubuğu #kategori-11 olur — bağlantı paylaşılabilir |
| 10 | Telefonunuzdan açın | Girinti ve dal çizgileri korunur, yatay kaydırma yoktur; slug gizlenir ve detay modalında durur |
İpucu: Demoyu açıkken F12 → Network sekmesini açın. Her taşımadamoveisteğininid,parent_idvepositionalanlarını, dönen JSON'u ve HTTP durum kodlarını (200 / 403 / 409 / 422 / 429) canlı görebilirsiniz.
Demo alanı hakkında bilinmesi gerekenler
| Konu | Durum |
|---|---|
| Veriler | cy_tree.sql içindeki 35 kategori + 55 ürün. Ağaç bilinçli olarak dengesizdir: bir dal 4 seviye iner, diğeri 1 seviyede kalır. |
| Sıfırlama | Demo veritabanı düzenli aralıklarla başlangıç haline döner; yaptığınız taşımalar kalıcı değildir. |
| MySQL sürümü | Recursive CTE gerektirir: MySQL 8.0+ veya MariaDB 10.2.2+. Bu projenin tek gerçek ortam koşuludur. |
| Kimlik doğrulama | Yoktur. Bilinçli bir tercihtir — örnek, ağaç mantığına odaklanır. |
APP_DEBUG | Canlıda otomatik false — sunucu adından türetilir, yerelde true kalır. |
| Bağımlılık | Sıfır. Composer yok, npm yok, CDN yok, sürükle-bırak kütüphanesi bile yok — tarayıcının kendi HTML5 DnD API'si kullanılır. |
Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp cy_tree.sql'i içe aktarmanız aynı ekranı kendi bilgisayarınızda 2 dakikada ayağa kaldırır → Kurulum
Bu Proje Nedir?
Kategori ağacı her projede karşınıza çıkar ve neredeyse her seferinde aynı üç soruyla birlikte gelir:
- Nasıl saklayacağım? —
parent_idmi,lft/rgtmi, ayrı bir ilişki tablosu mu? - Tüm alt ağacı nasıl getireceğim? — özyinelemeli PHP döngüsüyle 40 sorgu atmadan
- 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_idile 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.
İçindekiler
- Canlı Demo
- Ekran Görüntüleri
- Dört Kritik Karar
- Neler Var?
- Güvenlik: Neyi, Nasıl Kapattık?
- Kurulum
- Yapılandırma
- Kendi Projenize Eklemek
- Çılgın Yazılım Tasarım Kalıbı
- Dosya Yapısı
- Nasıl Çalışıyor?
- AJAX API Referansı
- Veritabanı Şeması
- Sık Sorulanlar
- Canlı Ortama Alırken
- Sorun Giderme
- Yol Haritası
- Katkı
- Lisans
Ekran Görüntüleri
Ağaç görünümü
Girinti, kesikli dal çizgileri ve her satırda doğrudan / toplam ürün sayacı. Sayaç şeridi ağacın nabzını verir: kaç kategori, kaç kök, kaç yaprak, en derin seviye kaç (ve sınıra ne kadar kaldı).

Kategori detayı ve tam yol
Kökten itibaren breadcrumb, seviye, kardeşleri arasındaki sıra ve dört sayaç. Adres çubuğu #kategori-11 olur; bağlantıyı gönderdiğinizde karşı taraf doğrudan o kategoriyi açar.

Silme: tek bir "sil" yoktur
Çocuğu olan bir düğümü silmenin üç makul karşılığı vardır ve hangisinin doğru olduğu duruma göre değişir. Bu yüzden strateji bir ayar dosyasına gömülmez, silme anında sorulur — ve her seçeneğin kaç kaydı etkileyeceği modal açılırken hesaplanıp yazılır.

Mobil görünüm
Dar ekranda girinti daraltılır, dal çizgileri ve sayaçlar korunur, slug gizlenir (detay modalında durur). Yatay kaydırma yoktur; dokunma hedefleri en az 32–44px'dir.
Üç modal:
| Modal | Açılış | İçerik |
|---|---|---|
| ✎ Ekle / Yeniden adlandır | + veya kalem butonu | Form, alan bazlı hata mesajı, "nereye eklenecek" satırı, slug açıklaması |
| 🗑 Silme | Çöp kutusu butonu | Üç strateji + her birinin etkisi + ürün koruması uyarısı |
| 👁 Detay | Göz butonu | Breadcrumb, seviye, sıra, alt kategori ve ürün sayaçları, paylaşılabilir #kategori-11 bağlantısı |
Dört Kritik Karar
1) Adjacency list — ve neden nested set değil
Ağaç saklamanın üç klasik yolu vardır:
| Model | Okuma | Yazma | Bedeli |
|---|---|---|---|
| Adjacency list (bu proje) | Recursive CTE gerekir | Bir dalı taşımak tek UPDATE | MySQL 8.0+ şartı |
Nested set (lft/rgt) | Çok hızlı, düz BETWEEN | Tek ekleme tablonun yarısını günceller | Sık değişen ağaçta felaket |
| Closure table | Çok hızlı | Güvenli ama iki tablo | N 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_pathsort_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_pathile 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
positiondeğ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çık | Tipik hatalı kod | Bu 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ı sayma | Slug çakışması önlenir (taşımada ve reparent'ta yeniden üretilir), hataya bırakılmaz. |
| Yanlışlıkla toplu silme | Sessiz CASCADE | Strateji 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şıma | Ardışık UPDATE'ler | Taşı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övde | JSON_INVALID_UTF8_SUBSTITUTE: tek bozuk bayt yüzünden 35 düğümün tamamı kaybolmaz. |
| CSV formül enjeksiyonu | Hü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üketimi | Sınırsız istek | RATE_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). |
Kurulum
Sadece görmek istiyorsanız kurulum gerekmez → Canlı Demoyu açın. Aşağıdaki adımlar projeyi kendi bilgisayarınızda çalıştırmak içindir (~2 dakika).
Gereksinimler
- PHP 8.0+ (
pdo_mysqlvembstringeklentileri) - MySQL 8.0+ veya MariaDB 10.2.2+ — recursive CTE için zorunlu
- Apache (XAMPP / WAMP / Laragon) — ya da PHP'nin yerleşik sunucusu
Adımlar
1 — Projeyi indirin
git clone https://github.com/CilginYazilim/nested-categories.git
cd nested-categories2 — Veritabanını oluşturun
cy_tree.sql veritabanını da kendisi oluşturur; önceden cy_tree adında bir veritabanı açmanıza gerek yok.
mysql -u root -p < cy_tree.sql
İsteğe bağlı — kendi veritabanı bilgileriniz:
cp .env.example .env(Windows:copy .env.example .env) deyipDB_*
satırlarını doldurun. Bu dosya olmadan da çalışır; varsayılanlar yerel bir
XAMPP kurulumuna (root, boş parola) göredir..env.gitignore
içindedir — parolanız depoya gitmez.
phpMyAdmin ile: İçe Aktar → Dosya seç → cy_tree.sql → Başlat
3 — Çalıştırın
php -S 127.0.0.1:8000XAMPP kullanıyorsanız projeyi
htdocs altına koyup şu adresi açın:http://localhost/nested-categories/
4 — Tarayıcıda açın → http://127.0.0.1:8000/
Karşınıza 35 kategori ve 55 ürün dolu, çalışır durumda bir ağaç gelecek.
MySQL sürümünüzü kontrol etmek için: SELECT VERSION(); — 8.0'ın altındaysa recursive CTE çalışmaz ve uygulama size bunu açıkça söyler.
Ortam değişkenleri
Depo kökündeki .env dosyasına yazın; system/config.php dosyasına
hiç dokunmayın:
cp .env.example .env # Windows: copy .env.example .env.env .gitignore içindedir: depoya gönderilmez ve dağıtım (deploy) onusilmez.
system/config.php ise depoda durur ve her dağıtımda depodakisürümle değiştirilir — parolayı oraya yazarsanız hem GitHub'a gider hem de
ilk deploy'da kaybolur.
Dosyayı hiç oluşturmasanız da uygulama çalışır; aşağıdaki varsayılanlar
yerel bir XAMPP kurulumuna göredir.
Değer arama sırası: .env → sunucunun gerçek ortam değişkeni
(Apache SetEnv, systemd…) → buradaki varsayılan.
| Değişken | Varsayılan | Ne işe yarar |
|---|---|---|
DB_HOST | 127.0.0.1 | Veritabanı sunucusu |
DB_NAME | cy_tree | Veritabanı adı |
DB_USER | root | Kullanıcı |
DB_PASS | (boş) | Şifre — koda yazmayın |
APP_TIMEZONE | Europe/Istanbul | PHP'nin saat dilimi |
APP_DEBUG | ortamdan | Hataların ekrana basılıp basılmayacağı |
APP_TIMEZONE neden var? XAMPP'ın php.ini dosyasındaki
date.timezone, MySQL'in kullandığı sistem diliminden farklı olabilir.
Test makinesinde PHP Europe/Berlin, MySQL Europe/Istanbul
kullanıyordu; aynı anı anlatan iki satır bir saat farklı görünüyordu.
Zaman hesapları SQL tarafında yapıldığı için doğruydu, ama ekrana
basılan saat kayıyordu. Artık dilim açıkça sabitleniyor — sunucunuz başka
bir bölgedeyse bu değişkeni tanımlamanız yeterli, koda dokunmayın.
Yapılandırma
Tüm ayarlar system/config.php içindedir.
| Sabit | Varsayılan | Ne işe yarar |
|---|---|---|
MAX_DEPTH | 5 | En fazla derinlik (kök = 0). Ekleme ve taşımada uygulanır |
DELETE_STRATEGIES | block, cascade, reparent | İzin verilen silme stratejileri (beyaz liste) |
DELETE_DEFAULT | block | Silme 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ı doldurunEn 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ı doldurunOrtam 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 CASCADE2 —
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ıf | Ne işe yarar | ||
|---|---|---|---|
.cy-card / .cy-card__header / __body / __footer | Gradyan başlıklı ana kart | ||
.cy-brand / .cy-brand__mark / __title / __subtitle | Logo kutusu + başlık bloğu | ||
.cy-btn + --primary \ | --onbrand \ | --glass | Marka butonları |
.cy-badge + --glass \ | --soft | Rozetler | |
.cy-modal / .cy-detail | Gradyan başlıklı modal ve etiket/değer listesi | ||
.cy-toast + --success \ | --danger \ | --info | Bildirim 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ı
| Katman | Dosya | Sorumluluk |
|---|---|---|
| Sunum | index.php | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | assets/js/tree.js | Ağaç kurma, DnD, arama, modallar, kaçışlama |
| Uç noktalar | system/ajax.php | İstek doğrulama, kurallar, transaction, JSON |
| Alan mantığı | system/function.php | CTE sorguları, slug, sıra sıkıştırma, CSV |
| Ayar | system/config.php | Sabitler, 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ı
| Kod | Anlamı |
|---|---|
200 | İşlem başarılı |
400 | Geçersiz parametre (örn. hatalı ID) |
403 | CSRF token geçersiz veya oturum düşmüş |
404 | Kategori bulunamadı |
405 | POST dışı istek |
409 | Silme engellendi — alt kategorisi ya da ürünü var |
422 | Döngü, derinlik aşımı ya da geçersiz ad |
429 | Hız sınırı aşıldı (retry_after saniye döner) |
500 | Sunucu / 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;
| Karar | Neden |
|---|---|
parent_id NULL = kök | Ayrı 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çınma | position yerine order yazmak, her sorguda backtick zorunluluğu getirirdi (ORDER ayrılmış kelimedir) |
| InnoDB | Transaction 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:
- 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. pathsütunu: her satıra1/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.- 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_DEBUGzaten ortamdan türetiliyor — yine de canlıdafalseolduğunu doğrulayın - [ ]
system/config.local.phpoluşturun; künyeyiconfig.php'ye yazmayın - [ ] Veritabanı için
rootyerine sınırlı yetkili bir kullanıcı oluşturun - [ ] HTTPS kullanın;
session.cookie_secure = 1vesession.cookie_httponly = 1ayarlayın - [ ] Giriş sistemi ekleyin — kimlik doğrulaması olmayan bir ağaç düzenleme ekranı, herkese açık bir yazma noktasıdır
- [ ]
MAX_DEPTHdeğerini kendi menü/breadcrumb tasarımınıza göre gözden geçirin - [ ] Silme stratejilerini daraltmayı düşünün (
DELETE_STRATEGIES);cascadeher projede istenmez - [ ] Nginx kullanıyorsanız
.htaccessçalışmaz;.sqlve.mderiş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üyor | En 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 olmuyor | drop olayı dragover içinde preventDefault() çağrılmazsa tetiklenmez. Kodu değiştirdiyseniz o satırı geri koyun. |
| Yanlış düğüm taşınıyor | dragstart içinde stopPropagation() eksik. İç içe `'lerde olay atalara kabarır ve dragId en dıştaki kökle ezilir. |
| HTTP 422 alıyorum | Bu 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ıyorum | Silme 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üyor | Oturum düşmüş — sayfayı yenileyin. Sunucuda session.save_path yazılabilir olmalıdır. |
| Türkçe karakterler bozuk | Veritabanı 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üyor | Dosya 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 defined` | JavaScript 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_totalsü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 bildir | Issues |
| 💡 Özellik öner | Issues |
| 🔧 Kod gönder | Pull Requests |
| ❓ Soru sor | Discussions |
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.cssmarkaya 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.
Kaynak Kod soldaki ağaçtan bir dosya seçin
-
assets
-
css
- bootstrap.min.css 227.5 KB
- cilginyazilim.css 22 KB
- style.css 15.3 KB
-
images
- logo.png 70.4 KB
-
js
- bootstrap.bundle.js 203.2 KB
- jquery-3.7.0.js 278.3 KB
- tree.js 27.8 KB
-
-
docs
-
screenshots
- 01-agac.png 271.4 KB
- 02-kategori-detay.png 163.6 KB
- 03-silme-stratejisi.png 177 KB
- 04-mobil.png 177.3 KB
-
-
system
- .htaccess 1.7 KB
- ajax.php 18.8 KB
- config.local.php.example 1.5 KB
- config.php 10.7 KB
- function.php 15.6 KB
- .gitignore 1.1 KB
- .htaccess 3.6 KB
- CHANGELOG.md 10.2 KB
- cy_tree.sql 15.8 KB
- index.php 16.4 KB
- LICENSE 1.1 KB
- README.en.md 46.4 KB
- README.md 48.6 KB
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli
değerler gösterilmeden önce maskelenir (••••••••).
Sık Sorulan Sorular
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.
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.
Çü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".
Çü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.
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.
Üç 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.
Yorumlar 0 konuşma
Bu kod örneğine henüz yorum yapılmamış. Takıldığınız bir yer veya merak ettiğiniz bir ayrıntı varsa ilk soruyu siz sorun.
Soru Sor veya Yorum Yaz
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.