Search by

yuriitatur / nested-data

yuriitatur

Nested data storage-agnostic library

Package info

bitbucket.org/yurii_tatur/nested-data

pkg:composer/yuriitatur/nested-data

Statistics

Installs: 17

Dependents: 1

Suggesters: 0

v0.1.0 2026-10-26 08:48 UTC

This package is auto-updated.

Last update: 2026-10-26 08:50:23 UTC


README

Quality Gate Status Coverage

Nested Data

A storage-agnostic PHP 8.4+ library for managing hierarchical data (categories, menus, organizational trees) using a materialized path model. Provides a repository decorator that wraps any RepositoryInterface-compatible repository and adds hierarchy querying capabilities.

Installation

composer require yuriitatur/nested-data

Setup

1. Prepare your entity

Implement NestedEntity and use NestedEntityTrait in your domain entity:

use YuriiTatur\Nested\Entities\NestedEntity;
use YuriiTatur\Nested\Entities\NestedEntityTrait;

class Category implements NestedEntity
{
    use NestedEntityTrait;

    public function __construct(
        private readonly int $id,
        public string $name,
        public string $slug = '',
    ) {}

    public function getId(): int
    {
        return $this->id;
    }
}

2. Implement an ancestry data hydrator

AncestryDataHydratorInterface controls which extra columns are stored alongside each path record (e.g. slug, sort order) and how they are mapped back onto your entity after a query:

use YuriiTatur\Nested\Hydration\AncestryDataHydratorInterface;
use YuriiTatur\Nested\Entities\NestedEntity;

class CategoryHydrator implements AncestryDataHydratorInterface
{
    public function enrich(NestedEntity $entity, array $ancestryData): void
    {
        $entity->slug = $ancestryData['slug'] ?? $entity->slug;
    }

    public function extract(NestedEntity $entity): array
    {
        return ['slug' => $entity->slug];
    }
}

If you have no extra per-position data, return [] from extract and do nothing in enrich.

3. Create the database table (Laravel)

Add a dedicated hierarchy table in a migration:

use YuriiTatur\Nested\Laravel\Database\NestedTable;

Schema::create('nested', function (Blueprint $table) {
    $table->id();
    NestedTable::register($table); // adds entity_id, path, path_hash, depth columns
});

The entity_id column name can be customised:

NestedTable::register($table, 'category_id');

// string primary keys (names, UUIDs, ULIDs): pass the matching Blueprint column method
NestedTable::register($table, 'role_name', 'string');

Add any extra columns your hydrator needs in the same migration.

An entity holds a position once: (entity_id, path_hash) is unique. path_hash also serves exact path lookups, which an indexed, case-sensitive hash keeps fast and literal on every database.

Ids compare like the entity's primary key does. On MySQL and MariaDB the default collation ignores case, so if your ids can differ only in case (abc and ABC), give the primary key a case-sensitive collation and the entity column the same one. register() returns the entity column for that:

NestedTable::register($table, 'role_name', 'string')->collation('utf8mb4_bin');

4. Wrap your repository

use YuriiTatur\Nested\Repositories\HierarchyRepository;
use YuriiTatur\Nested\Laravel\Drivers\EloquentHierarchyDriver;

$driver = new EloquentHierarchyDriver(
    table: 'nested',
    primaryKey: 'id',
    relatedEntityId: 'entity_id',
    connection: null,   // optional — pass a named connection for multi-tenant setups
);

$repo = new HierarchyRepository(
    innerRepository: $categoryRepository,   // your existing RepositoryInterface
    hierarchyDatabaseDriver: $driver,
    transactions: $transactionRunner,
    ancestryDataHydrator: new CategoryHydrator,
    events: $eventDispatcher,
);

Non-Laravel / testing

Use InMemoryPhpHierarchyDriver when you don't have a database or want fast in-memory tests:

use YuriiTatur\Nested\Drivers\InMemoryPhpHierarchyDriver;
use YuriiTatur\Repository\Drivers\Memory\InMemoryPhpDriver;

$driver = new InMemoryPhpHierarchyDriver;

// a custom store must not key its records by "id", that field holds the entity id of each position
$driver = new InMemoryPhpHierarchyDriver(new InMemoryPhpDriver([], '_id'));

Custom drivers

Implement HierarchyDatabaseDriverInterface. saveAncestry(Ancestry ...$ancestries) and deleteAncestry(Ancestry ...$ancestries) take every row of one change in a single call, so a driver can write them in one statement. Saving a position already stored must leave it as it is. searchNodes() must compare ids by their string form and match paths case-sensitively.

Managing hierarchy

Adding a node to the tree

use YuriiTatur\Nested\ValueObjects\ParentPath;

// Place $category as a root node
$repo->addParentPath($category, new ParentPath());

// Place $category as a child of node with ID 1
$repo->addParentPath($category, new ParentPath(1));

// Place $category as a grandchild: parent chain 1 → 3
$repo->addParentPath($category, new ParentPath(1, 3));

