الدورات
title
بناء شجرة مرنة في PostgreSQL باستخدام ltree وربطها مع Laravel (مع مثال عملي كامل)
1. مقدمة: لماذا نحتاج ltree؟
في أنظمة مثل LMS، المتاجر الإلكترونية، أو أنظمة المحتوى، نحتاج غالبًا لتمثيل بيانات هرمية (شجرية)، مثل:
- تصنيفات:
- برمجة → Web → Laravel
- هيكل دراسي:
- مرحلة → صف → مادة → فصل → درس
التعامل مع هذه الهياكل باستخدام حقول parent_id فقط يصبح معقدًا في الاستعلامات (جلب كل الأبناء، كل الأسلاف، عمق الشجرة…).
هنا يأتي دور نوع البيانات ltree في PostgreSQL، الذي يجعل التعامل مع الشجرة أسهل وأسرع على مستوى قاعدة البيانات نفسها.
2. ما هو ltree في PostgreSQL؟
ltree هو نوع بيانات (data type) في PostgreSQL مخصص لتخزين مسارات هرمية مثل:
rootroot.mathroot.math.algebraroot.programming.php.laravel
كل جزء (label) يفصل بينه وبين الآخر نقطة .، ويمكنك استخدام معاملات (operators) قوية للاستعلام:
path @> 'root.math': يعني أنpathيحتويroot.mathكفرع (path is ancestor of given label)'root.math' <@ path: يعني أن'root.math'أسفل / داخلpath(path is descendant)path ~ '*.laravel': البحث باستخدام pattern
هذا يجعل الاستعلامات الخاصة بالأبناء والأسلاف بسيطة وسريعة.
3. تفعيل ltree في PostgreSQL
أول خطوة: تفعيل الإضافة ltree في قاعدة البيانات:
CREATE EXTENSION IF NOT EXISTS ltree;
نفذها مرة واحدة في قاعدة البيانات (من خلال psql أو أي أداة إدارة مثل DBeaver / pgAdmin).
4. تصميم جدول التصنيفات باستخدام ltree
لنفرض لدينا جدول categories لتمثيل شجرة تصنيفات في LMS:
- مثال مسار:
lmslms.programminglms.programming.phplms.programming.php.laravel
4.1. Migration في Laravel
Laravel لا يحتوي ltree() جاهزة في الـ Schema Builder، لذلك نستخدم مزيجًا من Schema و DB::statement.
id();
$table->string('name');
$table->string('slug')->unique();
$table->unsignedBigInteger('parent_id')->nullable();
$table->timestamps();
// مبدئياً نضيف العمود كسلسلة نصية أو نتركه الآن
});
// إضافة عمود path من نوع ltree
DB::statement('ALTER TABLE categories ADD COLUMN path ltree');
DB::statement('CREATE INDEX categories_path_gist ON categories USING GIST (path);');
}
public function down()
{
Schema::dropIfExists('categories');
// يمكنك إبقاء extension إذا كانت مستخدمة في جداول أخرى
// DB::statement('DROP EXTENSION IF EXISTS ltree;');
}
};
نقاط مهمة:
- قمنا بإنشاء index من نوع GIST على العمود
pathلتحسين أداء الاستعلامات الخاصة بالأشجار. - ما زال لدينا
parent_idللمساعدة في بعض العمليات (أو إذا أردت هجينة بين parent_id و ltree).
5. إنشاء Model Category في Laravel
'string',
];
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
}
لاحظ: path في PostgreSQL من نوع ltree، لكنه في Laravel يُتعامل معه كنص (string) عند الجلب والتخزين، وهذا لا يسبب مشكلة.6. منطق بناء المسار (path) عند الإنشاء والتحديث
الفكرة:
- إذا كانت الفئة بدون parent → يكون path مثلاً
lmsأوslug. - إذا كانت الفئة لها parent → path =
parent.path . '.' . slug_child.
6.1. خدمة (Service) لإنشاء وتحديث التصنيفات
name = $data['name'];
$category->slug = $slug;
$category->parent_id = $data['parent_id'] ?? null;
$category->save();
// حساب path
if ($category->parent_id) {
$parent = Category::findOrFail($category->parent_id);
$path = $parent->path . '.' . $slug;
} else {
$path = $slug; // root node
}
// تحديث path في PostgreSQL
$category->path = $path;
$category->save();
return $category;
}
public function update(Category $category, array $data): Category
{
if (isset($data['name'])) {
$category->name = $data['name'];
}
if (isset($data['slug'])) {
$category->slug = $data['slug'];
}
if (array_key_exists('parent_id', $data)) {
$category->parent_id = $data['parent_id'];
}
$category->save();
// إعادة حساب path في حال تغير parent أو slug
$category->refresh();
$this->rebuildPath($category);
return $category;
}
/**
* إعادة بناء path لفئة محددة وكل أبنائها
*/
public function rebuildPath(Category $category): void
{
if ($category->parent_id) {
$parent = Category::findOrFail($category->parent_id);
$category->path = $parent->path . '.' . $category->slug;
} else {
$category->path = $category->slug;
}
$category->save();
// تحديث مسارات الأبناء
foreach ($category->children as $child) {
$this->rebuildPath($child);
}
}
}
7. Controller بسيط للتعامل مع التصنيفات
get();
return response()->json([
'status' => true,
'items' => $categories,
]);
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'exists:categories,id'],
]);
$category = $this->service->create($data);
return response()->json([
'status' => true,
'message' => 'Category created successfully',
'item' => $category,
], 201);
}
public function show(Category $category)
{
return response()->json([
'status' => true,
'item' => $category,
]);
}
}
8. استعلامات شائعة باستخدام ltree في Laravel
8.1. جلب كل الأبناء (subtree) لتصنيف معين
$category = Category::where('slug', 'programming')->firstOrFail();
$descendants = Category::whereRaw('path <@ ?::ltree', [$category->path])
->orderBy('path')
->get();
path <@ ?::ltreeتعني:pathهو descendant للمسار المعطى (بما في ذلك نفسه).
8.2. جلب كل الأسلاف (ancestors) لتصنيف معين
$category = Category::where('slug', 'laravel')->firstOrFail();
$ancestors = Category::whereRaw('?::ltree @> path', [$category->path])
->orderBy('path')
->get();
?::ltree @> pathتعني: المسار المعطى هو ancestor لـpath.
8.3. البحث عن جميع التصنيفات في فرع معين
مثلاً: كل ما تحت lms.programming:
$branch = 'lms.programming';
$branchCategories = Category::whereRaw('path <@ ?::ltree', [$branch])
->orderBy('path')
->get();
9. مثال واقعي لنظام LMS
تخيل أن لديك الشجرة التالية:
lmsprogrammingphplaraveljsvuemathalgebrageometry
المسارات ستكون:
lmslms.programminglms.programming.phplms.programming.php.laravellms.programming.jslms.programming.js.vuelms.mathlms.math.algebralms.math.geometry
باستخدام ltree يمكنك:
- جلب كل مواد البرمجة:
-
WHERE path <@ 'lms.programming' - جلب breadcrumb لمادة Laravel:
- ancestors لـ
'lms.programming.php.laravel' - عرض قائمة شجرية مرتبة عبر
ORDER BY path.
10. تحسينات متقدمة
- ✅ Custom Cast لتحويل
pathإلى مصفوفة labels في Laravel. - ✅ إضافة Scopes في الـ Model لسهولة الاستخدام:
// في Category model
public function scopeDescendantsOf($query, Category $category)
{
return $query->whereRaw('path <@ ?::ltree', [$category->path]);
}
public function scopeAncestorsOf($query, Category $category)
{
return $query->whereRaw('?::ltree @> path', [$category->path]);
}
ثم تستخدمها:
Category::descendantsOf($category)->get(); Category::ancestorsOf($category)->get();