[Open source, PHP, PostgreSQL, GitHub, Laravel] Как подружить ltree и Laravel
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Я участвую в разработке 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)idbigserialPKpathltreeматериализованный путьparent_idbigintegerродительская категорияВ path будут храниться материализованные пути, пример
id: 1
path: 1
parent_id: null
id: 2
path: 1.2
parent_id: 2
и тд..Товары (products)idbigserialPKcategory_idbigintegerкатегорияЕсли вы уже используете пакет для 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, то при использовании данных ресурсов, плоская коллекция автоматически преобразуется к дереву, причем без дополнительных запросов к БД.Читать научились, научимся писатьВсе описанное выше, помогло нам прочитать дерево из базы данных, отдельную ветвь или все дерево, а также мы научились отрисовывать дерево.Но чтобы это все мы могли делать, мы должны сначала это дерево уметь сохранять, для этого необходимо сохранить в БД материализованные пути для каждого узла нашего будущего дерева.Первое что приходит на ум, это форма с двумя полями:idinputparent_idselectТ.е. мы создаем элемент, и если нам нужен корневой, то 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 -для тех, кто любит читать мануалы в оригинале
Спасибо за внимание.
===========
Источник:
habr.com
===========
Похожие новости:
- [Платежные системы, Разработка под e-commerce] Как мы сделали оплату по QR
- [Программирование, Анализ и проектирование систем, Проектирование и рефакторинг, ООП] Symfony и Гексагональная архитектура (перевод)
- [Информационная безопасность, Программирование, Java, GitHub, Софт] Architectural approaches to authorization in server applications: Activity-Based Access Control Framework (перевод)
- [Open source, Java, Отладка, Визуализация данных] Новый подход к просмотру логов
- [Open source, *nix] FOSS News №53 – дайджест материалов о свободном и открытом ПО за 18-24 января 2021 года
- [Разработка веб-сайтов, PHP, Laravel] Laravel–Дайджест (18–24 января 2021)
- [Open source, Программирование, Go, Управление разработкой] Релиз ruleguard v0.3.0
- [Open source, Разработка игр, Финансы в IT, IT-компании] Wargaming получила отказ и в Калифорнийском суде
- [Программирование, Symfony] Новое в Symfony 5.2: атрибуты PHP 8 (перевод)
- [Open source, PHP, Программирование] PHP 8 продолжает развитие опенсорсного языка программирования (перевод)
Теги для поиска: #_open_source, #_php, #_postgresql, #_github, #_laravel, #_ltree, #_postgresql, #_postgres, #_php, #_laravel, #_derevja (деревья), #_open_source, #_open_source, #_php, #_postgresql, #_github, #_laravel
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:07
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Я участвую в разработке ERP системы, в детали посвящать вас не буду, это тайна за семью печатями, но скажу лишь одно, древовидных справочников у нас много и вложенность их не ограничена, кол-во в некоторых составляет несколько тысяч, а работать с ними надо максимально эффективно и удобно, как с точки зрения кода, так с точки зрения производительности.Что нам нужно
<?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'); } } id: 1 path: 1 parent_id: null id: 2 path: 1.2 parent_id: 2 и тд..Товары (products)idbigserialPKcategory_idbigintegerкатегорияЕсли вы уже используете пакет для Postgres, значит вы уже знаете, что в добавляя новый extension, у нас появляется новый тип данных для Doctrine и его нужно зарегистрировать, сделать это не сложно, достаточно через composer установить этот пакет и тогда это бремя за вас сделает провайдер, который автоматом будет зарегистрирован: composer require umbrellio/laravel-ltree
<?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;
SELECT * FROM categories WHERE parent_id IS NULL
SELECT * FROM categories WHERE parent_id = <ID>
SELECT * FROM categories WHERE path @> text2ltree('<ID>')
<?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; } <?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; <?php
final class Category extends Model implements LTreeInterface { use LTreeTrait; protected $table = 'categories'; protected $fillable = ['parent_id', 'path']; protected $timestamps = false; } <?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)); }
<?php
// ID категории (листика) = 15 $categories = Category::ancestorsOf(15)->get()->pluck('id')->toArray(); $products = Product::whereIn('category_id', $caregories)->get(); 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()); } } <?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); } } } <?php
$categories = Category::ancestorsOf(15)->get()->toTree(); <?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; }; } } <?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]; } } <?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()); } } <?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); } <?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(), ]; } } <?php
use Umbrellio\LTree\Resources\LTreeResourceCollection; class CategoryResourceCollection extends LTreeResourceCollection { public $collects = CategoryResource::class; } <?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'] ) ); } } <?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); } }
<?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); } }
=========== Источник: habr.com =========== Похожие новости:
|
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:07
Часовой пояс: UTC + 5