addParentPath attaches the entity under the direct parent of the path, and keeps the tree consistent:

  • The entity is placed under every position of that parent, so a node with several parents is reachable from all of them.
  • The entity's existing subtree is copied to each new position, so every ancestor reaches it at any depth.
  • Attaching an existing position again is a no-op.

It runs in a transaction and rejects, with InvalidArgumentException:

  • A circular dependency: the entity appears in the path, or the parent is one of the entity's descendants.
  • A parent that is not stored at the given path (root is always valid).

Its reads lock the rows they read until the transaction ends (the Laravel driver's lock_for_update meta), the parent's rows first, so concurrent changes of the same positions run one after another and none misses a row another one adds. Two attaches of the same position store it once.

Ids are compared by their string form, and path segments keep their type: "/admin/editor/" reads back as ['admin', 'editor'], "/1/3/" as [1, 3]. A path node must not be empty or contain /.

Removing a node from a position

$repo->deleteParentPath($category, new ParentPath(1, 3));

This detaches the entity from the path's direct parent: its position under every position of that parent is removed, together with the subtree below it. Removing the root path / removes only the root position.

The entity's PathList must contain the path being removed; entities returned by get(), getOne() and getById() are enriched with all their paths.

Deleting an entity entirely

Deleting an entity automatically removes all its stored positions, and the subtrees below them, in a single transaction:

$repo->delete($category);

Querying

All query methods return a new immutable repository instance and can be chained with regular RepositoryInterface methods (where, orderBy, limit, etc.).

Roots and depth

// All root nodes (depth = 0)
$roots = $repo->onlyRoots()->get();

// All nodes at depth 2
$depth2 = $repo->atDepthOf(2)->get();

Traversing the tree

// All ancestors of $category (every node in its path)
$ancestors = $repo->allAncestorsOf($category)->get();

// Only the direct parent
$parent = $repo->directAncestorOf($category)->getOne();

// All descendants at any depth
$subtree = $repo->allDescendantsOf($category)->get();

// Only direct children
$children = $repo->directDescendantsOf($category)->get();

// Siblings (same parent, different node)
$siblings = $repo->siblingsOf($category)->get();

Filtering by path positions

use YuriiTatur\Nested\ValueObjects\PathList;

// Entities that occupy any of the given positions
$results = $repo->forPaths(new PathList(new ParentPath(), new ParentPath(1)))->get();

Custom path conditions

whereHierarchy() and orWhereHierarchy() filter the ancestry records directly. A FullTextSearchNode on path matches its query literally and case-sensitively: %, _ and * are not wildcards. To search with wildcards, use a like condition:

use YuriiTatur\QueryBuilder\Nodes\FilterConditionNode;

// every position below node 1
$below = $repo->whereHierarchy(new FilterConditionNode('path', 'like', '/1/%'))->get();

Eager-loading descendants

By default descendants are lazy-loaded per entity when $entity->getDescendants() is first accessed. Use withDescendants() to load them all in a single pass:

$categories = $repo->withDescendants()->onlyRoots()->get();

foreach ($categories as $category) {
    // already loaded — no extra query
    foreach ($category->getDescendants() as $child) { ... }
}

Getting entity IDs including the subtree

// Returns a Collection of IDs: the entity itself + all descendants
$ids = $category->getSubtreeIds();

Caching

Wrap HierarchyRepository with CachedHierarchyRepository to add transparent Symfony-cache-based caching. The cache key covers both the entity query state and the hierarchy query state, so each unique combination of filters is cached independently.

use YuriiTatur\Nested\Repositories\CachedHierarchyRepository;

$cachedRepo = new CachedHierarchyRepository(
    inner: $repo,                 // any HierarchyRepositoryInterface
    cache: $tagAwareCache,        // Symfony\Contracts\Cache\TagAwareCacheInterface
    cachePrefix: 'categories',
    ttl: 3600,
);

addParentPath, deleteParentPath and delete invalidate the entity and its whole subtree, the aggregates, the cached "not found" results, and every result whose query has hierarchy conditions or eager-loaded descendants (tag {prefix}_hierarchy). Results of queries without hierarchy conditions stay cached unless they hold an affected entity. Use cleanCache() to flush all entries for this prefix.

Path value objects

ParentPath

Represents a single position in the hierarchy as an ordered list of ancestor IDs.

$path = new ParentPath(1, 3, 5); // represents /1/3/5/
(string) $path;                  // "/1/3/5/"
$path->depth;                    // 3
$path->getDirectParent();        // 5
$path->getParentsParents();      // [1, 3]
$path->hasParent(3);             // true

// Reconstruct from a stored string
$path = ParentPath::fromStringPath('/1/3/5/');

PathList

A mutable collection of ParentPath objects held on an entity. An entity can exist at multiple positions in the tree.

$list = $category->getPathList();
$list->hasPath(new ParentPath(1, 3)); // bool
count($list);                         // number of positions

Testing

composer test

License

This code is under MIT license, read more in the LICENSE file.