Как подружить ltree и Laravel

от автора

Я участвую в разработке 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 и у вас есть потребность в использовании древовидных справочников (категории, группы и тд), вложенность неограниченная, и вы хотите быстро и просто доставать любые срезы ветвей, вам точно будет полезен этот пакет.

Использовать его достаточно просто:

  1. Подключаете зависимость в composer: umbrellio/laravel-ltree

  2. Пишете миграцию с использованием типа ltree (добавляете parent_id и path в вашу таблицу-справочник)

  3. Имплементируете интерфейс LTreeModelInterface и подключаете трейт LTreeModelTrait в Eloquent\Model-и.

  4. Используете сервис LTreeService при операциях создания / обновления и удаления модели

  5. Используете ресурсы 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *