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
Arayüz ve Tablo İleri
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ı.
Veritabanı şeması projedeki cy_tree.sql dosyasında.
Kategori ağacı her projede karşınıza çıkar ve neredeyse her seferinde aynı üç soruyla birlikte gelir:
parent_id mi, lft/rgt mi, ayrı bir ilişki tablosu mu?Üçü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?
parent_id ile başlayıp "tüm alt ağacı nasıl getiririm?" duvarına toslayanlarKlonla, 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.
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.
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 → …if (isset(descendant_ids($db, $id)[$newParent])) {
json_error('Bir kategori kendi alt kategorisinin altına taşınamaz (döngü oluşurdu).', 422);
}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) { … }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`)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"reparent silme stratejisinde de vardır — yukarı çıkan her çocuk için slug yeniden üretilir.
Ağaç çekirdeği
sort_path ile derinlik öncelikli sıralama veritabanındaposition değerleri her işlemden sonra 0'dan sıkıştırılırON DELETE RESTRICT)Arayüz ve altyapı
#kategori-11prompt() yok), silme modalı, detay modalıaria-live| 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). |
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ı) |
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.
Kullanıcıya sormak istemiyorsanız DELETE_STRATEGIES dizisini tek elemana indirin:
define('DELETE_STRATEGIES', ['block']);
define('DELETE_DEFAULT', 'block');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.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# 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:8000config.local.php → .env → ortam değişkeni → yerel varsayılan.
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 CASCADEfunction.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.
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.
| 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.
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);
}
Kullanıcının işletim sistemi koyu temadaysa otomatik devreye girer. Zorlamak isterseniz:
<html data-cy-theme="dark"> <!-- veya "light" -->
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
┌─ 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 │
└──────────────────────────────────────────────────────────────────────┘
| 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 |
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}
]
}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"| 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) |
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ı |
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:
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.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.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.
SELECT VERSION();)APP_DEBUG zaten ortamdan türetiliyor — yine de canlıda false olduğunu doğrulayınsystem/config.local.php oluşturun; künyeyi config.php'ye yazmayınroot yerine sınırlı yetkili bir kullanıcı oluşturunsession.cookie_secure = 1 ve session.cookie_httponly = 1 ayarlayınMAX_DEPTH değerini kendi menü/breadcrumb tasarımınıza göre gözden geçirinDELETE_STRATEGIES); cascade her projede istenmez.htaccess çalışmaz; .sql ve .md erişimini sunucu yapılandırmasından kapatın:```nginx
location ~* \.(sql|log|ini|bak)$ { deny all; }
```
| 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 <li>'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. |
cached_total sütunu + tetikleyici)descendant_ids, node_depth, subtree_height, unique_slug)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 |
normalize_positions() çağrısını ve transaction'ı unutmayın.style.css üzerinden yapın; cilginyazilim.css markaya aittir ve diğer projelerle ortaktır.MIT — ticari kullanım dahil serbesttir.
cilginyazilim.com tarafından ❤ ile geliştirildi
İ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 (••••••••).
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.
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.
Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.
Başlangıç
arayuz-ve-tablo/php-pdo-ajax-crud
292 inceleme 90 canlı açılış 2 beğeni 2 yorum
Orta
arayuz-ve-tablo/surukle-birak-gorev-panosu
268 inceleme 77 canlı açılış 1 beğeni 2 yorum
İleri
arayuz-ve-tablo/datatables-performans-optimizasyonu
184 inceleme 73 canlı açılış 1 beğeni