الدورات

title


بناء شجرة مرنة في PostgreSQL باستخدام ltree وربطها مع Laravel (مع مثال عملي كامل)

بناء شجرة مرنة في PostgreSQL باستخدام ltree وربطها مع Laravel (مع مثال عملي كامل)

1. مقدمة: لماذا نحتاج ltree؟

في أنظمة مثل LMS، المتاجر الإلكترونية، أو أنظمة المحتوى، نحتاج غالبًا لتمثيل بيانات هرمية (شجرية)، مثل:

  • تصنيفات:
  • برمجة → Web → Laravel
  • هيكل دراسي:
  • مرحلة → صف → مادة → فصل → درس

التعامل مع هذه الهياكل باستخدام حقول parent_id فقط يصبح معقدًا في الاستعلامات (جلب كل الأبناء، كل الأسلاف، عمق الشجرة…).

هنا يأتي دور نوع البيانات ltree في PostgreSQL، الذي يجعل التعامل مع الشجرة أسهل وأسرع على مستوى قاعدة البيانات نفسها.


2. ما هو ltree في PostgreSQL؟

ltree هو نوع بيانات (data type) في PostgreSQL مخصص لتخزين مسارات هرمية مثل:

  • root
  • root.math
  • root.math.algebra
  • root.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:

  • مثال مسار:
  • lms
  • lms.programming
  • lms.programming.php
  • lms.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

تخيل أن لديك الشجرة التالية:

  • lms
  • programming
  • php
  • laravel
  • js
  • vue
  • math
  • algebra
  • geometry

المسارات ستكون:

  • lms
  • lms.programming
  • lms.programming.php
  • lms.programming.php.laravel
  • lms.programming.js
  • lms.programming.js.vue
  • lms.math
  • lms.math.algebra
  • lms.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();