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