Я участвую в разработке ERP системы, в детали посвящать вас не буду, это тайна за семью печатями, но скажу лишь одно, древовидных справочников у нас много и вложенность их не ограничена, кол-во в некоторых составляет несколько тысяч, а работать с ними надо максимально эффективно и удобно, как с точки зрения кода, так с точки зрения производительности.
Что нам нужно
-
Быстро доставать любые срезы дерева и его ветвей, как вверх к родителям, так и вниз к листьям
-
Уметь также доставать все узлы, кроме листьев
-
Дерево должно быть консистентным, т.е. не иметь пропущенных узлов в иерархии родителей
-
Материализованные пути должны строиться автоматом
-
При перемещении или удалении узла, обновляются также все дочерние узлы и перестраиваются их пути
-
Научиться из плоской коллекции, быстро построить дерево.
-
Так как справочников много, компоненты должны быть переиспользуемые
-
Так как планируется выносить в гитхаб, задействовать абстракции и интерфейсы.
Так как мы используем Postgres, выбор пал на ltree, подробнее о том, что это такое можно прочитать в конце статьи.
Установка расширения
Пример миграции для создания расширения
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\DB; class CreateLtreeExtension extends Migration { public function up(): void { DB::statement('CREATE EXTENSION IF NOT EXISTS LTREE'); } public function down(): void { DB::statement('DROP EXTENSION IF EXISTS LTREE'); } }
Задача
Например, у нас есть товары и есть категории товаров. Категории представляют собой дерево и могут иметь подчиненные категории, уровень вложенности неограничен и может быть любой. Допустим это будут таблицы.
Категории товаров (categories)
|
id |
bigserial |
PK |
|
path |
ltree |
материализованный путь |
|
parent_id |
biginteger |
родительская категория |
В path будут храниться материализованные пути, пример
id: 1
path: 1
parent_id: null
id: 2
path: 1.2
parent_id: 2
и тд..
Товары (products)
|
id |
bigserial |
PK |
|
category_id |
biginteger |
категория |
Если вы уже используете пакет для Postgres, значит вы уже знаете, что в добавляя новый extension, у нас появляется новый тип данных для Doctrine и его нужно зарегистрировать, сделать это не сложно, достаточно через composer установить этот пакет и тогда это бремя за вас сделает провайдер, который автоматом будет зарегистрирован:
composer require umbrellio/laravel-ltree
Пример миграции, использованием нового типа в Postgres
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Umbrellio\Postgres\Schema\Blueprint; class CreateCategoryExtension extends Migration { public function up(): void { Schema::table('categories', function (Blueprint $table) { $table->bigIncrements('id'); $table->bigInteger('parent_id')->nullable(); $table->ltree('path')->nullable(); $table ->foreign('parent_id') ->references('id') ->on('categories'); $table->index(['parent_id']); $table->unique(['path']); }); DB::statement("COMMENT ON COLUMN categories.path IS '(DC2Type:ltree)'"); } public function down(): void { Schema::drop('categories'); } }
При такой структуре самое простое, что приходит на ум, чтобы достать все дерево категорий товаров, это такой запрос:
SELECT * FROM categories;
Но, это нам вернет плоский список категорий, а отрисовать их, разумеется, нам надо в виде дерева. Поэтому самое простое решение, если бы мы не использовали ltree, был бы такой запрос:
SELECT * FROM categories WHERE parent_id IS NULL
Иными словами, только корневые категории. Ну а далее, используя рекурсию, отрисовывая корневые категории, будем дергать запрос для получения дочерних категорий, передавая туда ID категории:
SELECT * FROM categories WHERE parent_id = <ID>
Но, как вы видите, это долго, муторно и совсем не интересно, когда у нас в распоряжении есть ltree. С ним становится все гораздо проще, чтобы достать все дочерние категории, начиная от корня, ну или не от корня, а от произвольного узла, достаточно такого запроса:
SELECT * FROM categories WHERE path @> text2ltree('<ID>')
Вернемся к Laravel
Для начала напишем интерфейс древовидной модели и пару методов, для работы с ltree. Как вы их писать через абстрактный класс или через трейт не так важно, дело вкуса каждого, я выбираю трейты, т.к. если мне понадобится унаследовать модели не от Eloquent\Model я всегда смогу это сделать.
Пример интерфейса: LTreeInterface
<?php namespace Umbrellio\LTree\Interfaces; interface LTreeInterface { public const AS_STRING = 1; public const AS_ARRAY = 2; public function getLtreeParentColumn(): string; public function getLtreeParentId(): ?int; public function getLtreePathColumn(): string; public function getLtreePath($mode = self::AS_ARRAY); public function getLtreeLevel(): int; }
Пример трейта: LTreeTrait
<?php trait LTreeTrait { abstract public function getAttribute($key); public function getLtreeParentColumn(): string { return 'parent_id'; } public function getLtreePathColumn(): string { return 'path'; } public function getLtreeParentId(): ?int { $value = $this->getAttribute($this->getLtreeParentColumn()); return $value ? (int) $value : null; } public function getLtreePath($mode = LTreeInterface::AS_ARRAY) { $path = $this->getAttribute($this->getLtreePathColumn()); if ($mode === LTreeModelInterface::AS_ARRAY) { return $path !== null ? explode('.', $path) : []; } return (string) $path; } public function getLtreeLevel(): int { return is_array($path = $this->getLtreePath()) ? count($path) : 1;
Пример модели, реализующей интерфейс LTreeInterface: Category
<?php final class Category extends Model implements LTreeInterface { use LTreeTrait; protected $table = 'categories'; protected $fillable = ['parent_id', 'path']; protected $timestamps = false; }
Теперь, добавим несколько скоупов для удобной работы с материализованными путями:
Пример трейта: LTreeTrait
<?php use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Umbrellio\LTree\Collections\LTreeCollection; use Umbrellio\LTree\Interfaces\LTreeModelInterface; trait LTreeTrait { //... public function scopeParentsOf(Builder $query, array $paths): Builder { return $query->whereRaw(sprintf( "%s @> array['%s']::ltree[]", $this->getLtreePathColumn(), implode("', '", $paths) )); } public function scopeRoot(Builder $query): Builder { return $query->whereRaw(sprintf('nlevel(%s) = 1', $this->getLtreePathColumn())); } public function scopeDescendantsOf(Builder $query, LTreeModelInterface $model): Builder { return $query->whereRaw(sprintf( "({$this->getLtreePathColumn()} <@ text2ltree('%s')) = true", $model->getLtreePath(LTreeModelInterface::AS_STRING), )); } public function scopeAncestorsOf(Builder $query, LTreeModelInterface $model): Builder { return $query->whereRaw(sprintf( "({$this->getLtreePathColumn()} @> text2ltree('%s')) = true", $model->getLtreePath(LTreeModelInterface::AS_STRING), )); } public function scopeWithoutSelf(Builder $query, int $id): Builder { return $query->whereRaw(sprintf('%s <> %s', $this->getKeyName(), $id)); }
Где:
-
scopeAncestorsOf — позволяет доставать нам всех родителей вверх до корня (включая текущий узел)
-
scopeDescendantsOf — позволяет доставать всех детей вниз до листика (включая текущий узел)
-
scopeWithoutSelf — исключает текущий узел
-
scopeRoot — позволяет достать только корневые узлы 1-ого уровня
-
scopeParentsOf — почти тоже самое что и scopeAncestorsOf, только для нескольких узлов.
Т.е. добавив трейт к модели, она уже умеет при помощи нехитрых манипуляций работать с отдельными ветками, получать родителей, детей и тд.
Усложним задачу
Представим, что товары могут лежать не только в листиках, но и в промежуточных узлах. На входе мы имеем категорию — уровня листик, какого уровня вложенности мы не знаем, да это и не важно. Нужно достать все товары из категории, а также из всех родительских категорий этого листика.
Самое простое, что приходит на ум, нам нужно достать все идентификаторы категорий, а потом запросить товары находящиеся в выбранных категориях:
<?php // ID категории (листика) = 15 $categories = Category::ancestorsOf(15)->get()->pluck('id')->toArray(); $products = Product::whereIn('category_id', $caregories)->get();
Рисуем дерево
Да, мы научились работать с древовидной структурой на уровне БД, но как же отрисовать дерево, все еще не понятно.
Задача все та же, это должен быть 1 запрос и нарисовать мы должны дерево, вернемся к запросу:
SELECT * FROM categories;
Для того, чтобы используя этот запрос, у нас было дерево, первое что приходит на ум, это нам надо каким-то образом преобразовать массив вида:
<?php $a = [ [1, '1', null], [2, '1.2', 1], [3, '1.2.3', 2], [4, '4', null], [5, '1.2.5', 2], [6, '4.6', 4], // ... ];
К такому:
<?php $a = [ 0 => [ 'id' => 1, 'level' => 1, 'children' => [ 0 => [ 'id' => 2, 'level' => 2, 'children' => [ 0 => [ 'id' => 3, 'level' => 3, 'children' => [], ], 1 => [ 'id' => 5, 'level' => 3, 'children' => [], ], ] ] ] ], 1 => [ 'id' => 4, 'level' => 1, 'children' => [ 0 => [ 'id' => 6, 'level' => 2, 'children' => [], ] ] ] ];
Тогда при помощи обычной рекурсии мы смогли бы отрисовать дерево, пример грубый, написан на коленке, но это сейчас не важно:
<?php $categories = Category::all()->toTree(); // Collection function renderTree(Collection $collection) { /** @var LTreeNode $item */ foreach ($collection as $item) { if ($item->children->isNotEmpty()) { renderTree($item->children); return; } } echo str_pad($item->id, $item->level - 1, "---", STR_PAD_LEFT) . PHP_EOL; }
Немного изменений
Доработаем нашу модель, а именно трейт, добавив в него методы для гидрации в особую коллекцию:
<?php trait LTreeTrait { //... public function newCollection(array $models = []): LTreeCollection { return new LTreeCollection($models); } public function ltreeParent(): BelongsTo { return $this->belongsTo(static::class, $this->getLtreeParentColumn()); } public function ltreeChildren(): HasMany { return $this->hasMany(static::class, $this->getLtreeParentColumn()); } }
Данный метод будет возвращать специальную коллекцию, получив на входе плоский массив, преобразует его к дереву, когда это будет необходимо.
Пример коллекции: LTreeCollection
<?php use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Umbrellio\LTree\Helpers\LTreeBuilder; use Umbrellio\LTree\Helpers\LTreeNode; use Umbrellio\LTree\Interfaces\HasLTreeRelations; use Umbrellio\LTree\Interfaces\LTreeInterface; use Umbrellio\LTree\Interfaces\ModelInterface; use Umbrellio\LTree\Traits\LTreeModelTrait; class LTreeCollection extends Collection { private $withLeaves = true; public function toTree(bool $usingSort = true, bool $loadMissing = true): LTreeNode { if (!$model = $this->first()) { return new LTreeNode(); } if ($loadMissing) { $this->loadMissingNodes($model); } if (!$this->withLeaves) { $this->excludeLeaves(); } $builder = new LTreeBuilder( $model->getLtreePathColumn(), $model->getKeyName(), $model->getLtreeParentColumn() ); return $builder->build($collection ?? $this, $usingSort); } public function withLeaves(bool $state = true): self { $this->withLeaves = $state; return $this; } private function loadMissingNodes($model): self { if ($this->hasMissingNodes($model)) { $this->appendAncestors($model); } return $this; } private function excludeLeaves(): void { foreach ($this->items as $key => $item) { if ($item->ltreeChildren->isEmpty()) { $this->forget($key); } } } private function hasMissingNodes($model): bool { $paths = collect(); foreach ($this->items as $item) { $paths = $paths->merge($item->getLtreePath()); } return $paths ->unique() ->diff($this->pluck($model->getKeyName())) ->isNotEmpty(); } private function appendAncestors($model): void { $paths = $this ->pluck($model->getLtreePathColumn()) ->toArray(); $ids = $this ->pluck($model->getKeyName()) ->toArray(); $parents = $model::parentsOf($paths) ->whereKeyNot($ids) ->get(); foreach ($parents as $item) { $this->add($item); } } }
Эта коллекция по сути ничем не отличается от встроенной в Laravel, т.е. если ее итерировать мы все еще будем иметь плоский список. Но если вызвать метод toTree, то плоская коллекция, где все категории всех уровней вложенности были на одном уровне, рекурсивно выстроятся в свойства children и мы получим из обычного плоского массива — многоуровневый массив соответствующий нашему дереву.
А используя специальные скоупы, мы сможем строить деревья для отдельных ветвей, например:
<?php $categories = Category::ancestorsOf(15)->get()->toTree();
Также нам понадобится еще два класса, которые будут собственно строить дерево, это класс — представляющий узел и класс билдера, который будет рекурсивно обходить коллекцию и выстраивать узлы в нужном нам порядке (и сортировке):
LTreeNode
<?php use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; use Umbrellio\Common\Contracts\AbstractPresenter; use Umbrellio\LTree\Collections\LTreeCollection; use Umbrellio\LTree\Interfaces\LTreeInterface; use Umbrellio\LTree\Interfaces\ModelInterface; class LTreeNode extends AbstractPresenter { protected $parent; protected $children; public function __construct($model = null) { parent::__construct($model); } public function isRoot(): bool { return $this->model === null; } public function getParent(): ?self { return $this->parent; } public function setParent(?self $parent): void { $this->parent = $parent; } public function addChild(self $node): void { $this ->getChildren() ->add($node); $node->setParent($this); } public function getChildren(): Collection { if (!$this->children) { $this->children = new Collection(); } return $this->children; } public function countDescendants(): int { return $this ->getChildren() ->reduce( static function (int $count, self $node) { return $count + $node->countDescendants(); }, $this ->getChildren() ->count() ); } public function findInTree(int $id): ?self { if (!$this->isRoot() && $this->model->getKey() === $id) { return $this; } foreach ($this->getChildren() as $child) { $result = $child->findInTree($id); if ($result !== null) { return $result; } } return null; } public function each(callable $callback): void { if (!$this->isRoot()) { $callback($this); } $this ->getChildren() ->each(static function (self $node) use ($callback) { $node->each($callback); }); } public function toCollection(): LTreeCollection { $collection = new LTreeCollection(); $this->each(static function (self $item) use ($collection) { $collection->add($item->model); }); return $collection; } public function pathAsString() { return $this->model ? $this->model->getLtreePath(LTreeInterface::AS_STRING) : null; } public function toTreeArray(callable $callback) { return $this->fillTreeArray($this->getChildren(), $callback); } /** * Usage sortTree(['name' =>'asc', 'category'=>'desc']) * or callback with arguments ($a, $b) and return -1 | 0 | 1 * @param array|callable $options */ public function sortTree($options) { $children = $this->getChildren(); $callback = $options; if (!is_callable($options)) { $callback = $this->optionsToCallback($options); } $children->each(static function ($child) use ($callback) { $child->sortTree($callback); }); $this->children = $children ->sort($callback) ->values(); } private function fillTreeArray(iterable $nodes, callable $callback) { $data = []; foreach ($nodes as $node) { $item = $callback($node); $children = $this->fillTreeArray($node->getChildren(), $callback); $item['children'] = $children; $data[] = $item; } return $data; } private function optionsToCallback(array $options): callable { return function ($a, $b) use ($options) { foreach ($options as $property => $sort) { if (!in_array(strtolower($sort), ['asc', 'desc'], true)) { throw new InvalidArgumentException("Order '${sort}'' must be asc or desc"); } $order = strtolower($sort) === 'desc' ? -1 : 1; $result = $a->{$property} <=> $b->{$property}; if ($result !== 0) { return $result * $order; } } return 0; }; } }
LTreeBuilder
<?php class LTreeBuilder { private $pathField; private $idField; private $parentIdField; private $nodes = []; private $root = null; public function __construct(string $pathField, string $idField, string $parentIdField) { $this->pathField = $pathField; $this->idField = $idField; $this->parentIdField = $parentIdField; } public function build(LTreeCollection $items, bool $usingSort = true): LTreeNode { if ($usingSort === true) { $items = $items->sortBy($this->pathField, SORT_STRING); } $this->root = new LTreeNode(); foreach ($items as $item) { $node = new LTreeNode($item); [$id, $parentId] = $this->getNodeIds($item); $parentNode = $this->getNode($parentId); $parentNode->addChild($node); $this->nodes[$id] = $node; } return $this->root; } private function getNodeIds($item): array { $parentId = $item->{$this->parentIdField}; $id = $item->{$this->idField}; if ($id === $parentId) { throw new LTreeReflectionException($id); } return [$id, $parentId]; } private function getNode(?int $id): LTreeNode { if ($id === null) { return $this->root; } if (!isset($this->nodes[$id])) { throw new LTreeUndefinedNodeException($id); } return $this->nodes[$id]; } }
Для простоты отрисовки дерева напишем еще два абстрактных ресурса:
AbstractLTreeResource
<?php namespace Umbrellio\LTree\Resources; use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Collection; use Umbrellio\LTree\Collections\LTreeCollection; abstract class LTreeResourceCollection extends ResourceCollection { /** * @param LTreeCollection|Collection $resource */ public function __construct($resource, $sort = null, bool $usingSort = true, bool $loadMissing = true) { $collection = $resource->toTree($usingSort, $loadMissing); if ($sort) { $collection->sortTree($sort); } parent::__construct($collection->getChildren()); } }
AbstractLTreeResourceCollection
<?php namespace Umbrellio\LTree\Resources; use Illuminate\Http\Resources\Json\JsonResource; use Umbrellio\LTree\Helpers\LTreeNode; use Umbrellio\LTree\Interfaces\LTreeInterface; /** * @property LTreeNode $resource */ abstract class LTreeResource extends JsonResource { final public function toArray($request) { return array_merge($this->toTreeArray($request, $this->resource->model), [ 'children' => static::collection($this->resource->getChildren())->toArray($request), ]); } /** * @param LTreeInterface $model */ abstract protected function toTreeArray($request, $model); }
Вперед на амбразуру
Использовать примерно будем так, создадим два ресурса JsonResource:
CategoryResource
<?php use Umbrellio\LTree\Helpers\LTreeNode; use Umbrellio\LTree\Resources\LTreeResource; class CategoryResource extends LTreeResource { public function toTreeArray($request, LTreeNode $model) { return [ 'id' => $model->id, 'level' => $model->getLtreeLevel(), ]; } }
CategoryResourceCollection
<?php use Umbrellio\LTree\Resources\LTreeResourceCollection; class CategoryResourceCollection extends LTreeResourceCollection { public $collects = CategoryResource::class; }
Представим, что у вас есть контроллер CategoryController и метод АПИ data возвращающий категории в формате json:
Пример контроллера
<?php use Illuminate\Routing\Controller; use Illuminate\Http\Request; class CategoryController extends Controller { //... public function data(Request $request) { return response()->json( new CategoryResourceCollection( Category::all(), ['id' => 'asc'] ) ); } }
Используя специальные ресурсы, вам не нужно принудительно вызывать метод toTree, т.к. все методы Eloquent\Builder-а (get, all, first и тд) в модели реализующей интерфейс LtreeInterface возвращают LtreeCollection, то при использовании данных ресурсов, плоская коллекция автоматически преобразуется к дереву, причем без дополнительных запросов к БД.
Читать научились, научимся писать
Все описанное выше, помогло нам прочитать дерево из базы данных, отдельную ветвь или все дерево, а также мы научились отрисовывать дерево.
Но чтобы это все мы могли делать, мы должны сначала это дерево уметь сохранять, для этого необходимо сохранить в БД материализованные пути для каждого узла нашего будущего дерева.
Первое что приходит на ум, это форма с двумя полями:
|
id |
input |
|
parent_id |
select |
Т.е. мы создаем элемент, и если нам нужен корневой, то parent_id не заполняем, а если нужно то заполняем. А path по идее должен генерироваться автоматически на основании id и parent_id.
Для этого создадим сервис, который будем вызывать после сохранения нашей модели.
Пример сервиса генерирующего path
<?php namespace Umbrellio\LTree\Services; use Illuminate\Database\Eloquent\Model; use Umbrellio\LTree\Helpers\LTreeHelper; use Umbrellio\LTree\Interfaces\LTreeModelInterface; use Umbrellio\LTree\Interfaces\LTreeServiceInterface; final class LTreeService implements LTreeServiceInterface { private $helper; public function __construct(LTreeHelper $helper) { $this->helper = $helper; } public function createPath(LTreeModelInterface $model): void { $this->helper->buildPath($model); } public function updatePath(LTreeModelInterface $model): void { $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyUpdateColumns())); $this->helper->moveNode($model, $model->ltreeParent, $columns); $this->helper->buildPath($model); } public function dropDescendants(LTreeModelInterface $model): void { $columns = array_intersect_key($model->getAttributes(), array_flip($model->getLtreeProxyDeleteColumns())); $this->helper->dropDescendants($model, $columns); } }
-
createPath — создает path для нового узла, нужно вызывать после создания
-
updatePath — обновляет path при редактировании. Тут важно понимать, что меняя path текущего узла, необходимо также обновить и пути всех его дочерних элементов, и желательно сделать это одним запросом, т.к. в случае если дочерних элементов будет 1000 — делать тысячу запросов на UPDATE как-то не комильфо.
т.е. если мы перемещаем узел в другую подветвь, то вместе с ним перемещаются также и все его дети.
-
dropDescendants — метод удаляющий всех детей, при удалении узла, тут тоже важно понимать очередность, нельзя удалить узел, не удалив детей, вы сделаете дерево неконсистентным, а детей нужно удалять прежде, чем будете удалять узел.
Методы getLtreeProxyDeleteColumns и getLtreeProxyUpdateColumns — нужны для проксирования полей типа deleted_at, updated_at, editor_id и других полей, которые вы также хотите обновить в дочерних узлах при обновлении текущего узла или удалении.
<?php class CategoryService { private LTreeService $service; public function __construct (LTreeService $service) { $this->service = $service; } public function create (array $data): void { $model = App::make(Category::class); $model->fill($data); $model->save(); // создаем материализованный путь для узла $this->service->createPath($model); } }
Конечно, можно запилить немного магии, добавить эвенты, листенеры и дергать методы автоматически, но я больше предпочитаю самому вызывать нужные методы, так хоть есть немного понимания, что происходит за кулисами + можно все это обернуть в транзакцию и не бояться, что где-то вылезет Deadlock.
Подведем итог
Если у вас Postgres / PHP / Laravel и у вас есть потребность в использовании древовидных справочников (категории, группы и тд), вложенность неограниченная, и вы хотите быстро и просто доставать любые срезы ветвей, вам точно будет полезен этот пакет.
Использовать его достаточно просто:
-
Подключаете зависимость в composer: umbrellio/laravel-ltree
-
Пишете миграцию с использованием типа ltree (добавляете parent_id и path в вашу таблицу-справочник)
-
Имплементируете интерфейс LTreeModelInterface и подключаете трейт LTreeModelTrait в Eloquent\Model-и.
-
Используете сервис LTreeService при операциях создания / обновления и удаления модели
-
Используете ресурсы LTreeResource и LTreeResourceCollection, если у вас SPA
Ресурсы для изучения
-
https://postgrespro.ru/docs/postgresql/13/ltree — тут описания расширения Postgres, с примерами и на русском языке
-
https://www.postgresql.org/docs/13/ltree.html -для тех, кто любит читать мануалы в оригинале
Спасибо за внимание.
ссылка на оригинал статьи https://habr.com/ru/post/539244/
Добавить комментарий