diff --git a/composer.json b/composer.json index fb6a9f86ed..49dc8609ce 100644 --- a/composer.json +++ b/composer.json @@ -127,7 +127,7 @@ "mockery/mockery": "^1.6.15", "opis/json-schema": "^2.6.0", "pda/pheanstalk": "^8.0.2", - "phpstan/phpstan": "^2.2.15", + "phpstan/phpstan": "^2.3", "phpunit/phpunit": "13.3.*", "pusher/pusher-php-server": "^7.2", "resend/resend-php": "^1.0", diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index c515b5db31..17b985b61a 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -167,9 +167,10 @@ spatie/laravel-permission: aimeos/laravel-nestedset: branch: master - checked_through: null - last_reviewed_pr: null - sync_date: null + checked_through: 90ea384febeaaa97967f43b1af9661d4edf2354a + last_reviewed_pr: 25 + sync_date: '2026-10-08' + notes: The only tracked Nested Set upstream; lazychaser/laravel-nestedset and lunarphp/nestedset are not tracked because Aimeos carries their applicable fixes. src/NodeTrait.php maps to src/nested-set/src/HasNode.php; QueryBuilder, Collection and the relations map to src/nested-set/src/Eloquent; NestedSet and NestedSetServiceProvider keep their names. Hypervel keeps the original package's grouped method order rather than Aimeos's sort by visibility and name, so find upstream changes by method name. Upstream tests keep their class names in tests/NestedSet; tests/models map to tests/NestedSet/Fixtures/Models, and tests/data to the bases' getMockCategories() and getMockMenuItems(). Add ported cases to the shared bases, which also run on MySQL, MariaDB and PostgreSQL through the subclasses in tests/Integration/NestedSet/Database. README.md maps to src/docs/nested-set.md. PHP-Open-Source-Saver/jwt-auth: branch: main diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 6ef9643941..df94d23afd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ includes: - src/database/extension.neon - src/foundation/extension.neon + - src/scout/extension.neon parameters: tmpDir: .cache/phpstan @@ -57,7 +58,7 @@ parameters: - '#Access to an undefined property Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::\$#' - '#Call to an undefined method Hypervel\\Database\\Schema\\ForeignIdColumnDefinition::#' - # NodeTrait methods - mixed in at app level, not on base Model class + # HasNode methods - mixed in at app level, not on base Model class - message: '#Call to an undefined method Hypervel\\Database\\Eloquent\\Model::new(ScopedQuery|NestedSetQuery)\(\)#' path: src/nested-set/* diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist index 6a32b24cef..e36ba13bcf 100644 --- a/phpstan.types.neon.dist +++ b/phpstan.types.neon.dist @@ -1,6 +1,7 @@ includes: - src/database/extension.neon - src/foundation/extension.neon + - src/scout/extension.neon parameters: level: max @@ -8,5 +9,6 @@ parameters: scanDirectories: - src/database/src/PHPStan - src/foundation/src/PHPStan + - src/scout/src/PHPStan paths: - types diff --git a/src/database/composer.json b/src/database/composer.json index 9927e913f2..03f5bd67e3 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -59,7 +59,7 @@ }, "require-dev": { "fakerphp/faker": "^1.24", - "phpstan/phpstan": "^2.2.15" + "phpstan/phpstan": "^2.3" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." diff --git a/src/database/extension.neon b/src/database/extension.neon index 425d29e9c0..421ee28360 100644 --- a/src/database/extension.neon +++ b/src/database/extension.neon @@ -1,4 +1,16 @@ +parametersSchema: + eloquentBuilderMacros: arrayOf(string(), string()) + +parameters: + # Builder macros added by model traits' global scopes: trait => interface declaring the macros. + eloquentBuilderMacros: + Hypervel\Database\Eloquent\SoftDeletes: Hypervel\Database\PHPStan\SoftDeletingMacros + services: + - + class: Hypervel\Database\PHPStan\BuilderMacroResolver + arguments: + macros: %eloquentBuilderMacros% - class: Hypervel\Database\PHPStan\ModelScopeMethodResolver - diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php index 66e9c90d1b..a1d82fe52a 100644 --- a/src/database/src/Eloquent/SoftDeletes.php +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -52,13 +52,18 @@ public function forceDelete(): int|bool|null $this->forceDeleting = true; - return tap($this->delete(), function ($deleted) { + // Reset the flag even when deletion throws, so a later delete() stays a soft delete. + try { + $deleted = $this->delete(); + } finally { $this->forceDeleting = false; + } - if ($deleted) { - $this->fireModelEvent('forceDeleted', false); - } - }); + if ($deleted) { + $this->fireModelEvent('forceDeleted', false); + } + + return $deleted; } /** diff --git a/src/database/src/PHPStan/BuilderMacroResolver.php b/src/database/src/PHPStan/BuilderMacroResolver.php new file mode 100644 index 0000000000..cf40df2f76 --- /dev/null +++ b/src/database/src/PHPStan/BuilderMacroResolver.php @@ -0,0 +1,99 @@ + $macros + */ + public function __construct( + private readonly ReflectionProvider $reflectionProvider, + private readonly array $macros, + ) { + $this->scope = new OutOfClassScope; + } + + /** + * Determine whether the model's traits add the named builder macro. + */ + public function hasMacro(ClassReflection $modelClass, string $methodName): bool + { + return $this->macroInterface($modelClass, $methodName) !== null; + } + + /** + * Resolve a trait macro called on a builder for the given model type. + */ + public function resolve(ClassReflection $builderClass, Type $modelType, string $methodName): ?MethodReflection + { + $modelClasses = $modelType->getObjectClassReflections(); + + if (count($modelClasses) !== 1) { + return null; + } + + $macroInterface = $this->macroInterface($modelClasses[0], $methodName); + + if ($macroInterface === null) { + return null; + } + + $interfaceType = $macroInterface->isGeneric() + ? new GenericObjectType($macroInterface->getName(), [$modelType]) + : new ObjectType($macroInterface->getName()); + $method = $interfaceType->getMethod($methodName, $this->scope); + + // Fluent macros return the builder they run on. + return $macroInterface->getNativeMethod($methodName)->getVariants()[0]->getReturnType() instanceof StaticType + ? new ForwardedFluentMethodReflection($builderClass, $method) + : $method; + } + + /** + * Return the interface declaring a macro added by one of the model's traits. + */ + private function macroInterface(ClassReflection $modelClass, string $methodName): ?ClassReflection + { + $traits = null; + + foreach ($this->macros as $trait => $interface) { + $interfaceClass = $this->reflectionProvider->getClass($interface); + + // Builder::hasMacro() matches local macro names exactly. + if (! $interfaceClass->hasNativeMethod($methodName) + || $interfaceClass->getNativeMethod($methodName)->getName() !== $methodName) { + continue; + } + + $traits ??= $modelClass->getTraits(true); + + if (isset($traits[$trait])) { + return $interfaceClass; + } + } + + return null; + } +} diff --git a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php b/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php index 0849afc0b7..3effdda4f2 100644 --- a/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php +++ b/src/database/src/PHPStan/ForwardedBuilderMethodExtension.php @@ -48,6 +48,7 @@ class ForwardedBuilderMethodExtension implements MethodsClassReflectionExtension public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, + private readonly BuilderMacroResolver $builderMacros, ) { $this->scope = new OutOfClassScope; } @@ -86,7 +87,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN return null; } - $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + // Keep the exact spelling: builder macro names are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; if (array_key_exists($cacheKey, $this->methods)) { $cachedMethod = $this->methods[$cacheKey]; @@ -118,6 +120,13 @@ private function resolveEloquentBuilderMethod( ): ?MethodReflection { $modelType = $this->templateType($classReflection, EloquentBuilder::class, 'TModel'); + // Local macros run before named scopes in Builder::__call(). + $macro = $this->builderMacros->resolve($classReflection, $modelType, $methodName); + + if ($macro !== null) { + return $macro; + } + if ($this->hasNamedScope($modelType, $methodName)) { return null; } @@ -174,10 +183,6 @@ private function resolveRelationMethod(ClassReflection $classReflection, string return new ForwardedFluentMethodReflection($classReflection, $method); } - if ($this->hasNamedScope($relatedType, $methodName)) { - return null; - } - $method = $this->resolveEloquentBuilderMethod($eloquentBuilder, $methodName); if ($method !== null) { @@ -186,7 +191,8 @@ private function resolveRelationMethod(ClassReflection $classReflection, string : $method; } - if (! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { + if ($this->hasNamedScope($relatedType, $methodName) + || ! in_array($normalizedMethodName, self::RELATION_DOCUMENTED_FLUENT_METHODS, strict: true)) { return null; } diff --git a/src/database/src/PHPStan/ForwardedModelMethodExtension.php b/src/database/src/PHPStan/ForwardedModelMethodExtension.php index 94cf0a7077..cea4eee046 100644 --- a/src/database/src/PHPStan/ForwardedModelMethodExtension.php +++ b/src/database/src/PHPStan/ForwardedModelMethodExtension.php @@ -29,6 +29,7 @@ class ForwardedModelMethodExtension implements MethodsClassReflectionExtension public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, + private readonly BuilderMacroResolver $builderMacros, ) { $this->scope = new OutOfClassScope; } @@ -63,7 +64,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN return null; } - $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + // Keep the exact spelling: builder macro names are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; if (array_key_exists($cacheKey, $this->methods)) { $cachedMethod = $this->methods[$cacheKey]; @@ -77,7 +79,10 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN $builderType = $this->activeBuilderType($classReflection); $scopeMethod = $this->scopeMethods->resolve($classReflection, $methodName); - if (($scopeMethod === null || $this->hasNativeMethod($builderType, $methodName)) + // Native builder methods and local macros run before named scopes. + if (($scopeMethod === null + || $this->hasNativeMethod($builderType, $methodName) + || $this->builderMacros->hasMacro($classReflection, $methodName)) && $builderType->hasMethod($methodName)->yes()) { $method = new ForwardedModelMethodReflection( $classReflection, diff --git a/src/database/src/PHPStan/NamedScopeMethodExtension.php b/src/database/src/PHPStan/NamedScopeMethodExtension.php index 57d2bc851e..a04ec93bf5 100644 --- a/src/database/src/PHPStan/NamedScopeMethodExtension.php +++ b/src/database/src/PHPStan/NamedScopeMethodExtension.php @@ -32,6 +32,7 @@ class NamedScopeMethodExtension implements MethodsClassReflectionExtension public function __construct( private readonly ReflectionProvider $reflectionProvider, private readonly ModelScopeMethodResolver $scopeMethods, + private readonly BuilderMacroResolver $builderMacros, ) { $this->scope = new OutOfClassScope; } @@ -72,7 +73,8 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN return null; } - $cacheKey = $classReflection->getCacheKey() . ':' . strtolower($methodName); + // Keep the exact spelling: builder macro names are case-sensitive. + $cacheKey = $classReflection->getCacheKey() . ':' . $methodName; if (array_key_exists($cacheKey, $this->methods)) { $cachedMethod = $this->methods[$cacheKey]; @@ -90,12 +92,10 @@ private function resolveMethod(ClassReflection $classReflection, string $methodN $scopeMethod = $this->scopeMethods->resolve($modelClass, $methodName); $method = null; - if ($scopeMethod !== null && ! $this->nativeMethodTakesPrecedence( - $classReflection, - $methodName, - $queryType, - $static, - )) { + // Local builder macros run before named scopes in Builder::__call(). + if ($scopeMethod !== null + && ! $this->builderMacros->hasMacro($modelClass, $methodName) + && ! $this->nativeMethodTakesPrecedence($classReflection, $methodName, $queryType, $static)) { $method = new NamedScopeMethodReflection( $classReflection, $modelClass, diff --git a/src/database/src/PHPStan/SoftDeletingMacros.php b/src/database/src/PHPStan/SoftDeletingMacros.php new file mode 100644 index 0000000000..6271c56d4f --- /dev/null +++ b/src/database/src/PHPStan/SoftDeletingMacros.php @@ -0,0 +1,55 @@ + $attributes + * @param array $values + * @return TModel + */ + public function restoreOrCreate(array $attributes = [], array $values = []): Model; + + /** + * Create the model or get the matching one, then restore it. + * + * @param array $attributes + * @param array $values + * @return TModel + */ + public function createOrRestore(array $attributes = [], array $values = []): Model; +} diff --git a/src/docs/database.md b/src/docs/database.md index 91592afd7c..acd70dfbb2 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -489,7 +489,7 @@ Custom query builders may declare their binding-slot names through the third `Qu ### Static Analysis -The Hypervel database package includes a PHPStan extension that understands named scopes and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis setup instructions](/docs/{{version}}/installation#static-analysis) to enable it in your application. +The Hypervel database package includes a PHPStan extension that understands named scopes, soft deleting query methods such as `withTrashed` and `restore`, and query methods forwarded through Eloquent models, builders, and relationships. See the [static analysis setup instructions](/docs/{{version}}/installation#static-analysis) to enable it in your application. A scope that declares no return type, or declares `void`, `null`, or the query builder, stays chainable. Declaring a broader type such as `mixed` or `object` tells the analyzer the scope may return something else, so that type is preserved. When a scope declares a union containing the query builder, such as `Builder|int`, the builder becomes the chainable receiver and the remaining types are kept. diff --git a/src/docs/installation.md b/src/docs/installation.md index 2b43dc0b82..9df6dcad0b 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -213,7 +213,7 @@ Hypervel's application skeleton includes the `swoole/ide-helper` package in deve ### Static Analysis -The application skeleton includes PHPStan and `phpstan/extension-installer`, which loads Hypervel's extensions automatically. Run `composer analyse` to check your application. The database extension understands [Eloquent scopes and forwarded query methods](/docs/{{version}}/database#static-analysis), while the Foundation extension distinguishes factory calls such as `response()` from calls that create a response, such as `response(status: 204)`. +The application skeleton includes PHPStan and `phpstan/extension-installer`, which loads Hypervel's extensions automatically. Run `composer analyse` to check your application. The database extension understands [Eloquent scopes and forwarded query methods](/docs/{{version}}/database#static-analysis), while the Foundation extension distinguishes factory calls such as `response()` from calls that create a response, such as `response(status: 204)`. If your application uses [Scout](/docs/{{version}}/scout), its extension understands the `searchable` and `unsearchable` query methods. For an existing application, install the extension installer and allow its Composer plugin when prompted: @@ -229,6 +229,8 @@ includes: - vendor/hypervel/foundation/extension.neon ``` +If your application uses Scout, also include `vendor/hypervel/scout/extension.neon`. + ## Next Steps diff --git a/src/docs/nested-set.md b/src/docs/nested-set.md index 11140b6c3f..c10131638d 100644 --- a/src/docs/nested-set.md +++ b/src/docs/nested-set.md @@ -3,12 +3,16 @@ - [Introduction](#introduction) - [Installation](#installation) - [Database Setup](#database-setup) + - [Adding Nested Sets to Existing Tables](#adding-nested-sets-to-existing-tables) - [Model Setup](#model-setup) + - [Custom Column Names](#custom-column-names) - [Creating Nodes](#creating-nodes) - [Creating Root Nodes](#creating-root-nodes) - [Creating Child Nodes](#creating-child-nodes) - [Creating Trees From Arrays](#creating-trees-from-arrays) - [Moving Nodes](#moving-nodes) +- [Deleting Nodes](#deleting-nodes) +- [Transactions and Concurrency](#transactions-and-concurrency) - [Retrieving Nodes](#retrieving-nodes) - [Relationships](#relationships) - [Ancestors and Descendants](#ancestors-and-descendants) @@ -35,7 +39,7 @@ Hypervel's nested set package provides tools for storing hierarchical data in a relational database. It is useful for category trees, menus, organizational charts, threaded comments, file hierarchies, and other data where you often need to read a full branch of the tree. -Nested sets store each node with left and right boundary columns. This makes ancestor and descendant reads efficient, while inserts and moves update the affected boundary ranges. +A [nested set](https://en.wikipedia.org/wiki/Nested_set_model) gives each node a left and right bound that enclose the bounds of all of its descendants. Finding a node's ancestors or descendants only compares these numbers, while inserting, moving, or deleting a node updates the bounds of other nodes in the tree. Nested sets are therefore best suited to trees that are read much more often than they change. ## Installation @@ -110,6 +114,31 @@ $table->nestedSet(['menu_id']); $table->dropNestedSet(['menu_id']); ``` + +### Adding Nested Sets to Existing Tables + +If an existing table already stores each row's parent in a nullable `parent_id` column, add the remaining nested set columns and indexes yourself, since the `nestedSet` method would also create `parent_id`: + +```php +Schema::table('categories', function (Blueprint $table) { + $table->unsignedInteger('_lft')->default(0); + $table->unsignedInteger('_rgt')->default(0); + $table->unsignedSmallInteger('depth')->default(0); + + $table->index('_rgt'); + $table->index(['_lft', '_rgt']); + $table->index(['parent_id', '_lft']); +}); +``` + +After adding the `HasNode` trait to your model, calculate each node's bounds and depth from the existing parent keys using the [`fixTree`](#fixing-existing-trees) method: + +```php +Category::fixTree(); +``` + +Rows with a `null` parent become root nodes. Rows whose parent does not exist also become roots, and their `parent_id` is cleared. For scoped trees, start each index with the scope columns and fix each tree through a [scoped query](#scoped-trees). + ## Model Setup @@ -156,6 +185,71 @@ class CategoryQueryBuilder extends QueryBuilder } ``` +For [static analysis](/docs/{{version}}/database#static-analysis), make the builder generic and add the `HasBuilder` trait with its type. Since `HasNode` already defines `newEloquentBuilder`, keep its version when adding the trait: + +```php +use Hypervel\Database\Eloquent\HasBuilder; + +#[UseEloquentBuilder(CategoryQueryBuilder::class)] +class Category extends Model +{ + use HasNode; + + /** @use HasBuilder> */ + use HasBuilder { + HasNode::newEloquentBuilder insteadof HasBuilder; + } +} + +/** + * @template TModel of Model + * + * @extends QueryBuilder + */ +class CategoryQueryBuilder extends QueryBuilder +{ + // ... +} +``` + + +### Custom Column Names + +If your table uses other names for the nested set columns, override the matching methods on your model: + +```php +public function getLftName(): string +{ + return 'lft'; +} + +public function getRgtName(): string +{ + return 'rgt'; +} + +public function getDepthName(): string +{ + return 'level'; +} + +public function getParentIdName(): string +{ + return 'parent_category_id'; +} +``` + +The schema methods always use the default names, so create these columns and their indexes yourself, as shown in [adding nested sets to existing tables](#adding-nested-sets-to-existing-tables). + +Assigning `parent_id` appends a node through the trait's `setParentIdAttribute` mutator. If you rename the parent column, add a mutator for the new name so that assigning it, or creating nodes through the `children` relationship, still appends the node: + +```php +public function setParentCategoryIdAttribute(int|string|null $value): void +{ + $this->setParentIdAttribute($value); +} +``` + ## Creating Nodes @@ -236,6 +330,20 @@ $accessories = Category::create([ ]); ``` +Since assigning `parent_id` appends the node, the `children` and `parent` relationships work as well: + +```php +$monitors = $computers->children()->create([ + 'name' => 'Monitors', +]); + +$cameras = new Category([ + 'name' => 'Cameras', +]); + +$cameras->parent()->associate($electronics)->save(); +``` + Assigning `parent_id` resolves an active parent. To intentionally target a soft-deleted parent, pass a model retrieved with `withTrashed()` to `appendToNode` or `prependToNode`. @@ -265,6 +373,8 @@ $electronics = Category::create([ ]); ``` +The returned node's `children` relation contains the created child nodes, each with its own `children` loaded the same way. + ## Moving Nodes @@ -290,6 +400,8 @@ $smartwatches = new Category([ $smartwatches->insertAfterNode($smartphones); ``` +The target node must already be saved, while the node you are placing may be new or existing. An existing node moves to the new position and takes the target's parent. + You may move an existing node to another parent using `appendToNode` or `prependToNode`: ```php @@ -306,8 +418,95 @@ $laptops->up(); $tablets->down(2); ``` +Both methods return `false` when there is no sibling to move past. + +Saving a node does not always move it. For example, calling `saveAsRoot` on a node that is already a root leaves it in place. You may use the `hasMoved` method to determine whether the last save changed the node's position: + +```php +if ($category->save()) { + $moved = $category->hasMoved(); +} +``` + Hypervel will throw a `LogicException` if you try to move a node into itself or one of its descendants. + +## Deleting Nodes + +Deleting a node also deletes all of its descendants, and removing their rows closes the gap they leave in the tree. Models that use soft deletes keep their place in the tree instead, as described in [soft deleting nodes](#soft-deleting-nodes): + +```php +$electronics->delete(); +``` + +You may delete several nodes at once using `destroy`, even when some of them are descendants of others: + +```php +Category::destroy([$computers->id, $laptops->id]); +``` + +> [!WARNING] +> Always delete nodes through their models. A query builder delete, such as `Category::where('name', 'Old')->delete()`, skips tree maintenance and leaves the tree broken. If rows have been removed outside of their models, repair the tree using [`fixTree`](#fixing-existing-trees). + +Rows are removed children first, so deleting a subtree works with a restricting foreign key on `parent_id`. Descendants hidden by your model's global scopes are deleted along with the others. + +By default, descendants are deleted with a single query, so their model events are not fired. If your application relies on those events, enable descendant events on the model: + +```php +protected function shouldFireDescendantEvents(): bool +{ + return true; +} +``` + +Descendants are then deleted through their models, children first, in chunks of 1,000. You may change the chunk size by overriding the `getDescendantChunkSize` method. If an observer prevents a descendant from being deleted, a `LogicException` is thrown. + +A delete that fails partway through, such as when an observer prevents a descendant's deletion or another table's foreign key still references a node, leaves the earlier changes in place. Use `deleteOrFail` to run the whole delete in a transaction: + +```php +$electronics->deleteOrFail(); +``` + + +## Transactions and Concurrency + +Creating, moving, and deleting nodes, as well as repairing and rebuilding trees, run several statements that update the bounds of other rows. Hypervel does not start a transaction for you, so wrap each change in a database transaction: + +```php +use Hypervel\Support\Facades\DB; +use RuntimeException; + +DB::transaction(function () use ($electronics, $computers, $phones): void { + if (! $computers->appendToNode($electronics)->save()) { + throw new RuntimeException('Unable to save the computers category.'); + } + + if (! $phones->prependToNode($electronics)->save()) { + throw new RuntimeException('Unable to save the phones category.'); + } +}); +``` + +A structural save updates the bounds of other rows before `saving` observers run. It can therefore return `false` after changing the tree, when an observer vetoes the save or `saveOrIgnore()` ignores a conflict. Throw from the transaction closure, as above, so those changes are rolled back. The `saveOrFail` method does not help here, because it also returns `false` instead of throwing. The node keeps its queued operation, so you can save it again afterward, but Eloquent does not restore the model's other state after a rollback. + +A transaction does not stop two requests from changing the same tree at the same time. Serialize writes to each tree, for example using an [atomic lock](/docs/{{version}}/cache#atomic-locks): + +```php +use Hypervel\Support\Facades\Cache; + +Cache::lock('category-tree', 10)->block(5, function () use ($electronics, $laptops): void { + DB::transaction(function () use ($electronics, $laptops): void { + if (! $laptops->appendToNode($electronics)->save()) { + throw new RuntimeException('Unable to save the laptops category.'); + } + }); +}); +``` + +For scoped trees, include the scope values in the lock name so that writes to different trees may still run at the same time. + +Immediately before a structural change, Hypervel reloads each existing participating node from the write connection. A partially selected model must therefore include its primary key so Hypervel can reload the exact row. This keeps structural decisions aligned with the database without adding hidden reads to ordinary relation queries and node state helpers. + ## Retrieving Nodes @@ -338,6 +537,9 @@ $categories = Category::with(['ancestors', 'descendants', 'siblings']) ->get(); ``` +> [!WARNING] +> When eager loading descendants for thousands of separate parent nodes that have children, SQLite may spend substantial time preparing the query. For large SQLite workloads, eager load the descendants of the parent models in smaller batches. + ### Ancestors and Descendants @@ -357,6 +559,19 @@ $ancestors = $category->getAncestors(); $descendants = $category->getDescendants(); ``` +Ancestors are returned in order from the root down to the node's parent. For example, you may eager load them to display breadcrumbs for a list of categories: + +```php +$categories = Category::with('ancestors')->paginate(30); + +foreach ($categories as $category) { + echo $category->ancestors + ->pluck('name') + ->push($category->name) + ->implode(' > '); +} +``` + When loading these relations from models selected with specific columns, include `_lft`, `_rgt`, and any scope columns on the parent models. Ancestor results also require both bounds and any scope columns. Descendant results require `_lft` and any scope columns. An unsaved parent has an empty relation. A persisted parent or related result with a missing required column throws a `LogicException`; Hypervel does not issue a hidden query to repair an incomplete projection. To include the node itself in the result, use the query builder methods and pass the node's key: @@ -371,6 +586,12 @@ $descendants = Category::descendantsOf($category->getKey()); $descendantsAndSelf = Category::descendantsAndSelf($category->getKey()); ``` +Unlike the `ancestors` relationship, these query methods do not apply an order. Add `defaultOrder` when you need the ancestors from the root down: + +```php +$ancestors = Category::defaultOrder()->ancestorsOf($category->getKey()); +``` + If you need to continue building the query before retrieving results, use `whereAncestorOrSelf` or `whereDescendantOrSelf`: ```php @@ -384,13 +605,22 @@ $nodes = Category::whereDescendantOrSelf($category->getKey()) ->get(); ``` +These constraints may also be used in a subquery. For example, you may retrieve the products of a category and all of its descendants with a single query: + +```php +$products = Product::whereIn( + 'category_id', + Category::whereDescendantOrSelf($category)->select('id'), +)->get(); +``` + ### Siblings and Neighboring Nodes The package provides query and collection helpers for siblings: ```php -$siblings = $category->siblings()->get(); +$siblings = $category->getSiblings(); $siblingsAndSelf = $category->getSiblingsAndSelf(); @@ -403,6 +633,15 @@ $nextSibling = $category->getNextSibling(); $previousSibling = $category->getPrevSibling(); ``` +The `siblings`, `nextSiblings`, and `prevSiblings` methods return queries that you may constrain further: + +```php +$nextVisibleSiblings = $category->nextSiblings() + ->where('visible', true) + ->defaultOrder() + ->get(); +``` + When loading sibling relations from models selected with specific columns, include the configured parent and scope columns. The `siblings` relationship also requires the model's primary key so it can exclude the node itself. An unsaved parent has an empty relation. A persisted parent or related result with a missing required column throws a `LogicException`; Hypervel does not issue a hidden query to repair an incomplete projection. You may also query neighboring nodes without limiting the result to siblings: @@ -438,9 +677,13 @@ if ($category->isDescendantOf($parent)) { if ($parent->isAncestorOf($category)) { // ... } + +if ($category->isSiblingOf($other)) { + // ... +} ``` -You may also retrieve boundary information and movement state: +You may also retrieve boundary information: ```php [$left, $right] = $category->getBounds(); @@ -448,11 +691,9 @@ You may also retrieve boundary information and movement state: $height = $category->getNodeHeight(); $descendantCount = $category->getDescendantCount(); - -$moved = $category->hasMoved(); ``` -Node state helpers use the columns already loaded on the model and do not issue hidden queries. Select `parent_id` before calling `isRoot`. Select both `_lft` and `_rgt` before calling `isLeaf`, `getNodeHeight`, or `getDescendantCount`. With an incomplete projection, `isLeaf` returns `false`, while the numeric methods throw a `LogicException` because no correct value can be calculated. The mutating `up` and `down` methods reread the node before selecting its current siblings. +Node state helpers use the columns already loaded on the model and do not issue hidden queries. Select `parent_id` before calling `isRoot`, `isChildOf`, or `isSiblingOf`. Select both `_lft` and `_rgt` before calling `isLeaf`, `getNodeHeight`, or `getDescendantCount`. With an incomplete projection, `isLeaf` returns `false`, while the numeric methods throw a `LogicException` because no correct value can be calculated. The mutating `up` and `down` methods reread the node before selecting its current siblings. ## Querying Trees @@ -474,6 +715,14 @@ $descendantsAndSelf = Category::whereDescendantOrSelf($category->getKey())->get( $notDescendants = Category::whereNotDescendantOf($category)->get(); ``` +Each constraint accepts a node or its primary key. The `orWhereAncestorOf`, `orWhereDescendantOf`, and `orWhereNotDescendantOf` methods join the same constraints using `or`: + +```php +$categories = Category::whereDescendantOf($electronics) + ->orWhereDescendantOf($clothing) + ->get(); +``` + You may also query nodes that appear before or after another node in the tree: ```php @@ -510,22 +759,22 @@ $nodesWithParent = Category::hasParent()->get(); ### Depth -Every node stores its depth, with root nodes at depth `0`. You may select the depth column explicitly using `withDepth`: +Every node stores its depth in the `depth` column, with root nodes at depth `0`, so the depth is available on each retrieved node: ```php -$categories = Category::withDepth() - ->defaultOrder() - ->get(); +$categories = Category::defaultOrder()->get(); foreach ($categories as $category) { echo str_repeat(' ', $category->depth) . $category->name; } ``` -You may customize the selected depth column name: +When you select specific columns, or need the depth under another name, use the `withDepth` method: ```php -$categories = Category::withDepth('level')->get(); +$categories = Category::select('id', 'name') + ->withDepth('level') + ->get(); $categories->first()->level; ``` @@ -534,7 +783,7 @@ You may filter by depth directly: ```php $topTwoLevels = Category::query() - ->where('depth', '<=', 2) + ->where('depth', '<=', 1) ->defaultOrder() ->get(); ``` @@ -544,7 +793,7 @@ The standard nested set indexes favor ancestor, descendant, child, and sibling r ### Ordering -To retrieve nodes in tree order, use `defaultOrder`: +Queries do not apply an order by default. To retrieve nodes in tree order, where each node follows its parent and siblings keep their position, use `defaultOrder`: ```php $categories = Category::defaultOrder()->get(); @@ -571,6 +820,12 @@ $tree = $categories->toTree(); $flatTree = $categories->toFlatTree(); ``` +The `linkNodes` method fills each node's `parent` and `children` relations using the other nodes in the collection. The `toTree` method links the nodes the same way and returns only the top-level nodes, while `toFlatTree` returns every node followed directly by its descendants. Both keep the collection's order among siblings, so you may order the query by another column to sort each level of the tree: + +```php +$categories = Category::orderBy('name')->get()->toFlatTree(); +``` + When selecting specific columns for collection tree methods, include the model's primary key and configured parent column. The `toTree` and `toFlatTree` methods also require `_lft` when you do not pass a root; a root model passed to either method must include its primary key. You may build a tree for a specific root node by passing the root model or key to `toTree`: @@ -584,9 +839,29 @@ $tree = Category::whereDescendantOf($mobile) ->toTree($mobile); ``` +To include the root node itself, retrieve it along with its descendants and take the first node of the tree: + +```php +$mobile = Category::defaultOrder() + ->descendantsAndSelf(5) + ->toTree() + ->first(); +``` + +Linked nodes include their `parent` relation when serialized. To leave it out, call `makeHidden('parent')` on the collection before calling `toTree` or `linkNodes`: + +```php +$tree = Category::defaultOrder() + ->get() + ->makeHidden('parent') + ->toTree(); +``` + ## Rebuilding and Repairing Trees +The diagnostic, repair, and rebuild methods work with every row in the tree, including rows hidden by your model's global scopes, since those rows still hold positions in the tree. + ### Checking for Errors @@ -701,7 +976,7 @@ Category::rebuildSubtree($rootNode, [ ]); ``` -The nested array controls parentage. Primary keys identify existing nodes, while `parent_id`, `_lft`, `_rgt`, `depth`, and scope values in the payload are ignored. +The nested array controls parentage. Primary keys identify existing nodes, while `parent_id`, `_lft`, `_rgt`, `depth`, and scope values in the payload are ignored. If a primary key does not match a node in the tree or subtree being rebuilt, a `ModelNotFoundException` is thrown. Rebuilding a subtree has the same boundary requirement. If a parentage edge crosses the root's stored bounds, repair the complete tree with `fixTree()` first. @@ -759,7 +1034,7 @@ Return `['tenant_id', 'menu_id']` from `getScopeAttributes()` and provide both v Set every scope value before saving a new node. A stored node cannot be moved to another scope by changing those attributes. -When querying a scoped tree by plain IDs, start from the `scoped` query: +To query one tree, start from the `scoped` method. A primary key alone does not identify a node's tree, so queries that look up a node by its key, such as `descendantsOf`, throw a `LogicException` unless they start from `scoped`: ```php $items = MenuItem::scoped(['menu_id' => 1]) @@ -770,6 +1045,18 @@ $descendants = MenuItem::scoped(['menu_id' => 1]) ->descendantsOf($nodeId); ``` +Relationships, node methods, and constraints given a node use that node's own scope. This includes eager loading, which may load nodes from several trees at once: + +```php +$items = MenuItem::with('descendants')->get(); +``` + +To start a query within a node's tree, use the `newScopedQuery` method: + +```php +$leaves = $item->newScopedQuery()->whereIsLeaf()->get(); +``` + Diagnostics, whole-tree repair, and rebuild operations also require a concrete scope: ```php @@ -821,34 +1108,28 @@ Soft deleting a node soft deletes its descendants: $electronics->delete(); ``` -Restoring the node restores descendants that were deleted as part of the same delete operation. Descendants that were already deleted before the parent was deleted remain deleted: +Soft deleted nodes keep their place in the tree so they can be restored. As a result, a node whose children are all soft deleted is not a leaf: `isLeaf` returns `false` and `whereIsLeaf` does not match the node until its children are force deleted. + +Restoring the node restores descendants whose stored deletion time is the same as or later than the node's. Descendants deleted earlier remain deleted: ```php $electronics->restore(); ``` -Force deleting a node removes the node and its descendants from the table and closes the gap in the nested set: +Force deleting a node removes the node and its descendants from the table and closes the gap in the tree: ```php $electronics->forceDelete(); ``` -By default, descendants are deleted in one set-based query, so descendant model events are not fired. If your application requires those events, enable the evented path on the model: +Descendants hidden by your model's global scopes are restored along with the others. When [descendant events](#deleting-nodes) are enabled, descendants are restored through their models, parents first, using the same chunk size as deletes. -```php -protected function shouldFireDescendantEvents(): bool -{ - return true; -} +Like a delete, a force delete or restore may fail partway through, so wrap it in a transaction: -protected function getDescendantDeleteChunkSize(): int -{ - return 1000; -} +```php +DB::transaction(fn () => $electronics->forceDelete()); ``` -The evented path deletes descendants in bounded, children-first chunks. Wrap the delete in a transaction so an exception or veto from a descendant observer rolls back the whole operation. - ## Rendering Trees @@ -888,25 +1169,11 @@ echo renderTree($tree); Nested sets are designed for reading branches of a tree. Ancestor, descendant, and subtree reads can be performed efficiently using the `_lft` and `_rgt` boundaries. -Moving, inserting, deleting, repairing, and rebuilding nodes use multiple statements and update boundary values across affected rows. Wrap each mutation in a database transaction: - -```php -DB::transaction(function () use ($electronics, $computers, $phones): void { - $computers->appendToNode($electronics)->save(); - - $phones->prependToNode($electronics)->save(); -}); -``` - -Immediately before a structural mutation, Hypervel reloads each existing participating node from the write connection. A partially selected model must therefore include its primary key so Hypervel can reload the exact row. This keeps structural decisions aligned with the database without adding hidden reads to ordinary relation queries and node state helpers. - -If a model observer vetoes a mutation and it returns `false`, throw from the transaction closure so earlier structural writes are rolled back. - -Concurrent writers to the same table and nested set scope must also be serialized by your application. The package does not add an implicit distributed lock or network call. +Inserting, moving, and deleting nodes update the bounds of many other nodes in the same tree, so their cost grows with the size of the tree. [Scoped trees](#scoped-trees) keep this work within one tree. -The schema helpers create separate indexes for right-bound scans, left-bound scans, and parent lookups. Scope columns prefix each index, which keeps each scoped tree's reads isolated and substantially reduces ancestor, descendant, child, and sibling query work. These indexes add a bounded cost to structural writes; this favors the read-heavy workloads nested sets are designed for. Add a depth index only when your application frequently filters large trees by depth. +The schema helpers create indexes on `_rgt`, `[_lft, _rgt]`, and `[parent_id, _lft]`. For scoped trees, each index starts with the scope columns so queries can narrow their search to one tree. Including both bounds in the same index reduces the work needed for ancestor queries. These indexes make structural writes slightly slower in exchange for faster reads, which suits the read-heavy workloads nested sets are designed for. Add a depth index only when your application frequently filters large trees by depth. ## Credits -Hypervel Nested Set began as a port of [Aimeos Laravel Nested Set](https://github.com/aimeos/laravel-nestedset) and has been adapted for Hypervel's framework architecture and coroutine runtime. +Hypervel Nested Set is based on Alexander Kalnoy's [laravel-nestedset](https://github.com/lazychaser/laravel-nestedset) and its successor, [Aimeos Laravel Nested Set](https://github.com/aimeos/laravel-nestedset). It also includes fixes and tests from [Lunar's fork](https://github.com/lunarphp/nestedset), and has been adapted for Hypervel's framework architecture and coroutine runtime. diff --git a/src/foundation/composer.json b/src/foundation/composer.json index 512db498a8..592d1f60c4 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -82,7 +82,7 @@ "hypervel/websocket-server": "^0.4" }, "require-dev": { - "phpstan/phpstan": "^2.2.15" + "phpstan/phpstan": "^2.3" }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).", diff --git a/src/nested-set/LICENSE.md b/src/nested-set/LICENSE.md index a2980b4c8a..495e283383 100644 --- a/src/nested-set/LICENSE.md +++ b/src/nested-set/LICENSE.md @@ -2,6 +2,10 @@ The MIT License (MIT) Copyright (c) 2017 Alexander Kalnoy +Copyright (c) 2026 Aimeos and contributors + +Copyright (c) 2026 Neon Digital (maintenance for Lunar) + Copyright (c) Hypervel Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/src/nested-set/README.md b/src/nested-set/README.md index 020b5051d6..128c269ef6 100644 --- a/src/nested-set/README.md +++ b/src/nested-set/README.md @@ -1,8 +1,32 @@ NestedSet for Hypervel === -Ported from: https://github.com/aimeos/laravel-nestedset - [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/nested-set) Documentation: https://hypervel.org/docs/nested-set + +## Differences From laravel-nestedset + +Models use the `HasNode` trait instead of upstream's `NodeTrait`. + +Migrations add the nested set columns and indexes with one Blueprint macro matching the table's primary key: `nestedSet()` for `id()`, `integerNestedSet()` for `increments()`, `uuidNestedSet()` or `ulidNestedSet()`. Each macro includes the `depth` column and accepts the tree's scope columns, which prefix every index; `dropNestedSet()` accepts the same scope columns. The matching `NestedSet` static methods take scope columns instead of a key column name and type. Upstream's `nestedSetDepth()`, `nestedSetIndex()`, `dropNestedSetDepth()` and `dropNestedSetIndex()` macros and their `NestedSet` methods are not available. For a custom index layout, define the columns and indexes with ordinary Blueprint methods. + +The `depth` column is required. `withDepth()` reads the stored depth instead of counting ancestors in a subquery, so global scopes that hide ancestors do not change a node's depth. + +Scoped models override `getScopeAttributes(): array`; Aimeos declares `?array`. `ensureSameTree()` replaces `assertSameScope()` and also requires both nodes to use the same connection and table. + +Only a `null` parent ID makes a node a root. Upstream also treats `0` and an empty string as root parent IDs; here they are ordinary parent keys, so a node whose key is `0` can have children. Passing `null`, `0` or an empty string to `toTree()` or `toFlatTree()` builds from the nodes with that parent ID. Aimeos infers the root from `0` or an empty string, as it does when no root is given, and does not accept `null`. + +The query builder's `getDepth($position)` is `depthForPosition($position)`, which returns the depth of a node inserted at that position within the selected tree. Upstream's method returns the enclosing node's depth, so it cannot tell a position inside a root from one outside every node. + +`countErrors()` reports `invalid_intervals`, `duplicate_endpoints`, `missing_endpoints`, `crossing_intervals`, `missing_parent`, `wrong_parent` and `wrong_depth` instead of upstream's `oddness`, `duplicates`, `wrong_parent` and `missing_parent`, so `getTotalErrors()` sums different counts. `wrong_parent` counts once each node whose stored parent has incompatible bounds or depth; Aimeos counts each node between them, or each other node when the parent does not contain the child. Depth and interval inconsistencies are also reported separately. + +`fixTree()` and `fixSubtree()` read plain rows and hydrate only the nodes they save, so unchanged nodes fire no `retrieved` events. Repaired nodes still fire `saving` and `saved`, with any `extraColumns` loaded. + +`getAncestors()`, `getDescendants()` and `getSiblings()` always run a fresh query and leave loaded relations unchanged. When all columns are requested, upstream returns the loaded `ancestors`, `descendants` or `siblings` relation, or stores the query result as that relation. Use the relation properties, such as `$node->ancestors`, to reuse loaded results. + +Relation subclasses constrain and match all eager-loaded parents together: they implement `constrainEagerModels()`, which receives the base query builder and the prepared parent models, and `matchMany()`. These replace upstream's per-parent hooks: `addEagerConstraint()` is no longer abstract, and `matches()`, `matchForModel()`, `indexResults()`, `matchFromIndex()`, `preservesResultOrder()` and `getEagerModelKey()` are not available. + +Descendant model events are off by default; override `shouldFireDescendantEvents()` to return `true`. Aimeos fires them by default. When they are on, restoring a node also restores its descendants through their model events. `getDescendantChunkSize()` sets the chunk size for both and replaces Aimeos's `getDescendantDeleteChunkSize()`, and `deleteDescendantsWithEvents()` receives a `bool $forceDelete` flag instead of the descendants query and method name. + +Ported from: https://github.com/aimeos/laravel-nestedset diff --git a/src/nested-set/src/Eloquent/AncestorsRelation.php b/src/nested-set/src/Eloquent/AncestorsRelation.php index 48cd9280ca..be63ef9d04 100644 --- a/src/nested-set/src/Eloquent/AncestorsRelation.php +++ b/src/nested-set/src/Eloquent/AncestorsRelation.php @@ -4,8 +4,15 @@ namespace Hypervel\NestedSet\Eloquent; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Query\Builder; +/** + * @template TModel of Model + * + * @extends BaseRelation + */ class AncestorsRelation extends BaseRelation { /** @@ -36,20 +43,75 @@ public function addEagerConstraints(array $models): void } /** - * Determine whether a node is an ancestor of the parent. + * Constrain the eager query to ancestors of the prepared parents. + * + * A node is an ancestor when the smallest parent right bound at or after + * its left bound falls before its right bound. A balanced CASE finds that + * bound in logarithmic steps per row, whereas one "or" condition per + * parent costs every scanned row a comparison per parent. */ - protected function matches(Model $model, Model $related): bool + protected function constrainEagerModels(Builder $query, array $models): void { - /* @phpstan-ignore method.notFound */ - return $related->isAncestorOf($model); + $groups = []; + + foreach ($models as $model) { + $groups[$this->scopeKey($model)][] = $model; + } + + $grammar = $query->getGrammar(); + $lft = $grammar->wrap($this->related->qualifyColumn($this->related->getLftName())); /* @phpstan-ignore method.notFound */ + $rgt = $grammar->wrap($this->related->qualifyColumn($this->related->getRgtName())); /* @phpstan-ignore method.notFound */ + + $this->addBalancedOrConstraints( + $query, + array_values($groups), + function (Builder $query, array $group) use ($lft, $rgt): void { + $points = array_values(array_unique(array_map( + static fn (Model $model): int => $model->getRgt(), /* @phpstan-ignore method.notFound */ + $group, + ))); + sort($points); + + $query->whereNested(function (Builder $query) use ($group, $points, $lft, $rgt): void { + $group[0]->applyNestedSetScope($query); + + // Validated integer bounds are inlined, as whereIntegerInRaw() does, + // so large eager loads do not exhaust the driver's bound parameters. + $query->whereRaw(sprintf('%s <= %d', $lft, $points[count($points) - 1])) + ->whereRaw(sprintf('%s > %d', $rgt, $points[0])); + + if (count($points) > 1) { + $query->whereRaw(sprintf( + '%s > %s', + $rgt, + $this->successorExpression($lft, $points, 0, count($points) - 1), + )); + } + }, 'or'); + }, + ); } /** - * Add an eager ancestor constraint. + * Build a balanced expression for the smallest point at or after the left bound. + * + * @param list $points sorted parent right bounds */ - protected function addEagerConstraint(QueryBuilder $query, Model $model): void + protected function successorExpression(string $lft, array $points, int $low, int $high): string { - $query->orWhereAncestorOf($model); + if ($low === $high) { + return (string) $points[$low]; + } + + $middle = intdiv($low + $high, 2); + + return sprintf( + 'case when %s <= %d then %s else %s end', + $lft, + $points[$middle], + $this->successorExpression($lft, $points, $low, $middle), + $this->successorExpression($lft, $points, $middle + 1, $high), + ); } /** @@ -66,21 +128,23 @@ protected function prepareEagerModels(array $models): array $result = []; foreach ($groups as $group) { - usort($group, function (Model $left, Model $right): int { - $comparison = $right->getLft() /* @phpstan-ignore method.notFound */ - <=> $left->getLft(); /* @phpstan-ignore method.notFound */ + $lfts = []; + $rgts = []; + + foreach ($group as $model) { + $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ + $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + } + + $positions = array_keys($group); - return $comparison !== 0 - ? $comparison - : $left->getRgt() /* @phpstan-ignore method.notFound */ - <=> $right->getRgt(); /* @phpstan-ignore method.notFound */ - }); + // Positions are unique, so ties never compare the models. + array_multisort($lfts, SORT_DESC, $rgts, $positions, $group); $minimumRgt = null; - foreach ($group as $model) { - /** @var int $rgt */ - $rgt = $model->getRgt(); + foreach ($group as $offset => $model) { + $rgt = $rgts[$offset]; if ($minimumRgt !== null && $rgt >= $minimumRgt) { continue; @@ -95,23 +159,92 @@ protected function prepareEagerModels(array $models): array } /** - * Index only when multiple concrete scopes would otherwise be scanned. + * Match ancestors with one sweep per scope over results and parents in left-bound order. */ - protected function shouldIndexResults(array $models): bool + protected function matchMany(array $models, Collection $results): array { - if (count($models) < 2) { - return false; + if (count($models) === 1) { + $model = $models[0]; + $scope = $this->scopeKey($model); + $key = $this->matchKey($model); + /** @var int $lft */ + $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $found = []; + + foreach ($results as $related) { + if ($related->getLft() < $lft /* @phpstan-ignore method.notFound */ + && $related->getRgt() > $lft /* @phpstan-ignore method.notFound */ + && $this->scopeKey($related) === $scope + && ($key === null || $this->matchKey($related) !== $key) + ) { + $found[] = $related; + } + } + + return [spl_object_id($model) => $found]; } - $scope = $this->scopeKey($models[0]); + $buckets = $this->sortedResultBuckets($results, true); + $groups = []; + + foreach ($models as $model) { + $groups[$this->scopeKey($model)][] = $model; + } + + $matches = []; + + foreach ($groups as $scope => $group) { + $bucket = $buckets[$scope] ?? null; + + if ($bucket === null) { + continue; + } + + $lfts = array_map( + static fn (Model $model): int => $model->getLft(), /* @phpstan-ignore method.notFound */ + $group, + ); + $positions = array_keys($group); + + // Positions are unique, so ties never compare the models. + array_multisort($lfts, $positions, $group); + + $count = count($bucket['lfts']); + $next = 0; + $open = []; + + foreach ($group as $offset => $model) { + $key = $this->matchKey($model); + $lft = $lfts[$offset]; + + // Each result enters the open stack once and leaves once it closes. + for (; $next < $count && $bucket['lfts'][$next] < $lft; ++$next) { + while ($open !== [] && $bucket['rgts'][$open[count($open) - 1]] < $bucket['lfts'][$next]) { + array_pop($open); + } + + $open[] = $next; + } + + while ($open !== [] && $bucket['rgts'][$open[count($open) - 1]] <= $lft) { + array_pop($open); + } + + $found = []; + + foreach ($open as $index) { + if ($bucket['rgts'][$index] > $lft + && ($key === null || $bucket['keys'][$index] !== $key) + ) { + $found[] = $index; + } + } - foreach (array_slice($models, 1) as $model) { - if ($this->scopeKey($model) !== $scope) { - return true; + $matches[spl_object_id($model)] = $this->bucketModels($bucket, $found); } } - return false; + return $matches; } /** @@ -119,7 +252,7 @@ protected function shouldIndexResults(array $models): bool */ protected function requiredParentColumns(Model $model): array { - // Eager interval reduction sorts by both bounds even though the lazy + // Eager reduction and matching use both bounds even though the lazy // ancestor predicate only consumes the right bound. return $this->intervalColumns($model); } diff --git a/src/nested-set/src/Eloquent/BaseRelation.php b/src/nested-set/src/Eloquent/BaseRelation.php index 05043fa75f..221b49cc7d 100644 --- a/src/nested-set/src/Eloquent/BaseRelation.php +++ b/src/nested-set/src/Eloquent/BaseRelation.php @@ -4,26 +4,43 @@ namespace Hypervel\NestedSet\Eloquent; +use Closure; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Query\Builder; +use Hypervel\NestedSet\Eloquent\Collection as NestedSetCollection; use Hypervel\NestedSet\NestedSet; use InvalidArgumentException; use LogicException; +/** + * Relate a node to other nodes of its own class. + * + * @template TModel of Model + * + * @extends Relation> + */ abstract class BaseRelation extends Relation { + /** + * The maximum number of "or" constraints chained in one eager constraint group. + */ + protected const int EAGER_CONSTRAINT_GROUP_SIZE = 64; + /** * The nested-set query builder instance. * - * @var QueryBuilder + * @var QueryBuilder */ protected EloquentBuilder $query; /** * Create a new nested set relation. + * + * @param QueryBuilder $builder + * @param TModel $model */ public function __construct(QueryBuilder $builder, Model $model) { @@ -35,14 +52,20 @@ public function __construct(QueryBuilder $builder, Model $model) } /** - * Determine whether a related node matches a parent. + * Constrain the eager query to the prepared parent models. */ - abstract protected function matches(Model $model, Model $related): bool; + abstract protected function constrainEagerModels(Builder $query, array $models): void; /** - * Add an eager constraint for a parent. + * Match eager results to persisted parents on the results' connection and table. + * + * Aimeos matches one parent at a time through indexResults() and + * matchFromIndex(); this hook receives every parent so a relation can + * match them together. + * + * @return array> related models keyed by the parent's object ID */ - abstract protected function addEagerConstraint(QueryBuilder $query, Model $model): void; + abstract protected function matchMany(array $models, Collection $results): array; /** * Get the relation existence condition. @@ -79,9 +102,9 @@ public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilde $query->select($columns); $table = $query->getModel()->getTable(); + $hash = $this->getRelationCountHash(); - $query->from($table . ' as ' . $hash = $this->getRelationCountHash()); - + $query->from($table . ' as ' . $hash); $query->getModel()->setTable($hash); $grammar = $query->getQuery()->getGrammar(); @@ -129,6 +152,8 @@ public function getRelationCountHash(bool $incrementJoinCount = true): string /** * Get the results of the relationship. + * + * @return NestedSetCollection */ public function getResults(): Collection { @@ -152,13 +177,8 @@ public function addEagerConstraints(array $models): void return; } - $this->query->whereNested(function (Builder $inner) use ($models) { - // We will use this query in order to apply constraints to the - // base query builder - /** @var QueryBuilder $outer */ - $outer = $this->parent->newQuery()->setQuery($inner); - - $this->constrainEagerModels($outer, $models); + $this->query->whereNested(function (Builder $inner) use ($models): void { + $this->constrainEagerModels($inner, $models); }); } @@ -175,54 +195,61 @@ public function match(array $models, Collection $results, string $relation): arr ); } - $persisted = array_values(array_filter( - $models, - static fn (Model $model): bool => $model->exists, - )); - $indexed = $this->shouldIndexResults($persisted) - ? $this->indexResults($results) - : null; + $matches = []; - foreach ($models as $model) { - if (! $model->exists) { - $model->setRelation($relation, $this->related->newCollection()); + if ($results->isNotEmpty()) { + $identity = NestedSet::structuralIdentity($results->first()); + $persisted = array_values(array_filter( + $models, + static fn (Model $model): bool => $model->exists + && NestedSet::structuralIdentity($model) === $identity, + )); - continue; + if ($persisted !== []) { + $matches = $this->matchMany($persisted, $results); } + } - $related = $indexed === null - ? $this->matchForModel($model, $results) - : $this->matchFromIndex($model, $indexed); - - $model->setRelation($relation, $related); + foreach ($models as $model) { + $model->setRelation( + $relation, + $this->related->newCollection($matches[spl_object_id($model)] ?? []), + ); } return $models; } /** - * Match query results for one parent. + * Add constraints as balanced nested "or" groups. + * + * SQLite parses each chained "or" as one more level of expression depth and + * rejects expressions deeper than 1,000 levels, so long eager constraint + * lists are split into bounded groups instead of one chain. The callback + * adds one "or" constraint for an item. */ - protected function matchForModel(Model $model, Collection $results): Collection + protected function addBalancedOrConstraints(Builder $query, array $items, Closure $constraint): void { - $result = $this->related->newCollection(); + $size = static::EAGER_CONSTRAINT_GROUP_SIZE; - foreach ($results as $related) { - if ($this->matches($model, $related)) { - $result->push($related); + if (count($items) <= $size) { + foreach ($items as $item) { + $constraint($query, $item); } + + return; } - return $result; - } + $chunkSize = $size; - /** - * Apply eager constraints for the prepared parent models. - */ - protected function constrainEagerModels(QueryBuilder $query, array $models): void - { - foreach ($models as $model) { - $this->addEagerConstraint($query, $model); + while (count($items) > $chunkSize * $size) { + $chunkSize *= $size; + } + + foreach (array_chunk($items, $chunkSize) as $chunk) { + $query->whereNested(function (Builder $query) use ($chunk, $constraint): void { + $this->addBalancedOrConstraints($query, $chunk, $constraint); + }, 'or'); } } @@ -313,44 +340,79 @@ protected function ensureProjection(Model $model, array $columns, string $contex } /** - * Determine whether eager results should be indexed by tree scope. - */ - protected function shouldIndexResults(array $models): bool - { - return count($models) > 1; - } - - /** - * Index eager results by exact nested-set scope while preserving query order. + * Group eager results by scope in left-bound order, keeping their query positions. + * + * @return array, keys: list, lfts: list, rgts: list, positions: list, ordered: bool}> */ - protected function indexResults(Collection $results): array + protected function sortedResultBuckets(Collection $results, bool $includeRgt): array { - /** @var array}> $indexed */ - $indexed = []; + $buckets = []; + $position = 0; foreach ($results as $related) { $scope = $this->scopeKey($related); + $buckets[$scope]['models'][] = $related; + $buckets[$scope]['keys'][] = $this->matchKey($related); + $buckets[$scope]['lfts'][] = $related->getLft(); /* @phpstan-ignore method.notFound */ + $buckets[$scope]['rgts'] ??= []; + + if ($includeRgt) { + $buckets[$scope]['rgts'][] = $related->getRgt(); /* @phpstan-ignore method.notFound */ + } + + $buckets[$scope]['positions'][] = $position++; + } + + foreach ($buckets as $scope => $bucket) { + $bucket['ordered'] = true; - if (! isset($indexed[$scope])) { - $indexed[$scope] = [ - 'models' => [], - ]; + for ($index = 1, $count = count($bucket['lfts']); $index < $count; ++$index) { + if ($bucket['lfts'][$index] < $bucket['lfts'][$index - 1]) { + $bucket['ordered'] = false; + + break; + } + } + + if (! $bucket['ordered']) { + // Query positions are unique, so ties never compare the models. + $includeRgt + ? array_multisort($bucket['lfts'], $bucket['positions'], $bucket['rgts'], $bucket['keys'], $bucket['models']) + : array_multisort($bucket['lfts'], $bucket['positions'], $bucket['keys'], $bucket['models']); } - $indexed[$scope]['models'][] = $related; + $buckets[$scope] = $bucket; + } + + return $buckets; + } + + /** + * Restore query order among matched bucket entries when the bucket was re-sorted. + * + * @param list $matches bucket indexes in left-bound order + * @return list + */ + protected function bucketModels(array $bucket, array $matches): array + { + if (! $bucket['ordered']) { + usort( + $matches, + static fn (int $left, int $right): int => $bucket['positions'][$left] <=> $bucket['positions'][$right], + ); } - return $indexed; + return array_map(static fn (int $index): Model => $bucket['models'][$index], $matches); } /** - * Match a parent from its exact scope bucket. + * Get a model key in the string form used to recognize a parent's own row. */ - protected function matchFromIndex(Model $model, array $indexed): Collection + protected function matchKey(Model $model): ?string { - $bucket = $indexed[$this->scopeKey($model)]['models'] ?? []; + $key = $model->getKey(); - return $this->matchForModel($model, $this->related->newCollection($bucket)); + return $key === null ? null : (string) $key; } /** diff --git a/src/nested-set/src/Eloquent/Collection.php b/src/nested-set/src/Eloquent/Collection.php index d74945ebf7..584e769fa2 100644 --- a/src/nested-set/src/Eloquent/Collection.php +++ b/src/nested-set/src/Eloquent/Collection.php @@ -10,11 +10,18 @@ use InvalidArgumentException; use LogicException; +/** + * @template TKey of array-key + * @template TModel of Model + * + * @extends BaseCollection + */ class Collection extends BaseCollection { /** * Fill `parent` and `children` relationships for every node in the collection. - * This will overwrite any previously set relations. + * Replaces children and in-collection parents, and keeps loaded parents + * outside the collection. */ public function linkNodes(): static { @@ -32,11 +39,6 @@ public function linkNodes(): static */ protected function linkNodesFromGroups(array $groupedNodes, array $roots): static { - foreach ($this->items as $node) { - $node->unsetRelation('parent'); - $node->unsetRelation('children'); - } - foreach ($this->items as $node) { $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound */ @@ -48,6 +50,10 @@ protected function linkNodesFromGroups(array $groupedNodes, array $roots): stati if ($children !== []) { $parent = clone $node; + + // Detach relations from the parent stub so the child->parent + // reference can't re-enter the tree and cause infinite + // recursion when serializing (e.g. toJson()). $parent->setRelations([]); foreach ($children as $child) { @@ -65,6 +71,8 @@ protected function linkNodesFromGroups(array $groupedNodes, array $roots): stati * Build a tree from a list of nodes. Each item will have set children relation. * To successfully build tree "id", "_lft" and "parent_id" keys must present. * If `$root` is provided, the tree will contain only descendants of that node. + * + * @return static */ public function toTree(Model|int|string|false|null $root = false): static { @@ -143,22 +151,22 @@ protected function getRootNodeId(Model|int|string|false|null $root = false): int /** * Build a list of nodes that retain the order that they were pulled from * the database. + * + * @return static */ public function toFlatTree(Model|int|string|false|null $root = false): static { - $result = new static; - if ($this->isEmpty()) { - return $result; + return new static; } [$groupedNodes, $roots] = $this->groupNodesByParent(); - return $result->flattenTree( + return new static($this->flattenTree( $groupedNodes, $roots, $this->getRootNodeId($root), - ); + )); } /** @@ -202,9 +210,14 @@ protected function groupNodesByParent(): array /** * Flatten a tree without recursive stack growth. + * + * @param array> $groupedNodes + * @param list $roots + * @return list */ - protected function flattenTree(array $groupedNodes, array $roots, int|string|null $parentId): static + protected function flattenTree(array $groupedNodes, array $roots, int|string|null $parentId): array { + $nodes = []; $stack = []; $children = $this->nodesForParent($groupedNodes, $roots, $parentId); @@ -214,7 +227,7 @@ protected function flattenTree(array $groupedNodes, array $roots, int|string|nul while ($stack !== []) { $node = array_pop($stack); - $this->push($node); + $nodes[] = $node; $children = $this->nodesForParent($groupedNodes, $roots, $node->getKey()); @@ -223,11 +236,15 @@ protected function flattenTree(array $groupedNodes, array $roots, int|string|nul } } - return $this; + return $nodes; } /** * Get nodes for a parent ID. + * + * @param array> $groupedNodes + * @param list $roots + * @return list */ protected function nodesForParent( array $groupedNodes, diff --git a/src/nested-set/src/Eloquent/DescendantsRelation.php b/src/nested-set/src/Eloquent/DescendantsRelation.php index 48e1759f1b..5359f618d0 100644 --- a/src/nested-set/src/Eloquent/DescendantsRelation.php +++ b/src/nested-set/src/Eloquent/DescendantsRelation.php @@ -6,7 +6,13 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Query\Builder; +/** + * @template TModel of Model + * + * @extends BaseRelation + */ class DescendantsRelation extends BaseRelation { /** @@ -25,43 +31,74 @@ public function addConstraints(): void $this->query->whereDescendantOf($this->parent); } + /** + * Constrain the eager query to descendants of the prepared parents. + */ + protected function constrainEagerModels(Builder $query, array $models): void + { + // Per-parent ranges keep index scans within each parent's bounds; group bounds would + // scan everything between them. SQLite still prepares thousands of disjoint ranges + // in quadratic time. + $this->addBalancedOrConstraints($query, $models, $this->addEagerConstraint(...)); + } + /** * Add an eager descendant constraint. */ - protected function addEagerConstraint(QueryBuilder $query, Model $model): void + protected function addEagerConstraint(Builder $query, Model $model): void { - $query->orWhereDescendantOf($model); + $query->whereNested(function (Builder $query) use ($model): void { + $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ + + // Validated integer bounds are inlined, as whereIntegerInRaw() does, + // so large eager loads do not exhaust the driver's bound parameters. + $query->whereRaw(sprintf( + '%s between %d and %d', + $query->getGrammar()->wrap($this->related->qualifyColumn($this->related->getLftName())), /* @phpstan-ignore method.notFound */ + $model->getLft() + 1, /* @phpstan-ignore method.notFound */ + $model->getRgt(), /* @phpstan-ignore method.notFound */ + )); + }, 'or'); } /** - * Remove parent intervals whose descendant queries are already covered. + * Remove empty parent intervals and those whose descendant queries are already covered. */ protected function prepareEagerModels(array $models): array { $groups = []; foreach (parent::prepareEagerModels($models) as $model) { + // Without a bound strictly between the parent's bounds, no node can be its descendant. + if ($model->getRgt() /* @phpstan-ignore method.notFound */ + <= $model->getLft() + 1 /* @phpstan-ignore method.notFound */ + ) { + continue; + } + $groups[$this->scopeKey($model)][] = $model; } $result = []; foreach ($groups as $group) { - usort($group, function (Model $left, Model $right): int { - $comparison = $left->getLft() /* @phpstan-ignore method.notFound */ - <=> $right->getLft(); /* @phpstan-ignore method.notFound */ + $lfts = []; + $rgts = []; + + foreach ($group as $model) { + $lfts[] = $model->getLft(); /* @phpstan-ignore method.notFound */ + $rgts[] = $model->getRgt(); /* @phpstan-ignore method.notFound */ + } - return $comparison !== 0 - ? $comparison - : $right->getRgt() /* @phpstan-ignore method.notFound */ - <=> $left->getRgt(); /* @phpstan-ignore method.notFound */ - }); + $positions = array_keys($group); + + // Positions are unique, so ties never compare the models. + array_multisort($lfts, $rgts, SORT_DESC, $positions, $group); $maximumRgt = null; - foreach ($group as $model) { - /** @var int $rgt */ - $rgt = $model->getRgt(); + foreach ($group as $offset => $model) { + $rgt = $rgts[$offset]; if ($maximumRgt !== null && $rgt <= $maximumRgt) { continue; @@ -76,96 +113,61 @@ protected function prepareEagerModels(array $models): array } /** - * Determine whether a node is a descendant of the parent. + * Match descendants by binary search over each scope's left bounds. */ - protected function matches(Model $model, Model $related): bool + protected function matchMany(array $models, Collection $results): array { - /* @phpstan-ignore method.notFound */ - return $related->isDescendantOf($model); - } - - /** - * Index descendants by exact scope and left-bound order. - */ - protected function indexResults(Collection $results): array - { - /** @var array, lfts: list, monotonic: bool, last_lft: ?int}> $indexed */ - $indexed = []; - - foreach ($results as $related) { - $scope = $this->scopeKey($related); + if (count($models) === 1) { + $model = $models[0]; + $scope = $this->scopeKey($model); + $key = $this->matchKey($model); /** @var int $lft */ - $lft = $related->getLft(); /* @phpstan-ignore method.notFound */ - - if (! isset($indexed[$scope])) { - $indexed[$scope] = [ - 'models' => [], - 'lfts' => [], - 'monotonic' => true, - 'last_lft' => null, - ]; - } - - $bucket = &$indexed[$scope]; - - if ($bucket['last_lft'] !== null && $lft < $bucket['last_lft']) { - $bucket['monotonic'] = false; + $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + /** @var int $rgt */ + $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $found = []; + + foreach ($results as $related) { + $relatedLft = $related->getLft(); /* @phpstan-ignore method.notFound */ + + if ($relatedLft > $lft + && $relatedLft < $rgt + && $this->scopeKey($related) === $scope + && ($key === null || $this->matchKey($related) !== $key) + ) { + $found[] = $related; + } } - $bucket['models'][] = $related; - $bucket['lfts'][] = $lft; - $bucket['last_lft'] = $lft; - - // Break the reference before the cleanup loop can advance it. - unset($bucket); - } - - foreach ($indexed as &$bucket) { - unset($bucket['last_lft']); + return [spl_object_id($model) => $found]; } - return $indexed; - } + $buckets = $this->sortedResultBuckets($results, false); + $matches = []; - /** - * Match descendants from an exact scope bucket. - */ - protected function matchFromIndex(Model $model, array $indexed): Collection - { - $bucket = $indexed[$this->scopeKey($model)] ?? null; + foreach ($models as $model) { + $bucket = $buckets[$this->scopeKey($model)] ?? null; - if ($bucket === null) { - return $this->related->newCollection(); - } - - /** @var int $lft */ - $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ - /** @var int $rgt */ - $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ - - if (! $bucket['monotonic']) { - return $this->matchForModel( - $model, - $this->related->newCollection($bucket['models']), - ); - } - - $result = $this->related->newCollection(); - $start = static::lowerBound($bucket['lfts'], $lft + 1); - - for ($index = $start, $count = count($bucket['models']); $index < $count; ++$index) { - if ($bucket['lfts'][$index] >= $rgt) { - break; + if ($bucket === null) { + continue; } - $related = $bucket['models'][$index]; + $key = $this->matchKey($model); + /** @var int $rgt */ + $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + $found = []; + $index = static::lowerBound($bucket['lfts'], $model->getLft() + 1); /* @phpstan-ignore method.notFound */ - if ($this->matches($model, $related)) { - $result->push($related); + for ($count = count($bucket['lfts']); $index < $count && $bucket['lfts'][$index] < $rgt; ++$index) { + if ($key === null || $bucket['keys'][$index] !== $key) { + $found[] = $index; + } } + + $matches[spl_object_id($model)] = $this->bucketModels($bucket, $found); } - return $result; + return $matches; } /** diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index d597f69e44..29ad583f22 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -14,7 +14,13 @@ use Hypervel\Support\Collection as BaseCollection; use InvalidArgumentException; use LogicException; +use stdClass; +/** + * @template TModel of Model + * + * @extends EloquentBuilder + */ class QueryBuilder extends EloquentBuilder { /** @@ -28,13 +34,12 @@ public function getNodeData(int|string $id, bool $required = false): array $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + // first() ignores its columns once a global scope has selected some, + // so select() replaces any scope projection with the structural columns. $data = $this->toBase() - ->where($this->model->getKeyName(), '=', $id) - ->first([ - $lftName, - $rgtName, - $depthName, - ]); + ->select($this->qualifyColumns([$lftName, $rgtName, $depthName])) + ->where($this->qualifyColumn($this->model->getKeyName()), '=', $id) + ->first(); if (! $data && $required) { throw (new ModelNotFoundException)->setModel($this->model::class, [$id]); @@ -136,6 +141,8 @@ public function whereAncestorOrSelf(Model|int|string $id): static /** * Get ancestors of specified node. + * + * @return Collection */ public function ancestorsOf(Model|int|string $id, array $columns = ['*']): BaseCollection { @@ -144,6 +151,8 @@ public function ancestorsOf(Model|int|string $id, array $columns = ['*']): BaseC /** * Get ancestors and the node itself. + * + * @return Collection */ public function ancestorsAndSelf(Model|int|string $id, array $columns = ['*']): BaseCollection { @@ -186,9 +195,7 @@ public function whereDescendantOf(Model|int|string $id, string $boolean = 'and', } else { $this->ensureConcreteNestedSetScope('scalar lookup'); - /* @phpstan-ignore method.notFound */ - $data = $this->model->newNestedSetQuery() - ->getPlainNodeData($id, true); + $data = $this->newNestedSetLookupQuery()->getPlainNodeData($id, true); } // Don't include the node @@ -236,6 +243,8 @@ public function whereDescendantOrSelf(Model|int|string $id, string $boolean = 'a /** * Get descendants of specified node. + * + * @return Collection */ public function descendantsOf(Model|int|string $id, array $columns = ['*'], bool $andSelf = false): BaseCollection { @@ -248,6 +257,8 @@ public function descendantsOf(Model|int|string $id, array $columns = ['*'], bool /** * Get descendants and the node itself. + * + * @return Collection */ public function descendantsAndSelf(Model|int|string $id, array $columns = ['*']): BaseCollection { @@ -331,6 +342,8 @@ public function whereIsLeaf(): static /** * Get the leaf nodes. + * + * @return Collection */ public function leaves(array $columns = ['*']): BaseCollection { @@ -508,15 +521,19 @@ public function moveNode( /** * Get the depth of a node inserted at the given position. + * + * This replaces upstream's getDepth($position), which returns the enclosing node's depth. */ public function depthForPosition(int $position): int { $this->ensureConcreteNestedSetScope('depth lookup'); + // A base read keeps this internal lookup from hydrating a model and firing retrieved listeners. $depth = $this->newNestedSetLookupQuery() ->where($this->model->getLftName(), '<', $position) /* @phpstan-ignore method.notFound */ ->where($this->model->getRgtName(), '>=', $position) /* @phpstan-ignore method.notFound */ ->orderBy($this->model->getLftName(), 'desc') /* @phpstan-ignore method.notFound */ + ->toBase() ->value($this->model->getDepthName()); /* @phpstan-ignore method.notFound */ return $depth === null ? 0 : ((int) $depth + 1); @@ -525,9 +542,19 @@ public function depthForPosition(int $position): int /** * Create a nested set lookup that inherits the current read route. */ - protected function newNestedSetLookupQuery(): self + protected function newNestedSetLookupQuery(?string $table = null): self { - $query = $this->model->newNestedSetQuery(); /* @phpstan-ignore method.notFound */ + $query = $this->model->newNestedSetQuery($table); /* @phpstan-ignore method.notFound */ + + return $this->query->useWritePdo ? $query->useWritePdo() : $query; + } + + /** + * Create a base query for wrapping lookups that inherits the current read route. + */ + protected function newLookupBaseQuery(): BaseQueryBuilder + { + $query = $this->query->newQuery(); return $this->query->useWritePdo ? $query->useWritePdo() : $query; } @@ -601,7 +628,7 @@ protected function columnPatch(string $col, array $params): Expression { $height = (int) $params['height']; - if ($height > 0) { + if ($height >= 0) { $height = " + {$height}"; } @@ -617,7 +644,7 @@ protected function columnPatch(string $col, array $params): Expression $from = (int) $params['from']; $to = (int) $params['to']; - if ($distance > 0) { + if ($distance >= 0) { $distance = " + {$distance}"; } @@ -646,7 +673,7 @@ public function countErrors(): array 'wrong_depth' => $this->getWrongDepthQuery(), ]; - $query = $this->query->newQuery(); + $query = $this->newLookupBaseQuery(); foreach ($checks as $key => $inner) { $query->selectSub($inner, $key); @@ -663,8 +690,7 @@ public function countErrors(): array */ protected function getInvalidIntervalsQuery(bool $count = true): BaseQueryBuilder { - $query = $this->model - ->newNestedSetQuery() + $query = $this->newNestedSetLookupQuery() ->toBase() ->whereNested(function (BaseQueryBuilder $inner) { [$lft, $rgt] = $this->wrappedColumns(); @@ -688,13 +714,11 @@ protected function getEndpointEventsQuery(): BaseQueryBuilder // A left endpoint opens a node at its stored depth, while a right // endpoint closes it after one additional active level. - $lftQuery = $this->model - ->newNestedSetQuery() + $lftQuery = $this->newNestedSetLookupQuery() ->toBase() ->selectRaw("{$lft} as endpoint, {$depth} as expected, 1 as delta"); - $rgtQuery = $this->model - ->newNestedSetQuery() + $rgtQuery = $this->newNestedSetLookupQuery() ->toBase() ->selectRaw("{$rgt} as endpoint, {$depth} + 1 as expected, -1 as delta"); @@ -706,8 +730,7 @@ protected function getEndpointEventsQuery(): BaseQueryBuilder */ protected function getDuplicateEndpointGroupsQuery(): BaseQueryBuilder { - return $this->query - ->newQuery() + return $this->newLookupBaseQuery() ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') ->select('endpoint') ->groupBy('endpoint') @@ -719,8 +742,7 @@ protected function getDuplicateEndpointGroupsQuery(): BaseQueryBuilder */ protected function getDuplicateEndpointsQuery(): BaseQueryBuilder { - return $this->query - ->newQuery() + return $this->newLookupBaseQuery() ->fromSub($this->getDuplicateEndpointGroupsQuery(), 'duplicate_endpoints') ->selectRaw('count(*)'); } @@ -730,8 +752,7 @@ protected function getDuplicateEndpointsQuery(): BaseQueryBuilder */ protected function getMissingEndpointsQuery(): BaseQueryBuilder { - $statistics = $this->query - ->newQuery() + $statistics = $this->newLookupBaseQuery() ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') ->selectRaw( 'count(*) as endpoint_count, ' @@ -739,8 +760,7 @@ protected function getMissingEndpointsQuery(): BaseQueryBuilder . 'max(endpoint) as maximum_endpoint' ); - return $this->query - ->newQuery() + return $this->newLookupBaseQuery() ->fromSub($statistics, 'endpoint_statistics') ->selectRaw( 'case ' @@ -757,8 +777,7 @@ protected function getEndpointStateQuery(): BaseQueryBuilder { // Comparing each event's expected depth with the active count before // that endpoint detects intervals which cross instead of nest. - return $this->query - ->newQuery() + return $this->newLookupBaseQuery() ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') ->select(['expected']) ->selectRaw( @@ -776,8 +795,7 @@ protected function getCrossingIntervalsQuery(): BaseQueryBuilder ->selectRaw('1') ->limit(1); - return $this->query - ->newQuery() + return $this->newLookupBaseQuery() ->fromSub($this->getEndpointStateQuery(), 'endpoint_state') ->selectRaw( 'case when exists (' . $duplicates->toSql() @@ -797,8 +815,7 @@ protected function getMissingParentQuery(bool $count = true): BaseQueryBuilder $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ $keyName = $this->model->getKeyName(); - $query = $this->model - ->newNestedSetQuery($childAlias) + $query = $this->newNestedSetLookupQuery($childAlias) ->toBase() ->from($this->model->getTable() . ' as ' . $childAlias) ->leftJoin( @@ -833,8 +850,7 @@ protected function getWrongParentQuery(bool $count = true): BaseQueryBuilder $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ $grammar = $this->query->getGrammar(); - $query = $this->model - ->newNestedSetQuery($childAlias) + $query = $this->newNestedSetLookupQuery($childAlias) ->toBase() ->from($this->model->getTable() . ' as ' . $childAlias) ->join( @@ -888,8 +904,7 @@ protected function getWrongDepthQuery(bool $count = true): BaseQueryBuilder $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ $grammar = $this->query->getGrammar(); - $query = $this->model - ->newNestedSetQuery($childAlias) + $query = $this->newNestedSetLookupQuery($childAlias) ->toBase() ->from($this->model->getTable() . ' as ' . $childAlias) ->leftJoin( @@ -962,19 +977,16 @@ protected function addScopeColumnComparisons( /** * Ensure every nested set scope attribute is selected. */ - protected function ensureConcreteNestedSetScope( - string $operation = 'diagnostics', - ?Model $model = null, - ): void { - $model ??= $this->model; - $attributes = $model->getAttributes(); + protected function ensureConcreteNestedSetScope(string $operation = 'diagnostics'): void + { + $attributes = $this->model->getAttributes(); - foreach (array_keys($model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ if (! array_key_exists($attribute, $attributes)) { throw new LogicException(sprintf( 'Nested set %s for [%s] requires a concrete scoped([...]) selection because attribute [%s] was not selected.', $operation, - $model::class, + $this->model::class, $attribute, )); } @@ -1157,10 +1169,11 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int } $model = $root ?? $this->model; + $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound */ $scopeColumns = array_keys($model->getNestedSetScope()); /* @phpstan-ignore method.notFound */ $columns = array_values(array_unique([ $model->getKeyName(), - $model->getParentIdName(), /* @phpstan-ignore method.notFound */ + $parentIdName, $model->getLftName(), /* @phpstan-ignore method.notFound */ $model->getRgtName(), /* @phpstan-ignore method.notFound */ $model->getDepthName(), /* @phpstan-ignore method.notFound */ @@ -1168,21 +1181,24 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int ...$extraColumns, ])); - $nodes = $model + // Repair reads plain rows and hydrates only the nodes it saves, so a + // large, mostly healthy tree is not loaded as models. + $rows = $model ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ ->useWritePdo() ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); }) ->defaultOrder() + ->toBase() ->get($columns); $roots = []; $childrenByParent = []; $parentOrder = []; - foreach ($nodes as $node) { - static::addRepairNode($roots, $childrenByParent, $parentOrder, $node); + foreach ($rows as $row) { + static::addRepairNode($roots, $childrenByParent, $parentOrder, $row, $row->{$parentIdName}); } return $this->fixNodes($roots, $childrenByParent, $parentOrder, $root); @@ -1191,7 +1207,7 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int /** * Fix a subtree based on parentage information. */ - public function fixSubtree(Model $root, array $extraColumns = []): int + public function fixSubtree(?Model $root, array $extraColumns = []): int { return $this->fixTree($root, $extraColumns); } @@ -1205,11 +1221,11 @@ protected function fixNodes( array $parentOrder, ?Model $parent = null, ): int { + $model = $parent ?? $this->model; $parentId = $parent?->getKey(); $cut = $parent ? $parent->getLft() + 1 : 1; /* @phpstan-ignore method.notFound */ $depth = $parent ? $parent->getDepth() + 1 : 0; /* @phpstan-ignore method.notFound */ - $updated = []; - $ordered = []; + $placements = []; $moved = 0; if ($parent === null) { @@ -1222,9 +1238,9 @@ protected function fixNodes( $cut = static::reorderNodes( $childrenByParent, - $updated, - $ordered, + $placements, $nodes, + $model->getKeyName(), $parentId, $cut, $depth, @@ -1249,9 +1265,9 @@ protected function fixNodes( $cut = static::reorderNodes( $childrenByParent, - $updated, - $ordered, + $placements, $nodes, + $model->getKeyName(), $parentId, $cut, $depth, @@ -1259,72 +1275,148 @@ protected function fixNodes( } $grown = $parent ? $cut - $parent->getRgt() : 0; /* @phpstan-ignore method.notFound */ + $gapCut = null; if ($parent !== null && $grown !== 0) { $gapCut = $parent->getRgt() + 1; /* @phpstan-ignore method.notFound */ $moved = $parent ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ ->makeGap($gapCut, $grown); + } + + $changed = 0; - foreach ($ordered as $model) { - static::syncRepairNodeOriginalAfterGap($model, $gapCut, $grown); + foreach ($placements as $placement) { + if ($this->applyRepairPlacement($model, $placement, $gapCut, $grown)) { + ++$changed; } + } - $parent = $parent->rawNode( /* @phpstan-ignore method.notFound */ + if ($parent !== null && $gapCut !== null) { + static::saveRepairNode($parent->rawNode( /* @phpstan-ignore method.notFound */ $parent->getLft(), /* @phpstan-ignore method.notFound */ $cut, $parent->getParentId(), /* @phpstan-ignore method.notFound */ $parent->getDepth(), /* @phpstan-ignore method.notFound */ - ); + )); - $updated[] = $parent; - $ordered[] = $parent; + ++$changed; } - $nodesToSave = $parent !== null && $grown !== 0 - ? $ordered - : $updated; + return $changed + $moved; + } - foreach ($nodesToSave as $model) { - static::saveRepairNode($model); + /** + * Apply a repair placement and save the node when its stored values differ. + * + * Returns whether the placement differs from the values stored before any subtree gap, + * so rows that only the gap update shifted are counted by that update instead. + * + * @param array{0: Model|stdClass, 1: int, 2: int, 3: null|int|string, 4: int} $placement + */ + protected function applyRepairPlacement(Model $model, array $placement, ?int $gapCut, int $height): bool + { + [$node, $lft, $rgt, $parentId, $depth] = $placement; + $lftName = $model->getLftName(); /* @phpstan-ignore method.notFound */ + $rgtName = $model->getRgtName(); /* @phpstan-ignore method.notFound */ + + if ($node instanceof Model) { + static::assignRepairNode($node, $lft, $rgt, $parentId, $depth); + + $changed = $node->isDirty(); + + if ($gapCut !== null) { + $attributes = $node->getAttributes(); + + $node->setRawAttributes( + static::shiftRepairBoundsAfterGap($node->getRawOriginal(), $lftName, $rgtName, $gapCut, $height), + true, + ); + $node->setRawAttributes($attributes); + } + + if ($node->isDirty()) { + static::saveRepairNode($node); + } + + return $changed; + } + + $changed = ! $this->repairRowMatches($model, $node, $placement); + + if ($gapCut !== null) { + $node = (object) static::shiftRepairBoundsAfterGap((array) $node, $lftName, $rgtName, $gapCut, $height); } - return count($updated) + $moved; + if (! $this->repairRowMatches($model, $node, $placement)) { + $repaired = $model->newFromBuilder($node); + + static::assignRepairNode($repaired, $lft, $rgt, $parentId, $depth); + static::saveRepairNode($repaired); + } + + return $changed; } /** - * Sync a repair model's original bounds with the preceding gap update. + * Determine whether a row already stores a repair placement. + * + * @param array{0: Model|stdClass, 1: int, 2: int, 3: null|int|string, 4: int} $placement */ - protected static function syncRepairNodeOriginalAfterGap(Model $model, int $cut, int $height): void + protected function repairRowMatches(Model $model, stdClass $row, array $placement): bool { - $attributes = $model->getAttributes(); - $databaseAttributes = $model->getRawOriginal(); - $lftName = $model->getLftName(); /* @phpstan-ignore method.notFound */ - $rgtName = $model->getRgtName(); /* @phpstan-ignore method.notFound */ - $rgt = (int) $databaseAttributes[$rgtName]; + $stored = [ + 1 => $row->{$model->getLftName()}, /* @phpstan-ignore method.notFound */ + 2 => $row->{$model->getRgtName()}, /* @phpstan-ignore method.notFound */ + 3 => $row->{$model->getParentIdName()}, /* @phpstan-ignore method.notFound */ + 4 => $row->{$model->getDepthName()}, /* @phpstan-ignore method.notFound */ + ]; - if ($rgt < $cut) { - return; + foreach ($stored as $index => $value) { + $target = $placement[$index]; + + // Drivers return numbers as integers or numeric strings; null stays distinct from zero. + if ($value === null || $target === null ? $value !== $target : (string) $value !== (string) $target) { + return false; + } + } + + return true; + } + + /** + * Shift stored bounds as the preceding gap update shifted the row. + */ + protected static function shiftRepairBoundsAfterGap( + array $attributes, + string $lftName, + string $rgtName, + int $cut, + int $height, + ): array { + $lft = $attributes[$lftName]; + $rgt = $attributes[$rgtName]; + + if ($rgt === null || (int) $rgt < $cut) { + return $attributes; } - // Mirror makeGap() and columnPatch(); the snapshot must match the row - // changed by that update before Eloquent computes repair dirtiness. - $lft = (int) $databaseAttributes[$lftName]; - $databaseAttributes[$lftName] = $lft >= $cut ? $lft + $height : $lft; - $databaseAttributes[$rgtName] = $rgt + $height; + // Mirror makeGap() and columnPatch(), so dirtiness is computed against + // the values that update left in the row. + $attributes[$lftName] = $lft !== null && (int) $lft >= $cut ? (int) $lft + $height : $lft; + $attributes[$rgtName] = (int) $rgt + $height; - $model->setRawAttributes($databaseAttributes, true); - $model->setRawAttributes($attributes); + return $attributes; } /** - * Assign contiguous bounds and depth to a set of nodes. + * Place a set of nodes at contiguous bounds and depths, recording each placement in tree order. */ protected static function reorderNodes( array &$childrenByParent, - array &$updated, - array &$ordered, + array &$placements, array $nodes, + string $keyName, int|string|null $parentId, int $cut, int $depth, @@ -1339,23 +1431,10 @@ protected static function reorderNodes( while ($stack !== []) { $frameIndex = array_key_last($stack); - if (isset($stack[$frameIndex]['model'])) { + if (isset($stack[$frameIndex]['node'])) { $frame = array_pop($stack); - $model = $frame['model']; - - static::assignRepairNode( - $model, - $frame['lft'], - $cut, - $frame['parent_id'], - $frame['depth'], - ); - $ordered[] = $model; - - if ($model->isDirty()) { - $updated[] = $model; - } + $placements[] = [$frame['node'], $frame['lft'], $cut, $frame['parent_id'], $frame['depth']]; ++$cut; @@ -1368,17 +1447,17 @@ protected static function reorderNodes( continue; } - $model = $stack[$frameIndex]['nodes'][$stack[$frameIndex]['index']]; + $node = $stack[$frameIndex]['nodes'][$stack[$frameIndex]['index']]; ++$stack[$frameIndex]['index']; $stack[] = [ - 'model' => $model, + 'node' => $node, 'lft' => $cut++, 'parent_id' => $stack[$frameIndex]['parent_id'], 'depth' => $stack[$frameIndex]['depth'], ]; - $key = $model->getKey(); + $key = $node->{$keyName}; if ($key === null || ! array_key_exists($key, $childrenByParent)) { continue; @@ -1421,16 +1500,15 @@ protected static function addRepairNode( array &$roots, array &$childrenByParent, array &$parentOrder, - Model $model, + Model|stdClass $node, + int|string|null $parentId, ): void { - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ - if ($parentId === null) { if ($roots === []) { $parentOrder[] = null; } - $roots[] = $model; + $roots[] = $node; return; } @@ -1439,7 +1517,7 @@ protected static function addRepairNode( $parentOrder[] = $parentId; } - $childrenByParent[$parentId][] = $model; + $childrenByParent[$parentId][] = $node; } /** @@ -1505,9 +1583,11 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $usesSoftDeletes = $model::isSoftDeletable(); if ($delete && ! $usesSoftDeletes) { + // MySQL and MariaDB check a restricting parent key as each row is deleted. $model ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ ->whereIn($model->getKeyName(), array_keys($existing)) + ->orderBy($model->getLftName(), 'desc') /* @phpstan-ignore method.notFound */ ->delete(); } else { $deletedAtColumn = $delete && $usesSoftDeletes @@ -1529,6 +1609,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $childrenByParent, $parentOrder, $existingModel, + $existingModel->getParentId(), /* @phpstan-ignore method.notFound */ ); } } @@ -1568,7 +1649,10 @@ protected function buildRebuildDictionary( $children = $itemData['children'] ?? null; if (! isset($itemData[$keyName])) { - $model = $this->model->newInstance($scopeAttributes); + $model = $this->model->newInstance(); + + // Rebuild owns the tree scope, so a guarded scope attribute still applies. + $model->setRawAttributes(array_replace($model->getAttributes(), $scopeAttributes)); // Set temporary values without scheduling a tree action. $model->rawNode(0, 0, $parentId, 0); /* @phpstan-ignore method.notFound */ @@ -1608,7 +1692,7 @@ protected function buildRebuildDictionary( $model->fill($itemData); static::saveRepairNode($model); - static::addRepairNode($roots, $childrenByParent, $parentOrder, $model); + static::addRepairNode($roots, $childrenByParent, $parentOrder, $model, $model->getParentId()); /* @phpstan-ignore method.notFound */ if ($children === null) { continue; @@ -1637,6 +1721,8 @@ public function applyNestedSetScope(?string $table = null): static /** * Get the root node. + * + * @return null|TModel */ public function root(array $columns = ['*']): ?Model { diff --git a/src/nested-set/src/Eloquent/SiblingsRelation.php b/src/nested-set/src/Eloquent/SiblingsRelation.php index 9c4b254259..ec0a2825e6 100644 --- a/src/nested-set/src/Eloquent/SiblingsRelation.php +++ b/src/nested-set/src/Eloquent/SiblingsRelation.php @@ -6,7 +6,13 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Query\Builder; +/** + * @template TModel of Model + * + * @extends BaseRelation + */ class SiblingsRelation extends BaseRelation { /** @@ -16,6 +22,9 @@ class SiblingsRelation extends BaseRelation /** * Create a new sibling relation. + * + * @param QueryBuilder $builder + * @param TModel $model */ public function __construct(QueryBuilder $builder, Model $model, bool $andSelf = false) { @@ -53,9 +62,9 @@ public function addConstraints(): void /** * Apply an eager constraint for one parent model. */ - protected function addEagerConstraint(QueryBuilder $query, Model $model): void + protected function addEagerConstraint(Builder $query, Model $model): void { - $query->orWhere(function (QueryBuilder $query) use ($model) { + $query->whereNested(function (Builder $query) use ($model): void { $this->whereParentId($query, $model); $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ @@ -67,13 +76,13 @@ protected function addEagerConstraint(QueryBuilder $query, Model $model): void $model->getKey(), ); } - }); + }, 'or'); } /** * Group eager constraints by exact scope and parent. */ - protected function constrainEagerModels(QueryBuilder $query, array $models): void + protected function constrainEagerModels(Builder $query, array $models): void { if (count($models) === 1) { $this->addEagerConstraint($query, $models[0]); @@ -95,11 +104,11 @@ protected function constrainEagerModels(QueryBuilder $query, array $models): voi } } - foreach ($groups as $group) { - $query->orWhere(function (QueryBuilder $query) use ($group) { + $this->addBalancedOrConstraints($query, array_values($groups), function (Builder $query, array $group): void { + $query->whereNested(function (Builder $query) use ($group): void { $group['model']->applyNestedSetScope($query); - $query->where(function (QueryBuilder $query) use ($group) { + $query->where(function (Builder $query) use ($group): void { $parents = array_values($group['parents'] ?? []); $hasRoot = $group['has_root'] ?? false; $parentIdName = $group['model']->getParentIdName(); @@ -115,42 +124,17 @@ protected function constrainEagerModels(QueryBuilder $query, array $models): voi : $query->orWhereNull($qualifiedParentIdName); } }); - }); - } - } - - /** - * Determine whether a result is a sibling of the parent model. - */ - protected function matches(Model $model, Model $related): bool - { - if ($this->scopeKey($model) !== $this->scopeKey($related)) { - return false; - } - - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ - $relatedParentId = $related->getParentId(); /* @phpstan-ignore method.notFound */ - $sameParent = $parentId === null || $relatedParentId === null - ? $parentId === $relatedParentId - : (string) $parentId === (string) $relatedParentId; - - if (! $sameParent || $this->andSelf) { - return $sameParent; - } - - $key = $model->getKey(); - $relatedKey = $related->getKey(); - - // Strict parent and related keys are validated before matching. - return (string) $key !== (string) $relatedKey; + }, 'or'); + }); } /** - * Index eager results by exact scope and parent while preserving query order. + * Match siblings from exact scope-and-parent buckets in query order. */ - protected function indexResults(Collection $results): array + protected function matchMany(array $models, Collection $results): array { $index = []; + $keys = []; foreach ($results as $related) { $scope = $this->scopeKey($related); @@ -161,43 +145,44 @@ protected function indexResults(Collection $results): array } else { $index[$scope]['parents'][$parentId][] = $related; } + + if (! $this->andSelf) { + $keys[spl_object_id($related)] = $this->matchKey($related); + } } - return $index; - } + $matches = []; - /** - * Match siblings from an exact scope-and-parent bucket. - */ - protected function matchFromIndex(Model $model, array $indexed): Collection - { - $scope = $indexed[$this->scopeKey($model)] ?? null; + foreach ($models as $model) { + $scope = $index[$this->scopeKey($model)] ?? null; - if ($scope === null) { - return $this->related->newCollection(); - } + if ($scope === null) { + continue; + } - $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ - $candidates = $parentId === null - ? ($scope['roots'] ?? []) - : ($scope['parents'][$parentId] ?? []); + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + $siblings = $parentId === null ? ($scope['roots'] ?? []) : ($scope['parents'][$parentId] ?? []); - if ($this->andSelf) { - return $this->related->newCollection($candidates); - } + if ($this->andSelf) { + // Parents in one bucket share its array until a collection modifies it. + $matches[spl_object_id($model)] = $siblings; - $key = $model->getKey(); - $matches = []; + continue; + } - foreach ($candidates as $candidate) { - $candidateKey = $candidate->getKey(); + $key = $this->matchKey($model); + $found = []; - if ((string) $candidateKey !== (string) $key) { - $matches[] = $candidate; + foreach ($siblings as $candidate) { + if ($key === null || $keys[spl_object_id($candidate)] !== $key) { + $found[] = $candidate; + } } + + $matches[spl_object_id($model)] = $found; } - return $this->related->newCollection($matches); + return $matches; } /** @@ -253,7 +238,7 @@ protected function relationExistenceCondition(string $hash, string $table, strin /** * Constrain a query to the model's parent ID. */ - protected function whereParentId(QueryBuilder $query, Model $model): void + protected function whereParentId(Builder|QueryBuilder $query, Model $model): void { $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound */ $qualifiedParentIdName = $model->qualifyColumn($parentIdName); diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index 4cd2429dc6..1371c75e29 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -4,9 +4,12 @@ namespace Hypervel\NestedSet; +use Closure; use DateTimeInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\HasBuilder; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; use Hypervel\Database\Query\Builder as BaseQueryBuilder; @@ -22,14 +25,15 @@ use function Hypervel\Support\enum_value; /** - * @template TModel of Model - * * @property null|int|string $parent_id * @property ?int $depth * @property ?static $parent */ trait HasNode { + /** @use HasBuilder> */ + use HasBuilder; + /** * Pending operations. */ @@ -41,12 +45,14 @@ trait HasNode protected bool $moved = false; /** - * Whether the node is being deleted by an evented descendant cascade. + * Whether the node is being deleted or restored by an ancestor's evented cascade. */ - protected bool $deletingAsDescendant = false; + protected bool $cascadingAsDescendant = false; /** * Create a new Eloquent query builder for the model. + * + * @return QueryBuilder */ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder { @@ -54,10 +60,12 @@ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder ??= $this->resolveCustomBuilderClass(); if ($builderClass === false) { - return new QueryBuilder($query); + $builderClass = static::$builder === EloquentBuilder::class + ? QueryBuilder::class + : static::$builder; } - if (! is_subclass_of($builderClass, QueryBuilder::class)) { + if (! is_a($builderClass, QueryBuilder::class, true)) { throw new LogicException(sprintf( 'Nested set model [%s] must use a builder that extends [%s].', static::class, @@ -72,41 +80,75 @@ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder } /** - * Bootstrap node events. - */ - public static function bootHasNode(): void - { - static::saving(function ($model): void { - $model->callPendingActions(); - }); + * Fire the given event for the model, keeping the tree consistent around its observers. + * + * Upstream maintains the tree from listeners registered in bootNodeTrait(). A custom + * $dispatchesEvents result skips those listeners, and listeners cannot act after every + * deleting observer has allowed a delete, so the maintenance runs here instead. + */ + protected function fireModelEvent(string $event, bool $halt = true): mixed + { + // Quiet and event-free operations skip tree maintenance, as they skip listeners. + // Descendants changed by an ancestor's evented cascade only notify observers. + if (! in_array($event, ['saving', 'deleting', 'deleted', 'restoring', 'restored'], true) + || ! isset(static::$dispatcher) + || static::eventsDisabled() + || ($this->cascadingAsDescendant && $event !== 'saving') + ) { + return parent::fireModelEvent($event, $halt); + } - static::deleting(function ($model): void { - if (! $model->deletingAsDescendant) { - $model->prepareForNestedSetMutation(); - } - }); + switch ($event) { + case 'saving': + $this->callPendingAction(); + break; + case 'deleting': + $persisted = $this->findPersistedNodeAttributes($this->getMutationIdentityColumns()); - static::deleted(function ($model): void { - if (! $model->deletingAsDescendant) { - $model->deleteDescendants(); - } - }); + // An ancestor's delete may already have removed the row. + if ($persisted === null) { + return false; + } - // The restore events are supplied by SoftDeletes rather than Model. - if (static::isSoftDeletable()) { - static::restoring(function ($model): void { - $model->prepareForNestedSetMutation([$model->getDeletedAtColumn()]); - }); + $this->prepareFromPersistedAttributes($persisted); + + $result = parent::fireModelEvent($event, $halt); - static::restored(function ($model): void { + // Remove descendants once every observer has allowed the delete, and + // before the node's own row so restricting parent keys are satisfied. + if ($result !== false && $this->hardDeleting()) { + $this->deleteDescendants(); + } + + return $result; + case 'deleted': + if ($this->hardDeleting()) { + $height = $this->getRgt() - $this->getLft() + 1; + + $this->newNestedSetQuery()->makeGap($this->getRgt() + 1, -$height); + + // In case the user wants to re-create the node + $this->makeRoot(); + } else { + $this->deleteDescendants(); + } + + break; + case 'restoring': + $this->prepareForNestedSetMutation([$this->getDeletedAtColumn()]); + break; + case 'restored': /** @var null|DateTimeInterface|int|string $deletedAt */ - $deletedAt = $model->getPrevious()[$model->getDeletedAtColumn()] ?? null; + $deletedAt = $this->getPrevious()[$this->getDeletedAtColumn()] ?? null; if ($deletedAt !== null) { - $model->restoreDescendants($deletedAt); + $this->restoreDescendants($deletedAt); } - }); + + break; } + + return parent::fireModelEvent($event, $halt); } /** @@ -122,7 +164,7 @@ protected function setNodeAction(string $action, mixed ...$args): static /** * Call pending action. */ - protected function callPendingActions(): void + protected function callPendingAction(): void { $this->moved = false; @@ -136,7 +178,7 @@ protected function callPendingActions(): void return; } - $action = array_shift($this->pending); + $action = $this->pending[0]; if ($action === 'raw') { $this->ensureNestedSetScopeIsUnchanged(); @@ -146,11 +188,46 @@ protected function callPendingActions(): void } $method = 'action' . ucfirst($action); - $parameters = $this->pending; + $parameters = array_slice($this->pending, 1); $this->pending = []; - $this->moved = call_user_func_array([$this, $method], $parameters); + $this->moved = $this->{$method}(...$parameters); + } + + /** + * Save the model to the database. + */ + public function save(array $options = []): bool + { + return $this->restorePendingActionUnlessSaved(fn (): bool => parent::save($options)); + } + + /** + * Save the model to the database, ignoring specific unique constraint conflicts. + */ + public function saveOrIgnore(array $options = [], array|string|null $uniqueBy = null): bool + { + return $this->restorePendingActionUnlessSaved(fn (): bool => parent::saveOrIgnore($options, $uniqueBy)); + } + + /** + * Run a save, restoring its consumed action when the save does not complete. + */ + protected function restorePendingActionUnlessSaved(Closure $save): bool + { + $pending = $this->pending; + $saved = false; + + try { + return $saved = $save(); + } finally { + // Like dirty attributes, the action survives a save that did not complete, + // unless an observer queued a new one. + if (! $saved && $this->pending === []) { + $this->pending = $pending; + } + } } /** @@ -241,7 +318,7 @@ protected function actionAppendOrPrepend(self $parent, bool $prepend = false): b */ protected function actionAppendOrPrependPrepared(self $parent, bool $prepend = false): bool { - $this->ensureNodeInTree($parent) + $this->assertNodeExists($parent) ->assertNotDescendant($parent) ->ensureSameTree($parent) ->setParent($parent) @@ -295,7 +372,7 @@ protected function actionBeforeOrAfter(self $node, bool $after = false): bool { $node->prepareForNestedSetMutation(); - $this->ensureNodeInTree($node) + $this->assertNodeExists($node) ->assertNotDescendant($node) ->ensureSameTree($node) ->setParentFromSibling($node) @@ -337,8 +414,15 @@ public function prepareForNestedSetMutation(array $extraColumns = []): void ...$this->getMutationIdentityColumns(), ...$extraColumns, ])); - $persisted = $this->getPersistedNodeAttributes($columns); + $this->prepareFromPersistedAttributes($this->getPersistedNodeAttributes($columns)); + } + + /** + * Prepare an existing model for a structural mutation from its persisted row. + */ + protected function prepareFromPersistedAttributes(array $persisted): void + { $this->ensureNestedSetScopeIsUnchanged($persisted); $this->applyPersistedNodeAttributes($persisted); $this->ensureConcreteNestedSetScope('mutation'); @@ -358,6 +442,15 @@ protected function applyPersistedNodeAttributes(array $attributes): void * Read selected attributes from the exact persisted row. */ protected function getPersistedNodeAttributes(array $columns): array + { + return $this->findPersistedNodeAttributes($columns) + ?? throw (new ModelNotFoundException)->setModel(static::class, [$this->getKey()]); + } + + /** + * Read selected attributes from the exact persisted row, if it still exists. + */ + protected function findPersistedNodeAttributes(array $columns): ?array { if ($this->getKey() === null) { throw new LogicException(sprintf( @@ -367,11 +460,14 @@ protected function getPersistedNodeAttributes(array $columns): array )); } - return $this->newModelQuery() + // A base read keeps internal reloads from firing retrieved listeners. + $attributes = $this->newModelQuery() ->useWritePdo() ->whereKey($this->getKey()) - ->firstOrFail($columns) - ->getAttributes(); + ->toBase() + ->first($columns); + + return $attributes === null ? null : (array) $attributes; } /** @@ -404,6 +500,8 @@ protected function getMutationIdentityColumns(): array /** * Relation to the parent. + * + * @return BelongsTo */ public function parent(): BelongsTo { @@ -413,6 +511,8 @@ public function parent(): BelongsTo /** * Relation to children. + * + * @return HasMany */ public function children(): HasMany { @@ -422,6 +522,8 @@ public function children(): HasMany /** * Get query for descendants of the node. + * + * @return DescendantsRelation */ public function descendants(): DescendantsRelation { @@ -430,6 +532,8 @@ public function descendants(): DescendantsRelation /** * Get query for siblings of the node. + * + * @return SiblingsRelation */ public function siblings(): SiblingsRelation { @@ -438,6 +542,8 @@ public function siblings(): SiblingsRelation /** * Get the relation for the node siblings and the node itself. + * + * @return SiblingsRelation */ public function siblingsAndSelf(): SiblingsRelation { @@ -447,7 +553,7 @@ public function siblingsAndSelf(): SiblingsRelation /** * Get the node siblings and the node itself. * - * @return Collection + * @return Collection */ public function getSiblingsAndSelf(array $columns = ['*']): Collection { @@ -456,6 +562,8 @@ public function getSiblingsAndSelf(array $columns = ['*']): Collection /** * Get query for siblings after the node. + * + * @return QueryBuilder */ public function nextSiblings(): QueryBuilder { @@ -471,6 +579,8 @@ public function nextSiblings(): QueryBuilder /** * Get query for siblings before the node. + * + * @return QueryBuilder */ public function prevSiblings(): QueryBuilder { @@ -486,6 +596,8 @@ public function prevSiblings(): QueryBuilder /** * Get query for nodes after current node. + * + * @return QueryBuilder */ public function nextNodes(): QueryBuilder { @@ -501,7 +613,9 @@ public function nextNodes(): QueryBuilder } /** - * Get query for nodes before current node in reversed order. + * Get query for nodes before current node. + * + * @return QueryBuilder */ public function prevNodes(): QueryBuilder { @@ -518,6 +632,8 @@ public function prevNodes(): QueryBuilder /** * Get query ancestors of the node. + * + * @return AncestorsRelation */ public function ancestors(): AncestorsRelation { @@ -747,6 +863,7 @@ protected function moveNode(int $position, ?int $targetDepth = null): bool ) > 0; if ($updated) { + // Compute post-move position from pre-move values if ($position > $lft) { $this->setLft($position - $height); $this->setRgt($position - 1); @@ -761,6 +878,7 @@ protected function moveNode(int $position, ?int $targetDepth = null): bool $this->refreshNode(); } + // Sync originals: mass UPDATE already set these in DB, avoid redundant Eloquent write $this->syncOriginalAttributes([ $this->getLftName(), $this->getRgtName(), @@ -793,40 +911,31 @@ protected function insertNode(int $position, ?int $targetDepth = null): bool } /** - * Update the tree when the node is removed physically. + * Delete the node's descendants, including any hidden by ordinary global scopes. + * + * Hard deletes run before the node's own row is deleted and soft deletes after it, + * so descendants are never trashed before the node. */ protected function deleteDescendants(): void { - $lft = $this->getLft(); - $rgt = $this->getRgt(); - - $method = static::isSoftDeletable() && $this->forceDeleting - ? 'forceDelete' - : 'delete'; - if ($this->shouldFireDescendantEvents()) { - $this->deleteDescendantsWithEvents($method === 'forceDelete'); - } else { - $query = $method === 'forceDelete' - ? $this->newNestedSetQuery() - : $this->newScopedQuery(); + $this->deleteDescendantsWithEvents(static::isSoftDeletable() && $this->forceDeleting); - $query->whereDescendantOf($this) - ->{$method}(); + return; } - if ($this->hasForceDeleting()) { - $height = $rgt - $lft + 1; + $query = $this->newNestedSetQuery()->whereDescendantOf($this); - $this->newNestedSetQuery()->makeGap($rgt + 1, -$height); - - // In case if user wants to re-create the node - $this->makeRoot(); + if ($this->hardDeleting()) { + // MySQL and MariaDB check a restricting parent key as each row is deleted. + $query->orderBy($this->getLftName(), 'desc')->forceDelete(); + } else { + $query->withoutTrashed()->delete(); } } /** - * Determine whether descendant model events should be fired during deletion. + * Determine whether descendant model events should be fired during deletion and restoration. */ protected function shouldFireDescendantEvents(): bool { @@ -834,9 +943,9 @@ protected function shouldFireDescendantEvents(): bool } /** - * Get the descendant deletion chunk size. + * Get the number of descendants loaded per evented deletion or restoration chunk. */ - protected function getDescendantDeleteChunkSize(): int + protected function getDescendantChunkSize(): int { return 1000; } @@ -846,65 +955,95 @@ protected function getDescendantDeleteChunkSize(): int */ protected function deleteDescendantsWithEvents(bool $forceDelete): void { - $lftName = $this->getLftName(); - $query = $this->newNestedSetQuery() - ->useWritePdo() - ->where($lftName, '>', $this->getLft()) - ->where($lftName, '<', $this->getRgt()) - ->orderBy($lftName, 'desc'); + $query = $this->newNestedSetQuery()->whereDescendantOf($this); if (static::isSoftDeletable() && ! $forceDelete) { - $query->whereNull($this->getDeletedAtColumn()); + $query->withoutTrashed(); } + $this->changeDescendantsWithEvents( + $query, + childrenFirst: true, + operation: 'Deleting', + change: static fn (Model $descendant): int|bool|null => $forceDelete + ? $descendant->forceDelete() + : $descendant->delete(), + ); + } + + /** + * Change descendants one at a time in bounded chunks ordered by their left bound. + * + * @param Closure(Model): (null|bool|int) $change + */ + protected function changeDescendantsWithEvents( + QueryBuilder $query, + bool $childrenFirst, + string $operation, + Closure $change, + ): void { + $lftName = $this->getLftName(); + $chunkSize = $this->getDescendantChunkSize(); $cursor = null; + $query->useWritePdo()->orderBy($lftName, $childrenFirst ? 'desc' : 'asc'); + do { $chunk = clone $query; if ($cursor !== null) { - $chunk->where($lftName, '<', $cursor); + $chunk->where($lftName, $childrenFirst ? '<' : '>', $cursor); } - $descendants = $chunk - ->limit($this->getDescendantDeleteChunkSize()) - ->get(); + $descendants = $chunk->limit($chunkSize)->get(); foreach ($descendants as $descendant) { - $cursor = $descendant->getLft(); /* @phpstan-ignore method.notFound */ - $descendant->deletingAsDescendant = true; /* @phpstan-ignore property.notFound */ + $cursor = $descendant->getLft(); + $descendant->cascadingAsDescendant = true; try { - $deleted = $forceDelete - ? $descendant->forceDelete() - : $descendant->delete(); - - if ($deleted === false) { + if ($change($descendant) === false) { throw new LogicException(sprintf( - 'Deleting nested set descendant [%s] with key [%s] was vetoed.', + '%s nested set descendant [%s] with key [%s] was vetoed.', + $operation, $descendant::class, $descendant->getKey() ?? 'null', )); } } finally { - $descendant->deletingAsDescendant = false; /* @phpstan-ignore property.notFound */ + $descendant->cascadingAsDescendant = false; } } - } while ($descendants->isNotEmpty()); + } while ($descendants->count() === $chunkSize); } /** - * Restore the descendants. + * Restore descendants deleted at or after the given stored deletion time. */ protected function restoreDescendants(DateTimeInterface|int|string $deletedAt): void { - $this->descendants() - ->where($this->getDeletedAtColumn(), '>=', $deletedAt) - ->restore(); + $query = $this->newNestedSetQuery() + ->whereDescendantOf($this) + ->where($this->getDeletedAtColumn(), '>=', $deletedAt); + + if ($this->shouldFireDescendantEvents()) { + $this->changeDescendantsWithEvents( + $query, + childrenFirst: false, + operation: 'Restoring', + change: static fn (Model $descendant): bool => $descendant->restore(), + ); + + return; + } + + $query->restore(); } /** * Get a new base query that includes deleted nodes. + * + * @return QueryBuilder */ public function newNestedSetQuery(?string $table = null): QueryBuilder { @@ -915,6 +1054,8 @@ public function newNestedSetQuery(?string $table = null): QueryBuilder /** * Get a new query with ordinary visibility and the concrete tree scope. + * + * @return QueryBuilder */ public function newScopedQuery(?string $table = null): QueryBuilder { @@ -1103,6 +1244,8 @@ public function getNestedSetScopeKey(): string /** * Begin a query for one concrete nested set scope. + * + * @return QueryBuilder */ public static function scoped(array $attributes): QueryBuilder { @@ -1116,7 +1259,8 @@ public static function scoped(array $attributes): QueryBuilder /** * Create a new nested set collection. * - * @return Collection + * @param array $models + * @return Collection */ public function newCollection(array $models = []): Collection { @@ -1144,7 +1288,10 @@ public static function create(array $attributes = [], ?self $parent = null): sta $relation->add(static::create($child, $instance)); } - $instance->refreshNode(); + // Each append refreshes this node, but descendants of the last child widen it afterwards. + if ($relation->isNotEmpty()) { + $instance->refreshNode(); + } $relationParent = clone $instance; $relationParent->setRelations([]); @@ -1182,7 +1329,7 @@ public function getDescendantCount(): int * Set the value of model's parent id key. * Behind the scenes node is appended to found parent node. */ - public function setParentIdAttribute(int|string|null $value): void + public function setParentIdAttribute(int|string|null $value): static { $parentIdName = $this->getParentIdName(); $hasCurrent = array_key_exists($parentIdName, $this->attributes); @@ -1192,17 +1339,17 @@ public function setParentIdAttribute(int|string|null $value): void $current === $value || ($current !== null && $value !== null && (string) $current === (string) $value) )) { - return; + return $this; } if ($value === null) { - $this->makeRoot(); - - return; + return $this->makeRoot(); } - $this->setParentId($value); - $this->setNodeAction('appendToParentId', $value); + // The new parent is only looked up when the save runs, so forget a loaded old one. + $this->unsetRelation('parent'); + + return $this->setParentId($value)->setNodeAction('appendToParentId', $value); } /** @@ -1296,10 +1443,10 @@ public function getDepth(): ?int } /** - * Returns node that is next to current node without constraining to siblings. + * Return the node that is next to the current node without constraining to siblings. * This can be either a next sibling or a next sibling of the parent node. * - * @return null|TModel + * @return null|static */ public function getNextNode(array $columns = ['*']): ?Model { @@ -1307,10 +1454,10 @@ public function getNextNode(array $columns = ['*']): ?Model } /** - * Returns node that is before current node without constraining to siblings. + * Return the node that is before the current node without constraining to siblings. * This can be either a prev sibling or parent node. * - * @return null|TModel + * @return null|static */ public function getPrevNode(array $columns = ['*']): ?Model { @@ -1320,7 +1467,7 @@ public function getPrevNode(array $columns = ['*']): ?Model /** * Get the node's ancestors. * - * @return Collection + * @return Collection */ public function getAncestors(array $columns = ['*']): Collection { @@ -1330,7 +1477,7 @@ public function getAncestors(array $columns = ['*']): Collection /** * Get the node's descendants. * - * @return Collection + * @return Collection */ public function getDescendants(array $columns = ['*']): Collection { @@ -1340,7 +1487,7 @@ public function getDescendants(array $columns = ['*']): Collection /** * Get the node's siblings. * - * @return Collection + * @return Collection */ public function getSiblings(array $columns = ['*']): Collection { @@ -1350,7 +1497,7 @@ public function getSiblings(array $columns = ['*']): Collection /** * Get siblings after the node. * - * @return Collection + * @return Collection */ public function getNextSiblings(array $columns = ['*']): Collection { @@ -1360,7 +1507,7 @@ public function getNextSiblings(array $columns = ['*']): Collection /** * Get siblings before the node. * - * @return Collection + * @return Collection */ public function getPrevSiblings(array $columns = ['*']): Collection { @@ -1370,7 +1517,7 @@ public function getPrevSiblings(array $columns = ['*']): Collection /** * Get the next sibling. * - * @return null|TModel + * @return null|static */ public function getNextSibling(array $columns = ['*']): ?Model { @@ -1380,7 +1527,7 @@ public function getNextSibling(array $columns = ['*']): ?Model /** * Get the previous sibling. * - * @return null|TModel + * @return null|static */ public function getPrevSibling(array $columns = ['*']): ?Model { @@ -1497,7 +1644,7 @@ public function hasMoved(): bool /** * Get whether user is intended to delete the model from database entirely. */ - protected function hasForceDeleting(): bool + protected function hardDeleting(): bool { return ! static::isSoftDeletable() || $this->forceDeleting; } @@ -1537,6 +1684,7 @@ public function setRgt(?int $value): static */ public function setParentId(int|string|null $value): static { + // Only null marks a root; unlike upstream, 0 and '' stay real parent keys. $this->attributes[$this->getParentIdName()] = $value; return $this; @@ -1547,7 +1695,7 @@ public function setParentId(int|string|null $value): static */ public function setDepth(?int $value): static { - $this->attributes[$this->getDepthName()] = $value; + $this->attributes[$this->getDepthName()] = $value ?? 0; return $this; } @@ -1610,9 +1758,8 @@ protected function ensureLoadedStructuralTargetIsValid(self $node): void $this->assertNotDescendant($node); } - // Keep NodeTrait::assertNotDescendant() so upstream subclasses remain compatible. /** - * Assert that a node is not this node's descendant. + * Assert that a node is neither this node nor one of its descendants. */ protected function assertNotDescendant(self $node): static { @@ -1624,9 +1771,9 @@ protected function assertNotDescendant(self $node): static } /** - * Ensure that a node has persisted tree bounds. + * Assert that a node has positive tree bounds. */ - protected function ensureNodeInTree(self $node): static + protected function assertNodeExists(self $node): static { if (($node->getLft() ?? 0) < 1 || ($node->getRgt() ?? 0) < 1) { throw new LogicException('Node must be part of a tree.'); diff --git a/src/nested-set/src/NestedSet.php b/src/nested-set/src/NestedSet.php index ea8b5ddad3..7777840547 100644 --- a/src/nested-set/src/NestedSet.php +++ b/src/nested-set/src/NestedSet.php @@ -83,13 +83,15 @@ public static function ulidColumns(Blueprint $table, array $scopes = []): void public static function dropColumns(Blueprint $table, array $scopes = []): void { $table->dropIndex([...$scopes, self::RGT]); - $table->dropIndex([...$scopes, self::LFT]); + $table->dropIndex([...$scopes, self::LFT, self::RGT]); $table->dropIndex([...$scopes, self::PARENT_ID, self::LFT]); $table->dropColumn(static::getDefaultColumns()); } /** * Get a list of default columns. + * + * @return array */ public static function getDefaultColumns(): array { @@ -161,7 +163,8 @@ protected static function addBoundsAndDepth(Blueprint $table): void protected static function addIndexes(Blueprint $table, array $scopes): void { $table->index([...$scopes, self::RGT]); - $table->index([...$scopes, self::LFT]); + // Indexing both bounds lets ancestor queries filter the right bound within the index. + $table->index([...$scopes, self::LFT, self::RGT]); $table->index([...$scopes, self::PARENT_ID, self::LFT]); } diff --git a/src/nested-set/src/NestedSetServiceProvider.php b/src/nested-set/src/NestedSetServiceProvider.php index 49d57674f7..56271fdc0f 100644 --- a/src/nested-set/src/NestedSetServiceProvider.php +++ b/src/nested-set/src/NestedSetServiceProvider.php @@ -14,6 +14,8 @@ class NestedSetServiceProvider extends ServiceProvider */ public function register(): void { + // These macros include the depth column and indexes, replacing upstream's separate + // depth and index macros. Custom layouts use ordinary Blueprint methods. Blueprint::macro('nestedSet', function (array $scopes = []): void { NestedSet::columns($this, $scopes); }); diff --git a/src/scout/composer.json b/src/scout/composer.json index 4ce5c2de55..855f0f0406 100644 --- a/src/scout/composer.json +++ b/src/scout/composer.json @@ -73,6 +73,11 @@ "providers": [ "Hypervel\\Scout\\ScoutServiceProvider" ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] } } } diff --git a/src/scout/extension.neon b/src/scout/extension.neon new file mode 100644 index 0000000000..7c92ff5bf7 --- /dev/null +++ b/src/scout/extension.neon @@ -0,0 +1,3 @@ +parameters: + eloquentBuilderMacros: + Hypervel\Scout\Searchable: Hypervel\Scout\PHPStan\SearchableMacros diff --git a/src/scout/src/PHPStan/SearchableMacros.php b/src/scout/src/PHPStan/SearchableMacros.php new file mode 100644 index 0000000000..3371190a56 --- /dev/null +++ b/src/scout/src/PHPStan/SearchableMacros.php @@ -0,0 +1,23 @@ +assertTrue($user->exists); + $this->assertFalse($user->isForceDeleting()); } public function testForceDestroyFullyDeletesRecord(): void diff --git a/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php b/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php index a3c2e1fa1b..2b464987c8 100644 --- a/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php +++ b/tests/Database/PHPStan/ForwardedBuilderMethodExtensionTest.php @@ -4,8 +4,11 @@ namespace Hypervel\Tests\Database\PHPStan; +use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\PHPStan\BuilderMacroResolver; use Hypervel\Database\PHPStan\ForwardedBuilderMethodExtension; use Hypervel\Database\PHPStan\ModelScopeMethodResolver; +use Hypervel\Database\PHPStan\SoftDeletingMacros; use Hypervel\Tests\TestCase; use PHPStan\Reflection\ReflectionProvider; @@ -16,6 +19,10 @@ public function testDoesNotReflectClassesDuringConstruction(): void $reflectionProvider = $this->createMock(ReflectionProvider::class); $reflectionProvider->expects($this->never())->method('getClass'); - new ForwardedBuilderMethodExtension($reflectionProvider, new ModelScopeMethodResolver); + new ForwardedBuilderMethodExtension( + $reflectionProvider, + new ModelScopeMethodResolver, + new BuilderMacroResolver($reflectionProvider, [SoftDeletes::class => SoftDeletingMacros::class]), + ); } } diff --git a/tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php b/tests/Integration/NestedSet/Database/MariaDb/NestedSetSchemaTest.php similarity index 57% rename from tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php rename to tests/Integration/NestedSet/Database/MariaDb/NestedSetSchemaTest.php index cba8c24d6a..c40c65fe96 100644 --- a/tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php +++ b/tests/Integration/NestedSet/Database/MariaDb/NestedSetSchemaTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Integration\NestedSet\Database\MariaDb; use Hypervel\Testbench\Attributes\RequiresDatabase; -use Hypervel\Tests\Integration\NestedSet\Database\NestedSetDatabaseTestCase; +use Hypervel\Tests\NestedSet\NestedSetSchemaTest as BaseNestedSetSchemaTest; #[RequiresDatabase('mariadb')] -class NestedSetDatabaseTest extends NestedSetDatabaseTestCase +class NestedSetSchemaTest extends BaseNestedSetSchemaTest { } diff --git a/tests/Integration/NestedSet/Database/MariaDb/NodeTest.php b/tests/Integration/NestedSet/Database/MariaDb/NodeTest.php new file mode 100644 index 0000000000..c194c20472 --- /dev/null +++ b/tests/Integration/NestedSet/Database/MariaDb/NodeTest.php @@ -0,0 +1,13 @@ +=8.0')] -class NestedSetDatabaseTest extends NestedSetDatabaseTestCase +class NestedSetSchemaTest extends BaseNestedSetSchemaTest { } diff --git a/tests/Integration/NestedSet/Database/MySql/NodeTest.php b/tests/Integration/NestedSet/Database/MySql/NodeTest.php new file mode 100644 index 0000000000..f3a639396e --- /dev/null +++ b/tests/Integration/NestedSet/Database/MySql/NodeTest.php @@ -0,0 +1,13 @@ +=8.0')] +class NodeTest extends BaseNodeTest +{ +} diff --git a/tests/Integration/NestedSet/Database/MySql/NodeUuidTest.php b/tests/Integration/NestedSet/Database/MySql/NodeUuidTest.php new file mode 100644 index 0000000000..c380a4df39 --- /dev/null +++ b/tests/Integration/NestedSet/Database/MySql/NodeUuidTest.php @@ -0,0 +1,13 @@ +=8.0')] +class NodeUuidTest extends BaseNodeUuidTest +{ +} diff --git a/tests/Integration/NestedSet/Database/MySql/ScopedNodeTest.php b/tests/Integration/NestedSet/Database/MySql/ScopedNodeTest.php new file mode 100644 index 0000000000..71c26733a3 --- /dev/null +++ b/tests/Integration/NestedSet/Database/MySql/ScopedNodeTest.php @@ -0,0 +1,13 @@ +=8.0')] +class ScopedNodeTest extends BaseScopedNodeTest +{ +} diff --git a/tests/Integration/NestedSet/Database/MySql/ScopedNodeUuidTest.php b/tests/Integration/NestedSet/Database/MySql/ScopedNodeUuidTest.php new file mode 100644 index 0000000000..06cfd61afa --- /dev/null +++ b/tests/Integration/NestedSet/Database/MySql/ScopedNodeUuidTest.php @@ -0,0 +1,13 @@ +=8.0')] +class ScopedNodeUuidTest extends BaseScopedNodeUuidTest +{ +} diff --git a/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php b/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php deleted file mode 100644 index 3d716da992..0000000000 --- a/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php +++ /dev/null @@ -1,397 +0,0 @@ -id(); - $table->string('name'); - NestedSet::columns($table); - }); - - Schema::create('nested_set_integer_nodes', static function (Blueprint $table): void { - $table->increments('id'); - $table->string('name'); - NestedSet::integerColumns($table); - }); - - Schema::create('nested_set_uuid_nodes', static function (Blueprint $table): void { - $table->uuid('id')->primary(); - $table->uuid('tenant_id'); - $table->string('name'); - $table->softDeletes(); - NestedSet::uuidColumns($table, ['tenant_id']); - }); - - Schema::create('nested_set_ulid_nodes', static function (Blueprint $table): void { - $table->ulid('id')->primary(); - $table->string('name'); - NestedSet::ulidColumns($table); - }); - } - - protected function destroyDatabaseMigrations(): void - { - Schema::dropIfExists('nested_set_ulid_nodes'); - Schema::dropIfExists('nested_set_uuid_nodes'); - Schema::dropIfExists('nested_set_integer_nodes'); - Schema::dropIfExists('nested_set_bigint_nodes'); - } - - public function testSchemaUsesMatchingKeyTypesAndExactIndexOrder(): void - { - foreach ([ - 'nested_set_bigint_nodes', - 'nested_set_integer_nodes', - 'nested_set_uuid_nodes', - 'nested_set_ulid_nodes', - ] as $table) { - $this->assertSame( - Schema::getColumnType($table, 'id'), - Schema::getColumnType($table, NestedSet::PARENT_ID), - ); - $this->assertSame( - match ($this->driver) { - 'pgsql' => 'int2', - 'sqlite' => 'integer', - default => 'smallint', - }, - Schema::getColumnType($table, NestedSet::DEPTH), - ); - } - - $this->assertNestedSetIndexes('nested_set_bigint_nodes'); - $this->assertNestedSetIndexes('nested_set_integer_nodes'); - $this->assertNestedSetIndexes('nested_set_uuid_nodes', ['tenant_id']); - $this->assertNestedSetIndexes('nested_set_ulid_nodes'); - } - - public function testSchemaDropIsSymmetric(): void - { - Schema::table('nested_set_uuid_nodes', static function (Blueprint $table): void { - NestedSet::dropColumns($table, ['tenant_id']); - }); - - $this->assertSame( - ['id', 'tenant_id', 'name', 'deleted_at'], - Schema::getColumnListing('nested_set_uuid_nodes'), - ); - $this->assertSame([], array_values(array_filter( - Schema::getIndexes('nested_set_uuid_nodes'), - static fn (array $index): bool => ! $index['primary'], - ))); - } - - public function testIntegerAndUuidTreesMaintainDepthAndTenantIsolation(): void - { - $integerRoot = IntegerNestedSetNode::create(['name' => 'integer root']); - $integerChild = new IntegerNestedSetNode(['name' => 'integer child']); - $integerChild->appendToNode($integerRoot)->save(); - - $firstRoot = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000101', - self::FIRST_TENANT, - 'first root', - ); - $firstChild = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000102', - self::FIRST_TENANT, - 'first child', - $firstRoot, - ); - $secondRoot = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000201', - self::SECOND_TENANT, - 'second root', - ); - - $this->assertSame(0, $integerRoot->getDepth()); - $this->assertSame(1, $integerChild->getDepth()); - $this->assertSame(0, $firstRoot->getDepth()); - $this->assertSame(1, $firstChild->getDepth()); - $this->assertSame([1, 4], $firstRoot->getBounds()); - $this->assertSame([1, 2], $secondRoot->getBounds()); - - $this->assertSame( - [$firstChild->getKey()], - $firstRoot->descendants()->pluck('id')->all(), - ); - $this->assertSame( - [$firstRoot->getKey()], - $firstChild->ancestors()->pluck('id')->all(), - ); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->isBroken()); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::SECOND_TENANT, - ])->isBroken()); - } - - public function testMovingAnExistingSubtreeUpdatesPersistedDepth(): void - { - $firstRoot = IntegerNestedSetNode::create(['name' => 'first root']); - $parent = new IntegerNestedSetNode(['name' => 'parent']); - $parent->appendToNode($firstRoot)->save(); - $moved = new IntegerNestedSetNode(['name' => 'moved']); - $moved->appendToNode($parent)->save(); - $descendant = new IntegerNestedSetNode(['name' => 'descendant']); - $descendant->appendToNode($moved)->save(); - $secondRoot = IntegerNestedSetNode::create(['name' => 'second root']); - - $moved->appendToNode($secondRoot)->save(); - - $this->assertSame(1, IntegerNestedSetNode::findOrFail($moved->getKey())->getDepth()); - $this->assertSame(2, IntegerNestedSetNode::findOrFail($descendant->getKey())->getDepth()); - $this->assertFalse(IntegerNestedSetNode::query()->isBroken()); - } - - public function testNestedSavepointRollbackDoesNotRetainRolledBackCoordinates(): void - { - $firstRoot = IntegerNestedSetNode::create(['name' => 'first root']); - $child = new IntegerNestedSetNode(['name' => 'child']); - $child->appendToNode($firstRoot)->save(); - $secondRoot = IntegerNestedSetNode::create(['name' => 'second root']); - $rollback = new RuntimeException('Roll back the nested move.'); - - DB::transaction(function () use ($child, $secondRoot, $rollback): void { - $caught = null; - - try { - DB::transaction(function () use ($child, $secondRoot, $rollback): never { - $child->appendToNode($secondRoot)->save(); - - throw $rollback; - }); - } catch (RuntimeException $exception) { - $caught = $exception; - } - - $this->assertSame($rollback, $caught); - - $child->appendToNode($secondRoot)->save(); - }); - - $this->assertSame( - $secondRoot->getKey(), - IntegerNestedSetNode::findOrFail($child->getKey())->getParentId(), - ); - $this->assertFalse(IntegerNestedSetNode::query()->isBroken()); - } - - public function testCompositeDiagnosticsAndCompoundOrderingArePortable(): void - { - $first = IntegerNestedSetNode::create(['name' => 'first']); - $second = IntegerNestedSetNode::create(['name' => 'second']); - - $this->assertSame( - [$first->getKey(), $second->getKey()], - IntegerNestedSetNode::query() - ->select(['id', NestedSet::LFT]) - ->whereKey($first->getKey()) - ->union( - IntegerNestedSetNode::query() - ->select(['id', NestedSet::LFT]) - ->whereKey($second->getKey()), - ) - ->orderByRaw('case when name = ? then 0 else 1 end', ['second']) - ->defaultOrder() - ->pluck('id') - ->all(), - ); - - $root = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000501', - self::FIRST_TENANT, - 'root', - ); - $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000502', - self::FIRST_TENANT, - 'child', - $root, - ); - - $this->assertSame([ - 'invalid_intervals' => 0, - 'duplicate_endpoints' => 0, - 'missing_endpoints' => 0, - 'crossing_intervals' => 0, - 'missing_parent' => 0, - 'wrong_parent' => 0, - 'wrong_depth' => 0, - ], UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->countErrors()); - } - - public function testUuidTreeRepairRemainsScopeCorrect(): void - { - $root = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000301', - self::FIRST_TENANT, - 'root', - ); - $child = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000302', - self::FIRST_TENANT, - 'child', - $root, - ); - $otherRoot = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000401', - self::SECOND_TENANT, - 'other root', - ); - - DB::table('nested_set_uuid_nodes') - ->where('id', $child->getKey()) - ->update([ - NestedSet::LFT => 1, - NestedSet::PARENT_ID => '018f3a2b-0000-7000-8000-000000000999', - NestedSet::DEPTH => 0, - ]); - - UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->fixTree(); - - $child->refresh(); - $otherRoot->refresh(); - - $this->assertNull($child->getParentId()); - $this->assertSame(0, $child->getDepth()); - $this->assertSame([1, 2], $otherRoot->getBounds()); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->isBroken()); - } - - public function testUuidTreeSoftDeleteAndRestoreRemainScopeCorrect(): void - { - $root = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000601', - self::FIRST_TENANT, - 'root', - ); - $child = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000602', - self::FIRST_TENANT, - 'child', - $root, - ); - $otherRoot = $this->createUuidNode( - '018f3a2b-0000-7000-8000-000000000701', - self::SECOND_TENANT, - 'other root', - ); - - $root->delete(); - - $this->assertNull(UuidNestedSetNode::find($root->getKey())); - $this->assertNull(UuidNestedSetNode::find($child->getKey())); - $this->assertNotNull(UuidNestedSetNode::find($otherRoot->getKey())); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->isBroken()); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::SECOND_TENANT, - ])->isBroken()); - - $root->restore(); - - $this->assertNotNull(UuidNestedSetNode::find($root->getKey())); - $this->assertNotNull(UuidNestedSetNode::find($child->getKey())); - $this->assertNotNull(UuidNestedSetNode::find($otherRoot->getKey())); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::FIRST_TENANT, - ])->isBroken()); - $this->assertFalse(UuidNestedSetNode::scoped([ - 'tenant_id' => self::SECOND_TENANT, - ])->isBroken()); - } - - protected function createUuidNode( - string $id, - string $tenantId, - string $name, - ?UuidNestedSetNode $parent = null, - ): UuidNestedSetNode { - $node = new UuidNestedSetNode([ - 'id' => $id, - 'tenant_id' => $tenantId, - 'name' => $name, - ]); - - if ($parent !== null) { - $node->appendToNode($parent); - } - - $node->save(); - - return $node; - } - - protected function assertNestedSetIndexes(string $table, array $scopes = []): void - { - $indexColumns = array_map( - static fn (array $index): array => $index['columns'], - Schema::getIndexes($table), - ); - - $this->assertContains([...$scopes, NestedSet::RGT], $indexColumns); - $this->assertContains([...$scopes, NestedSet::LFT], $indexColumns); - $this->assertContains([...$scopes, NestedSet::PARENT_ID, NestedSet::LFT], $indexColumns); - } -} - -class IntegerNestedSetNode extends Model -{ - use HasNode; - - public bool $timestamps = false; - - protected ?string $table = 'nested_set_integer_nodes'; - - protected array $fillable = ['name']; -} - -class UuidNestedSetNode extends Model -{ - use HasNode; - use SoftDeletes; - - public bool $incrementing = false; - - public bool $timestamps = false; - - protected string $keyType = 'string'; - - protected ?string $table = 'nested_set_uuid_nodes'; - - protected array $fillable = ['id', 'tenant_id', 'name']; - - protected function getScopeAttributes(): array - { - return ['tenant_id']; - } -} diff --git a/tests/Integration/NestedSet/Database/Postgres/NestedSetDatabaseTest.php b/tests/Integration/NestedSet/Database/Postgres/NestedSetSchemaTest.php similarity index 57% rename from tests/Integration/NestedSet/Database/Postgres/NestedSetDatabaseTest.php rename to tests/Integration/NestedSet/Database/Postgres/NestedSetSchemaTest.php index 8b2e339036..75b9e37383 100644 --- a/tests/Integration/NestedSet/Database/Postgres/NestedSetDatabaseTest.php +++ b/tests/Integration/NestedSet/Database/Postgres/NestedSetSchemaTest.php @@ -5,9 +5,9 @@ namespace Hypervel\Tests\Integration\NestedSet\Database\Postgres; use Hypervel\Testbench\Attributes\RequiresDatabase; -use Hypervel\Tests\Integration\NestedSet\Database\NestedSetDatabaseTestCase; +use Hypervel\Tests\NestedSet\NestedSetSchemaTest as BaseNestedSetSchemaTest; #[RequiresDatabase('pgsql')] -class NestedSetDatabaseTest extends NestedSetDatabaseTestCase +class NestedSetSchemaTest extends BaseNestedSetSchemaTest { } diff --git a/tests/Integration/NestedSet/Database/Postgres/NodeTest.php b/tests/Integration/NestedSet/Database/Postgres/NodeTest.php new file mode 100644 index 0000000000..08d2600f0f --- /dev/null +++ b/tests/Integration/NestedSet/Database/Postgres/NodeTest.php @@ -0,0 +1,13 @@ +id(); $table->unsignedInteger('menu_id'); $table->string('title')->nullable(); + $table->softDeletes(); NestedSet::columns($table, ['menu_id']); }); } diff --git a/tests/NestedSet/Fixtures/migrations/2025_07_04_000000_create_custom_column_categories_table.php b/tests/NestedSet/Fixtures/migrations/2025_07_04_000000_create_custom_column_categories_table.php new file mode 100644 index 0000000000..1a4d2c188c --- /dev/null +++ b/tests/NestedSet/Fixtures/migrations/2025_07_04_000000_create_custom_column_categories_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name')->nullable(); + $table->unsignedInteger('lft')->default(0); + $table->unsignedInteger('rgt')->default(0); + $table->unsignedSmallInteger('level')->default(0); + $table->unsignedBigInteger('ancestor_id')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('custom_column_categories'); + } +}; diff --git a/tests/NestedSet/Fixtures/migrations/2025_07_05_000000_create_nullable_menu_items_table.php b/tests/NestedSet/Fixtures/migrations/2025_07_05_000000_create_nullable_menu_items_table.php new file mode 100644 index 0000000000..8b1b1485d1 --- /dev/null +++ b/tests/NestedSet/Fixtures/migrations/2025_07_05_000000_create_nullable_menu_items_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedInteger('menu_id')->nullable(); + $table->string('title')->nullable(); + NestedSet::columns($table, ['menu_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('nullable_menu_items'); + } +}; diff --git a/tests/NestedSet/Fixtures/migrations/2025_07_06_000000_create_constrained_categories_table.php b/tests/NestedSet/Fixtures/migrations/2025_07_06_000000_create_constrained_categories_table.php new file mode 100644 index 0000000000..86405a8387 --- /dev/null +++ b/tests/NestedSet/Fixtures/migrations/2025_07_06_000000_create_constrained_categories_table.php @@ -0,0 +1,37 @@ +id(); + $table->string('name'); + NestedSet::columns($table); + $table->foreign(NestedSet::PARENT_ID)->references('id')->on('constrained_categories'); + }); + + Schema::create('constrained_category_items', function (Blueprint $table): void { + $table->id(); + $table->foreignId('category_id')->constrained('constrained_categories'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('constrained_category_items'); + Schema::dropIfExists('constrained_categories'); + } +}; diff --git a/tests/NestedSet/Fixtures/migrations/uuid/2025_07_02_000000_create_categories_table.php b/tests/NestedSet/Fixtures/migrations/uuid/2025_07_02_000000_create_categories_table.php new file mode 100644 index 0000000000..d8d566f094 --- /dev/null +++ b/tests/NestedSet/Fixtures/migrations/uuid/2025_07_02_000000_create_categories_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('name'); + $table->softDeletes(); + NestedSet::uuidColumns($table); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/tests/NestedSet/Fixtures/migrations/uuid/2025_07_03_000000_create_menu_items_table.php b/tests/NestedSet/Fixtures/migrations/uuid/2025_07_03_000000_create_menu_items_table.php new file mode 100644 index 0000000000..cd5c822d1f --- /dev/null +++ b/tests/NestedSet/Fixtures/migrations/uuid/2025_07_03_000000_create_menu_items_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->unsignedInteger('menu_id'); + $table->string('title')->nullable(); + NestedSet::uuidColumns($table, ['menu_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('menu_items'); + } +}; diff --git a/tests/NestedSet/NestedSetCacheTest.php b/tests/NestedSet/NestedSetCacheTest.php new file mode 100644 index 0000000000..4715c1aaa1 --- /dev/null +++ b/tests/NestedSet/NestedSetCacheTest.php @@ -0,0 +1,48 @@ +assertTrue(Category::usesSoftDelete()); + $this->assertFalse(MenuItem::usesSoftDelete()); + + $this->assertSame([ + Category::class => true, + MenuItem::class => false, + ], (new ReflectionProperty(Model::class, 'isSoftDeletable'))->getValue()); + } + + public function testNodeTraitCheckIsCachedPerClass(): void + { + $this->assertTrue(NestedSet::isNode(new Category)); + $this->assertTrue(NestedSet::isNode(new CategoryUuid)); + $this->assertFalse(NestedSet::isNode(new stdClass)); + $this->assertFalse(NestedSet::isNode(null)); + + $cache = new ReflectionProperty(NestedSet::class, 'nodeClasses'); + + $this->assertSame([ + Category::class => true, + CategoryUuid::class => true, + stdClass::class => false, + ], $cache->getValue()); + + NestedSet::flushState(); + + $this->assertSame([], $cache->getValue()); + } +} diff --git a/tests/NestedSet/NestedSetMutationLifecycleTest.php b/tests/NestedSet/NestedSetMutationLifecycleTest.php index 5a65994704..057a2abd57 100644 --- a/tests/NestedSet/NestedSetMutationLifecycleTest.php +++ b/tests/NestedSet/NestedSetMutationLifecycleTest.php @@ -8,7 +8,6 @@ use Hypervel\Support\Facades\DB; use Hypervel\Testbench\TestCase; use Hypervel\Tests\NestedSet\Fixtures\Models\Category; -use RuntimeException; use function Hypervel\Coroutine\parallel; @@ -44,31 +43,6 @@ protected function setUp(): void ]); } - public function testTransactionRetryPreparesParticipantsFromTheRolledBackTree(): void - { - $source = Category::findOrFail(2); - $target = Category::findOrFail(5); - $attempts = 0; - $retry = new RuntimeException('database is locked'); - - DB::transaction(function () use ($source, $target, &$attempts, $retry): void { - ++$attempts; - $source->appendToNode($target)->save(); - - if ($attempts === 1) { - throw $retry; - } - }, 2); - - $persistedSource = Category::findOrFail($source->getKey()); - - $this->assertSame(2, $attempts); - $this->assertSame($target->getKey(), $persistedSource->getParentId()); - $this->assertSame([8, 9], $persistedSource->getBounds()); - $this->assertSame($persistedSource->getBounds(), $source->getBounds()); - $this->assertTreeNotBroken(); - } - public function testCompletedParentChildMutationsRemainCorrectAcrossFreshCoroutines(): void { $source = Category::findOrFail(2); diff --git a/tests/NestedSet/NestedSetReadWriteTest.php b/tests/NestedSet/NestedSetReadWriteTest.php index 5cc93ed001..3f28b9f2c1 100644 --- a/tests/NestedSet/NestedSetReadWriteTest.php +++ b/tests/NestedSet/NestedSetReadWriteTest.php @@ -14,6 +14,7 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use PHPUnit\Framework\Attributes\DataProvider; class NestedSetReadWriteTest extends TestCase { @@ -104,6 +105,29 @@ public function testOrdinaryLookupsUseTheReplicaAndMutationReadsUseTheWriter(): $connection->flushQueryLog(); + $this->assertSame( + [2, 4], + NestedSetReadWriteNode::on('nested_set_split')->whereDescendantOf(1)->orderBy('id')->pluck('id')->all(), + ); + + $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'read'); + + $connection->flushQueryLog(); + + $this->assertSame( + [2, 4], + NestedSetReadWriteNode::on('nested_set_split') + ->useWritePdo() + ->whereDescendantOf(1) + ->orderBy('id') + ->pluck('id') + ->all(), + ); + + $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'write'); + + $connection->flushQueryLog(); + NestedSetReadWriteNode::on('nested_set_split')->moveNode(3, 2); $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'write'); @@ -126,6 +150,34 @@ public function testOrdinaryLookupsUseTheReplicaAndMutationReadsUseTheWriter(): $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'write'); } + #[DataProvider('diagnosticMethods')] + public function testDiagnosticsUseTheWriterOnlyWhenRequested(string $method): void + { + $connection = DB::connection('nested_set_split'); + $connection->enableQueryLog(); + + NestedSetReadWriteNode::on('nested_set_split')->{$method}(); + + $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'read'); + + $connection->flushQueryLog(); + + NestedSetReadWriteNode::on('nested_set_split')->useWritePdo()->{$method}(); + + $this->assertSelectQueriesUseConnection($connection->getQueryLog(), 'write'); + } + + /** + * Get the tree diagnostic methods. + */ + public static function diagnosticMethods(): array + { + return [ + 'count errors' => ['countErrors'], + 'is broken' => ['isBroken'], + ]; + } + public function testModelMutationSelectionsUseTheWriter(): void { $connection = DB::connection('nested_set_split'); diff --git a/tests/NestedSet/NestedSetSchemaTest.php b/tests/NestedSet/NestedSetSchemaTest.php index a088bfb909..ec3a140378 100644 --- a/tests/NestedSet/NestedSetSchemaTest.php +++ b/tests/NestedSet/NestedSetSchemaTest.php @@ -6,15 +6,16 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\NestedSet\NestedSet; use Hypervel\NestedSet\NestedSetServiceProvider; use Hypervel\Support\Facades\Schema; -use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; -#[WithConfig('database.default', 'testing')] class NestedSetSchemaTest extends TestCase { + use DatabaseMigrations; + /** * Get package providers. */ @@ -25,11 +26,27 @@ protected function getPackageProviders(ApplicationContract $app): array ]; } - public function tearDown(): void + /** + * Use a table prefix so generated index names are checked against prefixed tables. + */ + protected function defineEnvironment(ApplicationContract $app): void { - Schema::dropIfExists('nested_set_schema'); + parent::defineEnvironment($app); + + $config = $app->make('config'); - parent::tearDown(); + $config->set( + 'database.connections.' . $config->string('database.default') . '.prefix', + 'prfx_', + ); + } + + /** + * Drop the schema fixture on the test coroutine's connection. + */ + protected function tearDownInCoroutine(): void + { + Schema::dropIfExists('nested_set_schema'); } public function testNestedSetMacroCreatesExpectedColumnsAndIndexes(): void @@ -40,8 +57,7 @@ public function testNestedSetMacroCreatesExpectedColumnsAndIndexes(): void $table->nestedSet(['tenant_id']); }); - $this->assertNestedSetColumns(['tenant_id', NestedSet::LFT, NestedSet::RGT, NestedSet::PARENT_ID, NestedSet::DEPTH]); - $this->assertNestedSetIndexes(['tenant_id']); + $this->assertNestedSetSchema(['tenant_id']); } public function testIntegerNestedSetMacroCreatesExpectedColumnsAndIndexes(): void @@ -51,33 +67,30 @@ public function testIntegerNestedSetMacroCreatesExpectedColumnsAndIndexes(): voi $table->integerNestedSet(); }); - $this->assertNestedSetColumns([NestedSet::LFT, NestedSet::RGT, NestedSet::PARENT_ID, NestedSet::DEPTH]); - $this->assertNestedSetIndexes(); + $this->assertNestedSetSchema(); } - public function testUuidNestedSetMacroCreatesCompatibleSqliteParentColumnAndIndexes(): void + public function testUuidNestedSetMacroCreatesExpectedColumnsAndIndexes(): void { Schema::create('nested_set_schema', function (Blueprint $table): void { $table->uuid('id')->primary(); $table->uuidNestedSet(); }); - $this->assertSame('varchar', Schema::getColumnType('nested_set_schema', NestedSet::PARENT_ID)); - $this->assertNestedSetIndexes(); + $this->assertNestedSetSchema(); } - public function testUlidNestedSetMacroCreatesCompatibleSqliteParentColumnAndIndexes(): void + public function testUlidNestedSetMacroCreatesExpectedColumnsAndIndexes(): void { Schema::create('nested_set_schema', function (Blueprint $table): void { $table->ulid('id')->primary(); $table->ulidNestedSet(); }); - $this->assertSame('varchar', Schema::getColumnType('nested_set_schema', NestedSet::PARENT_ID)); - $this->assertNestedSetIndexes(); + $this->assertNestedSetSchema(); } - public function testDropNestedSetMacroDropsTheColumnsAndIndexesItCreates(): void + public function testDropsEveryNestedSetIndexItCreates(): void { Schema::create('nested_set_schema', function (Blueprint $table): void { $table->id(); @@ -97,28 +110,38 @@ public function testDropNestedSetMacroDropsTheColumnsAndIndexesItCreates(): void } /** - * Assert that the table contains the expected nested set columns. - */ - protected function assertNestedSetColumns(array $columns): void - { - $this->assertEqualsCanonicalizing($columns, array_values(array_intersect( - Schema::getColumnListing('nested_set_schema'), - $columns, - ))); - } - - /** - * Assert that the table contains the expected nested set indexes. + * Assert that the table has the nested set columns, column types, and exact indexes. */ - protected function assertNestedSetIndexes(array $scopes = []): void + protected function assertNestedSetSchema(array $scopes = []): void { - $indexColumns = array_map( - fn (array $index): array => $index['columns'], - Schema::getIndexes('nested_set_schema'), + $columns = Schema::getColumns('nested_set_schema'); + $types = array_column($columns, 'type', 'name'); + $typeNames = array_column($columns, 'type_name', 'name'); + + [$boundType, $depthType] = match (Schema::getConnection()->getDriverName()) { + 'pgsql' => ['int4', 'int2'], + 'sqlite' => ['integer', 'integer'], + default => ['int', 'smallint'], + }; + + $this->assertSame($types['id'], $types[NestedSet::PARENT_ID]); + $this->assertSame($boundType, $typeNames[NestedSet::LFT]); + $this->assertSame($boundType, $typeNames[NestedSet::RGT]); + $this->assertSame($depthType, $typeNames[NestedSet::DEPTH]); + + $this->assertEqualsCanonicalizing( + [ + implode(',', [...$scopes, NestedSet::RGT]), + implode(',', [...$scopes, NestedSet::LFT, NestedSet::RGT]), + implode(',', [...$scopes, NestedSet::PARENT_ID, NestedSet::LFT]), + ], + array_values(array_map( + fn (array $index): string => implode(',', $index['columns']), + array_filter( + Schema::getIndexes('nested_set_schema'), + fn (array $index): bool => ! $index['primary'], + ), + )), ); - - $this->assertContains([...$scopes, NestedSet::RGT], $indexColumns); - $this->assertContains([...$scopes, NestedSet::LFT], $indexColumns); - $this->assertContains([...$scopes, NestedSet::PARENT_ID, NestedSet::LFT], $indexColumns); } } diff --git a/tests/NestedSet/NestedSetTest.php b/tests/NestedSet/NestedSetTest.php index d7aab95084..561884e7ee 100644 --- a/tests/NestedSet/NestedSetTest.php +++ b/tests/NestedSet/NestedSetTest.php @@ -7,8 +7,8 @@ use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\HasBuilder; use Hypervel\Database\Eloquent\Model; -use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder as BaseQueryBuilder; use Hypervel\NestedSet\Eloquent\QueryBuilder; use Hypervel\NestedSet\HasNode; @@ -18,7 +18,6 @@ use LogicException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; -use ReflectionProperty; use stdClass; use Stringable; @@ -102,15 +101,6 @@ public function testIsNodeReturnsTrueForModelUsingHasNodeThroughAnotherTrait(): $this->assertTrue(NestedSet::isNode(new NestedSetTestNestedTraitNodeModel)); } - public function testNodeBootDetectsSoftDeletesWithoutNestedModelConstruction(): void - { - $node = new NestedSetTestNodeModel; - $softDeletingNode = new NestedSetTestSoftDeletingNodeModel; - - $this->assertFalse($node::usesSoftDelete()); - $this->assertTrue($softDeletingNode::usesSoftDelete()); - } - public function testNodeUsesNestedSetBuilderByDefault(): void { $builder = (new NestedSetTestNodeModel) @@ -119,21 +109,59 @@ public function testNodeUsesNestedSetBuilderByDefault(): void $this->assertInstanceOf(QueryBuilder::class, $builder); } - public function testNodeUsesCompatibleAttributedBuilder(): void + /** + * @param class-string $model + * @param class-string $builder + */ + #[DataProvider('compatibleBuilderModels')] + public function testNodeUsesCompatibleCustomBuilder(string $model, string $builder): void { - $builder = (new NestedSetTestCustomBuilderNodeModel) - ->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + $instance = (new $model)->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); - $this->assertInstanceOf(NestedSetTestCustomBuilder::class, $builder); + $this->assertSame($builder, $instance::class); } - public function testNodeRejectsIncompatibleAttributedBuilder(): void + /** + * Provide node models with compatible custom builders. + * + * @return array, class-string}> + */ + public static function compatibleBuilderModels(): array + { + return [ + 'attribute' => [NestedSetTestCustomBuilderNodeModel::class, NestedSetTestCustomBuilder::class], + 'attribute naming the nested set builder' => [NestedSetTestNestedSetBuilderNodeModel::class, QueryBuilder::class], + 'builder property' => [NestedSetTestBuilderPropertyNodeModel::class, NestedSetTestCustomBuilder::class], + ]; + } + + /** + * @param class-string $model + */ + #[DataProvider('incompatibleBuilderModels')] + public function testNodeRejectsIncompatibleCustomBuilder(string $model): void { $this->expectException(LogicException::class); - $this->expectExceptionMessageIsOrContains('must use a builder that extends'); + $this->expectExceptionMessageIs(sprintf( + 'Nested set model [%s] must use a builder that extends [%s].', + $model, + QueryBuilder::class, + )); - (new NestedSetTestIncompatibleBuilderNodeModel) - ->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + (new $model)->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + } + + /** + * Provide node models with builders that do not extend the nested set builder. + * + * @return array}> + */ + public static function incompatibleBuilderModels(): array + { + return [ + 'attribute' => [NestedSetTestIncompatibleBuilderNodeModel::class], + 'builder property' => [NestedSetTestIncompatibleBuilderPropertyNodeModel::class], + ]; } public function testIsNodeReturnsFalseForPlainEloquentModel(): void @@ -154,28 +182,6 @@ public function testIsNodeReturnsFalseForArbitraryObject(): void $this->assertFalse(NestedSet::isNode(new stdClass)); } - public function testIsNodeCachesEachConcreteClassAndFlushesItsState(): void - { - NestedSet::flushState(); - - $node = new NestedSetTestNodeModel; - $plain = new NestedSetTestPlainModel; - - $this->assertTrue(NestedSet::isNode($node)); - $this->assertFalse(NestedSet::isNode($plain)); - - $property = new ReflectionProperty(NestedSet::class, 'nodeClasses'); - - $this->assertSame([ - NestedSetTestNodeModel::class => true, - NestedSetTestPlainModel::class => false, - ], $property->getValue()); - - NestedSet::flushState(); - - $this->assertSame([], $property->getValue()); - } - public function testScopeValuesAreNormalizedForSqlAndBucketIdentity(): void { $model = new NestedSetTestScopeNodeModel; @@ -321,14 +327,6 @@ class NestedSetTestNestedTraitNodeModel extends Model protected ?string $table = 'nested_set_test_nested_trait_nodes'; } -class NestedSetTestSoftDeletingNodeModel extends Model -{ - use SoftDeletes; - use HasNode; - - protected ?string $table = 'nested_set_test_soft_deleting_nodes'; -} - class NestedSetTestScopeNodeModel extends Model { use HasNode; @@ -354,9 +352,33 @@ class NestedSetTestCustomBuilderNodeModel extends Model { use HasNode; + /** @use HasBuilder> */ + use HasBuilder { + HasNode::newEloquentBuilder insteadof HasBuilder; + } + + protected static string $builder = QueryBuilder::class; + protected ?string $table = 'nested_set_test_custom_builder_nodes'; } +#[UseEloquentBuilder(QueryBuilder::class)] +class NestedSetTestNestedSetBuilderNodeModel extends Model +{ + use HasNode; + + protected ?string $table = 'nested_set_test_nested_set_builder_nodes'; +} + +class NestedSetTestBuilderPropertyNodeModel extends Model +{ + use HasNode; + + protected static string $builder = NestedSetTestCustomBuilder::class; + + protected ?string $table = 'nested_set_test_builder_property_nodes'; +} + #[UseEloquentBuilder(Builder::class)] class NestedSetTestIncompatibleBuilderNodeModel extends Model { @@ -364,3 +386,20 @@ class NestedSetTestIncompatibleBuilderNodeModel extends Model protected ?string $table = 'nested_set_test_incompatible_builder_nodes'; } + +/** + * @template TModel of Model + * @extends Builder + */ +class NestedSetTestPlainBuilder extends Builder +{ +} + +class NestedSetTestIncompatibleBuilderPropertyNodeModel extends Model +{ + use HasNode; + + protected static string $builder = NestedSetTestPlainBuilder::class; + + protected ?string $table = 'nested_set_test_incompatible_builder_property_nodes'; +} diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index 172c3832b1..bae80f2c65 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -4,3479 +4,774 @@ namespace Hypervel\Tests\NestedSet; +use Closure; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Model; -use Hypervel\Database\Eloquent\ModelNotFoundException; -use Hypervel\Database\Schema\Blueprint; -use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Database\QueryException; use Hypervel\NestedSet\Eloquent\Collection; +use Hypervel\NestedSet\Eloquent\QueryBuilder; use Hypervel\NestedSet\HasNode; use Hypervel\Support\CarbonImmutable; -use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Facades\DB; -use Hypervel\Support\Facades\Schema; -use Hypervel\Testbench\TestCase; +use Hypervel\Support\Facades\Event; +use Hypervel\Testbench\Attributes\RequiresDatabase; use Hypervel\Tests\NestedSet\Fixtures\Models\Category; -use InvalidArgumentException; use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionMethod; -class NodeTest extends TestCase +class NodeTest extends NodeTestBase { - use RefreshDatabase; - - protected bool $migrateRefresh = true; + protected string $category = Category::class; /** - * Get the migration options. + * Enforce SQLite foreign keys, as the server drivers do, for the constrained fixtures. */ - protected function migrateFreshUsing(): array + protected function defineEnvironment(ApplicationContract $app): void { - return [ - '--seed' => $this->shouldSeed(), - '--database' => $this->getRefreshConnection(), - '--realpath' => true, - '--path' => __DIR__ . '/Fixtures/migrations', - ]; + parent::defineEnvironment($app); + + $config = $app->make('config'); + + $config->set( + 'database.connections.' . $config->string('database.default') . '.foreign_key_constraints', + true, + ); } /** - * Set up the test environment. + * Seed the fixture tree inside the test coroutine and transaction. */ - public function setUp(): void + protected function afterRefreshingDatabase(): void { - parent::setUp(); - - DB::enableQueryLog(); - - DB::table('categories') - ->insert($this->getMockCategories()); + parent::afterRefreshingDatabase(); - // Reset Postgres sequence after inserting with explicit IDs + // Reset the Postgres sequence after inserting explicit keys. if (DB::connection()->getDriverName() === 'pgsql') { - DB::statement("SELECT setval('categories_id_seq', (SELECT MAX(id) FROM categories))"); + $table = DB::connection()->getTablePrefix() . 'categories'; + + DB::statement("SELECT setval(pg_get_serial_sequence('{$table}', 'id'), (SELECT MAX(id) FROM {$table}))"); } } /** - * Get the initial category records. + * Get the fixture migration path. */ - protected function getMockCategories(): array - { - return [ - ['id' => 1, 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => 2, 'depth' => 2], - ['id' => 4, 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => 2, 'depth' => 2], - ['id' => 5, 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => 1, 'depth' => 1], - ['id' => 6, 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => 5, 'depth' => 2], - ['id' => 7, 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => 5, 'depth' => 2], - ['id' => 8, 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => 7, 'depth' => 3], - ['id' => 9, 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => 5, 'depth' => 2], - ['id' => 10, 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => 5, 'depth' => 2], - ['id' => 11, 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null, 'depth' => 0], - ]; - } - - private function resetRebuildQueryFixture(): void + protected function getMigrationPath(): string { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'root', '_lft' => 1, '_rgt' => 8, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'branch', '_lft' => 2, '_rgt' => 3, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'parent', '_lft' => 4, '_rgt' => 7, 'parent_id' => 1, 'depth' => 1], - ['id' => 4, 'name' => 'child', '_lft' => 5, '_rgt' => 6, 'parent_id' => 3, 'depth' => 2], - ]); + return __DIR__ . '/Fixtures/migrations'; } - public function tearDown(): void + /** + * Get the key of a seeded fixture category. + */ + protected function key(int $number): int { - DB::flushQueryLog(); - DB::disableQueryLog(); - - parent::tearDown(); + return $number; } - protected function assertTreeNotBroken(): void + /** + * Count the logged evented chunk queries of two nodes in the given left-bound order. + */ + protected function countChunkQueries(string $direction): int { - $this->assertSame([ - 'invalid_intervals' => 0, - 'duplicate_endpoints' => 0, - 'missing_endpoints' => 0, - 'crossing_intervals' => 0, - 'missing_parent' => 0, - 'wrong_parent' => 0, - 'wrong_depth' => 0, - ], Category::countErrors()); - } + $order = 'order by ' . DB::getQueryGrammar()->wrap('_lft') . " {$direction} limit 2"; - protected function assertNodeReceivesValidValues($node): void - { - $lft = $node->getLft(); - $rgt = $node->getRgt(); - $nodeInDb = $this->findCategory($node->name); - - $this->assertEquals( - [$nodeInDb->getLft(), $nodeInDb->getRgt()], - [$lft, $rgt], - 'Node is not synced with database after save.' - ); + return count(array_filter( + array_column(DB::getQueryLog(), 'query'), + static fn (string $query): bool => str_contains($query, $order), + )); } - public function findCategory(string $name, bool $withTrashed = false): ?Category + #[RequiresDatabase(['sqlite', 'pgsql'])] + public function testIgnoredSaveOrIgnoreConflictKeepsThePendingAction(): void { - $category = new Category; - $query = $withTrashed ? $category->withTrashed() : $category->newQuery(); + $node = (new Category(['name' => 'conflict']))->forceFill(['id' => 1]); + $node->appendToNode(Category::findOrFail(5)); + $ignored = new LogicException('The insert was ignored.'); + $caught = null; - return $query->whereName($name)->first(); - } + try { + DB::transaction(function () use ($node, $ignored): void { + if (! $node->saveOrIgnore()) { + throw $ignored; + } + }); + } catch (LogicException $exception) { + $caught = $exception; + } - protected function nodeValues($node): array - { - return [$node->_lft, $node->_rgt, $node->parent_id, $node->depth]; - } + $this->assertSame($ignored, $caught); - public function testGetsNodeData(): void - { - $data = Category::getNodeData(3); + unset($node->id); - $this->assertEquals(['_lft' => 3, '_rgt' => 4, 'depth' => 2], $data); + $this->assertTrue($node->save()); + $this->assertSame(5, $node->getParentId()); + $this->assertTreeNotBroken(); } - public function testGetsPlainNodeData(): void + public function testEventedDescendantDeletesAreChunked(): void { - $data = Category::getPlainNodeData(3); + $deleting = []; - $this->assertEquals([3, 4], $data); - } + EventedCategoryModel::deleting(function (EventedCategoryModel $model) use (&$deleting): void { + $deleting[] = $model->getKey(); + }); - public function testZeroHeightGapDoesNotIssueAQuery(): void - { + $node = EventedCategoryModel::findOrFail(5); DB::flushQueryLog(); - $this->assertSame(0, Category::query()->makeGap(5, 0)); - $this->assertSame([], DB::getQueryLog()); - } + $node->delete(); - public function testLowLevelMoveDerivesDepthWhenTheCallerOmitsIt(): void - { - $this->assertSame(0, Category::query()->depthForPosition(21)); - $this->assertSame(3, Category::query()->depthForPosition(12)); + $this->assertSame([5, 10, 9, 8, 7, 6], $deleting); - Category::query()->moveNode(2, 12); + foreach ([5, 6, 7, 8, 9, 10] as $id) { + $this->assertNotNull(EventedCategoryModel::withTrashed()->findOrFail($id)->deleted_at); + } - $this->assertSame(3, Category::findOrFail(2)->getDepth()); - $this->assertSame(4, Category::findOrFail(3)->getDepth()); + // Five descendants in chunks of two: the short third chunk ends the loop. + $this->assertSame(3, $this->countChunkQueries('desc')); } - #[DataProvider('invalidLowLevelNodeData')] - public function testLowLevelMoveRejectsInvalidNodeData(array $nodeData): void + /** + * @param Closure(EventedCategoryModel): mixed $delete + */ + #[DataProvider('vetoedEventedDeletions')] + public function testEventedDescendantDeletionVetoRollsBackEarlierDeletes(Closure $delete): void { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Node data for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] must contain [_lft], [_rgt], and [depth].', + EventedCategoryModel::deleting( + fn (EventedCategoryModel $model): ?bool => $model->getKey() === 8 ? false : null, ); + $before = $this->persistedRows(); + + try { + $delete(EventedCategoryModel::findOrFail(5)); + $this->fail('Expected the descendant deletion veto to propagate.'); + } catch (LogicException $exception) { + $this->assertSame( + sprintf( + 'Deleting nested set descendant [%s] with key [8] was vetoed.', + EventedCategoryModel::class, + ), + $exception->getMessage(), + ); + } - Category::query()->moveNode(2, 12, nodeData: $nodeData); + $this->assertSame($before, $this->persistedRows()); } - public static function invalidLowLevelNodeData(): array + /** + * Provide transactional deletions whose veto follows earlier descendant deletes. + * + * @return array + */ + public static function vetoedEventedDeletions(): array { return [ - 'missing depth' => [['_lft' => 2, '_rgt' => 7]], - 'null left bound' => [['_lft' => null, '_rgt' => 7, 'depth' => 1]], + 'soft delete' => [static fn (EventedCategoryModel $node): mixed => $node->deleteOrFail()], + 'force delete' => [ + static fn (EventedCategoryModel $node): mixed => DB::transaction(fn (): mixed => $node->forceDelete()), + ], ]; } - public function testMovePreparesEachParticipantAtTheSaveBoundary(): void - { - $node = Category::findOrFail(3); - $parent = Category::findOrFail(5); - DB::flushQueryLog(); - - $node->appendToNode($parent)->save(); - - $this->assertSame(3, $this->countStructuralIdentityReloads()); - } - - public function testMovingANewSourceDoesNotPreflightTheSource(): void + public function testEventedDescendantRestoresAreChunkedParentsFirst(): void { - $parent = Category::findOrFail(5); - DB::flushQueryLog(); + CarbonImmutable::setTestNow('2025-07-03 12:00:00'); + EventedCategoryModel::findOrFail(7)->delete(); - (new Category(['name' => 'new child'])) - ->appendToNode($parent) - ->save(); + CarbonImmutable::setTestNow('2025-07-03 12:00:01'); + EventedCategoryModel::findOrFail(5)->delete(); - $this->assertSame(2, $this->countStructuralIdentityReloads()); - $this->assertTreeNotBroken(); - } + $restoring = []; + $restored = []; - public function testEveryMovePreparesEachParticipantOnlyOnce(): void - { - $firstSource = Category::findOrFail(3); - $firstTarget = Category::findOrFail(5); - $secondSource = Category::findOrFail(6); - $secondTarget = Category::findOrFail(2); + EventedCategoryModel::restoring(function (EventedCategoryModel $model) use (&$restoring): void { + $restoring[] = $model->getKey(); + }); + EventedCategoryModel::restored(function (EventedCategoryModel $model) use (&$restored): void { + $restored[] = $model->getKey(); + }); - $firstSource->appendToNode($firstTarget)->save(); + $node = EventedCategoryModel::withTrashed()->findOrFail(5); DB::flushQueryLog(); - $secondSource->appendToNode($secondTarget)->save(); + $node->restore(); - $this->assertSame(3, $this->countStructuralIdentityReloads()); - $this->assertTreeNotBroken(); + $this->assertSame(2, $this->countChunkQueries('asc')); + + // Samsung and galaxy were deleted before mobile, so they stay deleted. + $this->assertSame([5, 6, 9, 10], $restoring); + $this->assertSame([6, 9, 10, 5], $restored); + $this->assertSame([5, 6, 9, 10], EventedCategoryModel::whereKey([5, 6, 7, 8, 9, 10])->orderBy('id')->pluck('id')->all()); + $this->assertSame([7, 8], EventedCategoryModel::onlyTrashed()->orderBy('id')->pluck('id')->all()); } - #[DataProvider('deletedStructuralParticipants')] - public function testDeletedStructuralParticipantsFailBeforeWriting(string $participant): void + public function testEventedDescendantRestorationVetoRollsBackEarlierRestores(): void { - $source = Category::findOrFail(3); - $target = Category::findOrFail(5); - - Category::query()->moveNode(11, 1); - DB::table('categories') - ->where('id', $participant === 'source' ? $source->getKey() : $target->getKey()) - ->delete(); - - $columns = ['id', '_lft', '_rgt', 'parent_id', 'depth']; - $before = DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(static fn (object $row): array => (array) $row) - ->all(); - $caught = null; + EventedCategoryModel::findOrFail(5)->delete(); + EventedCategoryModel::restoring( + fn (EventedCategoryModel $model): ?bool => $model->getKey() === 8 ? false : null, + ); + $before = $this->persistedRows(); try { - $source->appendToNode($target)->save(); - $this->fail('Expected the deleted structural participant to be rejected.'); - } catch (ModelNotFoundException $exception) { - $caught = $exception; + DB::transaction(fn (): bool => EventedCategoryModel::withTrashed()->findOrFail(5)->restore()); + $this->fail('Expected the descendant restoration veto to propagate.'); + } catch (LogicException $exception) { + $this->assertSame( + sprintf( + 'Restoring nested set descendant [%s] with key [8] was vetoed.', + EventedCategoryModel::class, + ), + $exception->getMessage(), + ); } - $this->assertNotNull($caught); - $this->assertSame( - $before, - DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(static fn (object $row): array => (array) $row) - ->all(), - ); - } - - public static function deletedStructuralParticipants(): array - { - return [ - 'source' => ['source'], - 'target' => ['target'], - ]; + $this->assertSame($before, $this->persistedRows()); } - public function testReceivesValidValuesWhenAppendedTo(): void + public function testEventedForceDeletionIncludesTrashedDescendantsAndClosesTheGap(): void { - $node = new Category(['name' => 'test']); - $root = Category::root(); + EventedCategoryModel::findOrFail(8)->delete(); - $accepted = [$root->_rgt, $root->_rgt + 1, $root->id, $root->depth + 1]; + $deleting = []; - $root->appendNode($node); + EventedCategoryModel::deleting(function (EventedCategoryModel $model) use (&$deleting): void { + $deleting[] = $model->getKey(); + }); - $this->assertTrue($node->hasMoved()); - $this->assertEquals($accepted, $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertFalse($node->isDirty()); - $this->assertTrue($node->isDescendantOf($root)); - } + EventedCategoryModel::findOrFail(5)->forceDelete(); - public function testReceivesValidValuesWhenPrependedTo(): void - { - $root = Category::root(); - $node = new Category(['name' => 'test']); - $root->prependNode($node); + $this->assertSame([5, 10, 9, 8, 7, 6], $deleting); + $this->assertSame(8, EventedCategoryModel::findOrFail(1)->getRgt()); - $this->assertTrue($node->hasMoved()); - $this->assertEquals([$root->_lft + 1, $root->_lft + 2, $root->id, $root->depth + 1], $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertTrue($node->isDescendantOf($root)); - $this->assertTrue($root->isAncestorOf($node)); - $this->assertTrue($node->isChildOf($root)); + foreach ([5, 6, 7, 8, 9, 10] as $id) { + $this->assertNull(EventedCategoryModel::withTrashed()->find($id)); + } } - public function testReceivesValidValuesWhenInsertedAfter(): void + public function testDeletingVetoRegisteredAfterBootLeavesTheSubtreeUnchanged(): void { - $target = $this->findCategory('apple'); - $node = new Category(['name' => 'test']); - $node->afterNode($target)->save(); + Category::deleting(fn (Category $model): ?bool => $model->getKey() === 5 ? false : null); + $before = $this->persistedRows(); - $this->assertTrue($node->hasMoved()); - $this->assertEquals([$target->_rgt + 1, $target->_rgt + 2, $target->parent->id, $target->depth], $this->nodeValues($node)); - $this->assertTreeNotBroken(); - $this->assertFalse($node->isDirty()); - $this->assertTrue($node->isSiblingOf($target)); + $this->assertFalse(Category::findOrFail(5)->forceDelete()); + $this->assertSame($before, $this->persistedRows()); } - public function testInsertAfterRefreshesTargetAndInvalidatesStructuralRelations(): void + /** + * @param class-string $model + */ + #[DataProvider('constrainedCategoryModels')] + public function testHardDeletionSatisfiesARestrictingParentKey(string $model): void { - $node = Category::with(['parent', 'siblings'])->findOrFail(3); - $target = Category::with(['parent', 'siblings'])->findOrFail(9); - - $this->assertTrue($node->insertAfterNode($target)); - - $storedTarget = Category::findOrFail(9); + $model::create(['name' => 'root', 'children' => [ + ['name' => 'branch', 'children' => [ + ['name' => 'twig', 'children' => [['name' => 'leaf']]], + ['name' => 'other twig'], + ]], + ['name' => 'other branch'], + ]]); - $this->assertSame($storedTarget->getLft(), $target->getLft()); - $this->assertSame($storedTarget->getRgt(), $target->getRgt()); - $this->assertFalse($node->relationLoaded('parent')); - $this->assertFalse($node->relationLoaded('siblings')); - $this->assertFalse($target->relationLoaded('parent')); - $this->assertFalse($target->relationLoaded('siblings')); - } - - public function testReceivesValidValuesWhenInsertedBefore(): void - { - $target = $this->findCategory('apple'); - $node = new Category(['name' => 'test']); - $node->beforeNode($target)->save(); + $model::where('name', 'branch')->firstOrFail()->delete(); - $this->assertTrue($node->hasMoved()); - $this->assertEquals([$target->_lft, $target->_lft + 1, $target->parent->id, $target->depth], $this->nodeValues($node)); - $this->assertTreeNotBroken(); + $this->assertSame(['root', 'other branch'], $model::defaultOrder()->pluck('name')->all()); + $this->assertSame([1, 4], $model::where('name', 'root')->firstOrFail()->getBounds()); + $this->assertFalse($model::isBroken()); } - public function testCategoryMovesDown(): void + /** + * Provide node models whose table restricts deleting a referenced parent. + * + * @return array}> + */ + public static function constrainedCategoryModels(): array { - $node = $this->findCategory('apple'); - $target = $this->findCategory('mobile'); - - $target->appendNode($node); - - $this->assertTrue($node->hasMoved()); - $this->assertNodeReceivesValidValues($node); - $this->assertTreeNotBroken(); + return [ + 'set-based' => [ConstrainedCategoryModel::class], + 'evented' => [EventedConstrainedCategoryModel::class], + ]; } - public function testCategoryMovesUp(): void + public function testRebuildDeletionSatisfiesARestrictingParentKey(): void { - $node = $this->findCategory('samsung'); - $target = $this->findCategory('notebooks'); - - $target->appendNode($node); - - $this->assertTrue($node->hasMoved()); - $this->assertTreeNotBroken(); - $this->assertNodeReceivesValidValues($node); - } - - #[DataProvider('staleSiblingMovements')] - public function testSiblingMovementRereadsAfterAnEarlierStructuralShift( - string $method, - int $nodeId, - ): void { - $node = Category::findOrFail($nodeId); + $root = ConstrainedCategoryModel::create(['name' => 'root', 'children' => [ + ['name' => 'branch', 'children' => [ + ['name' => 'twig', 'children' => [['name' => 'leaf']]], + ]], + ['name' => 'other branch'], + ]]); - Category::query()->moveNode(11, 1); + ConstrainedCategoryModel::rebuildTree([ + ['id' => $root->getKey(), 'children' => [['name' => 'new branch']]], + ], delete: true); - $this->assertTrue($node->{$method}()); - $this->assertSame( - [4, 3], - Category::where('parent_id', 2)->defaultOrder()->pluck('id')->all(), - ); - $this->assertTreeNotBroken(); + $this->assertSame(['root', 'new branch'], ConstrainedCategoryModel::defaultOrder()->pluck('name')->all()); + $this->assertFalse(ConstrainedCategoryModel::isBroken()); } - public static function staleSiblingMovements(): array + public function testFailedNodeDeletionRollsBackItsDeletedDescendants(): void { - return [ - 'move the first sibling down' => ['down', 3], - 'move the second sibling up' => ['up', 4], - ]; - } - - #[DataProvider('structuralMovementCases')] - public function testStructuralMovesMaintainSubtreeDepth( - string $method, - string $nodeName, - string $targetName, - int $expectedDepth, - ?string $childName, - ): void { - $node = $this->findCategory($nodeName); - $target = $this->findCategory($targetName); - - if ($method === 'append') { - $target->appendNode($node); - } else { - $node->insertBeforeNode($target); - } + ConstrainedCategoryModel::create(['name' => 'root', 'children' => [ + ['name' => 'branch', 'children' => [['name' => 'leaf']]], + ]]); + $branch = ConstrainedCategoryModel::where('name', 'branch')->firstOrFail(); + DB::table('constrained_category_items')->insert(['category_id' => $branch->getKey()]); + $before = $this->persistedRows('constrained_categories'); - $this->assertSame($expectedDepth, $node->getDepth()); - - if ($childName !== null) { - $this->assertSame($expectedDepth + 1, $this->findCategory($childName)->getDepth()); + try { + $branch->deleteOrFail(); + $this->fail('Expected the referenced node to be kept.'); + } catch (QueryException) { + $this->assertSame($before, $this->persistedRows('constrained_categories')); } - - $this->assertTreeNotBroken(); - } - - public static function structuralMovementCases(): array - { - return [ - 'append level up' => ['append', 'samsung', 'store', 1, 'galaxy'], - 'append same level' => ['append', 'samsung', 'notebooks', 2, 'galaxy'], - 'append level down' => ['append', 'notebooks', 'samsung', 3, 'apple'], - 'insert before level up' => ['before', 'samsung', 'notebooks', 1, 'galaxy'], - 'insert before same level' => ['before', 'samsung', 'sony', 2, 'galaxy'], - 'insert before level down' => ['before', 'notebooks', 'galaxy', 3, 'apple'], - ]; } - public function testBeforeNodeDerivesDepthWhenTheTargetDepthWasNotSelected(): void + public function testHardDeletionRemovesDescendantsHiddenByGlobalScopes(): void { - $node = $this->findCategory('samsung'); - $target = Category::query() - ->select(['id', 'name', '_lft', '_rgt', 'parent_id']) - ->findOrFail(3); - - $node->insertBeforeNode($target); + HiddenGalaxyCategoryModel::findOrFail(5)->delete(); - $this->assertSame(2, Category::findOrFail($node->getKey())->getDepth()); - $this->assertSame(3, $this->findCategory('galaxy')->getDepth()); + $this->assertNull(DB::table('categories')->find(8)); $this->assertTreeNotBroken(); } - public function testAppendNewNodeDerivesDepthFromHandPositionedParentWithoutLoadedDepth(): void + public function testSoftDeletionAndRestorationIncludeDescendantsHiddenByGlobalScopes(): void { - $parent = new Category; - $parent->setRawAttributes([ - 'id' => 7, - '_lft' => 11, - '_rgt' => 14, - 'parent_id' => 5, - ]); - - $node = new Category(['name' => 'new phone']); - $node->appendToNode($parent)->save(); - $node = $node->fresh(); - - $this->assertSame(7, $node->getParentId()); - $this->assertSame(3, $node->getDepth()); - $this->assertTreeNotBroken(); - } + GloballyScopedCategoryModel::findOrFail(5)->delete(); - public function testAppendExistingSubtreeRefreshesParentWithoutSelectedDepth(): void - { - $node = $this->findCategory('notebooks'); - $parent = Category::query() - ->select(['id', '_lft', '_rgt', 'parent_id']) - ->findOrFail(7); + $this->assertNotNull(Category::withTrashed()->findOrFail(8)->deleted_at); - $node->appendToNode($parent)->save(); + GloballyScopedCategoryModel::withTrashed()->findOrFail(5)->restore(); - $this->assertSame(3, Category::findOrFail(2)->getDepth()); - $this->assertSame(4, Category::findOrFail(3)->getDepth()); - $this->assertSame(4, Category::findOrFail(4)->getDepth()); - $this->assertTreeNotBroken(); + $this->assertNotNull(Category::find(8)); } - public function testMovingPartiallySelectedSourceRefreshesItsStructuralData(): void + public function testCustomModelEventResultsDoNotSkipTreeMaintenance(): void { - $node = Category::query() - ->select(['id', 'parent_id']) - ->findOrFail(2); + // A non-null custom event result skips the model's ordinary listeners. + Event::listen(CategoryLifecycleEvent::class, static fn (): bool => true); - $node->appendToNode($this->findCategory('samsung'))->save(); + $node = new CustomEventCategoryModel(['name' => 'custom']); + $node->appendToNode(CustomEventCategoryModel::findOrFail(5))->save(); - $this->assertSame(3, Category::findOrFail(2)->getDepth()); - $this->assertSame(4, Category::findOrFail(3)->getDepth()); - $this->assertSame(4, Category::findOrFail(4)->getDepth()); $this->assertTreeNotBroken(); - } - public function testPersistedMutationParticipantRequiresASelectedKey(): void - { - $node = Category::query() - ->select(['_lft', '_rgt', 'depth', 'parent_id']) - ->whereKey(2) - ->firstOrFail(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column to be selected.', - ); + $subtree = [5, 6, 7, 8, 9, 10, $node->getKey()]; - $node->appendToNode(Category::findOrFail(5))->save(); - } + CustomEventCategoryModel::findOrFail(5)->delete(); - public function testRefreshingPersistedNodeRequiresASelectedKey(): void - { - $node = Category::query() - ->select(['_lft', '_rgt', 'depth']) - ->whereKey(3) - ->firstOrFail(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column to be selected.', - ); + $this->assertSame(0, Category::whereIn('id', $subtree)->count()); - $node->refreshNode(); - } + CustomEventCategoryModel::withTrashed()->findOrFail(5)->restore(); - public function testBeforeNodeRefreshesMissingTargetParentage(): void - { - $node = Category::findOrFail(6); - $target = Category::query() - ->select(['id', '_lft', '_rgt', 'depth']) - ->findOrFail(3); + $this->assertSame(7, Category::whereIn('id', $subtree)->count()); - $node->insertBeforeNode($target); + CustomEventCategoryModel::findOrFail(5)->forceDelete(); - $this->assertSame(2, Category::findOrFail(6)->getParentId()); - $this->assertTrue(Category::findOrFail(6)->isSiblingOf(Category::findOrFail(3))); + $this->assertSame(0, Category::withTrashed()->whereIn('id', $subtree)->count()); $this->assertTreeNotBroken(); } - public function testDeferredSiblingInsertionRevalidatesTheTargetsCurrentParent(): void + public function testPredicatesTreatZeroAsARealPersistedParentKey(): void { - $node = Category::findOrFail(3); - $target = Category::findOrFail(9); - - $node->beforeNode($target); - $target->appendToNode(Category::findOrFail(2))->save(); - $node->save(); + $parent = new Category; + $parent->setRawAttributes([ + 'id' => 0, + '_lft' => 1, + '_rgt' => 4, + 'parent_id' => null, + 'depth' => 0, + ], true); + $parent->exists = true; - $node = Category::findOrFail(3); - $target = Category::findOrFail(9); + $child = new Category; + $child->setRawAttributes([ + 'id' => 12, + '_lft' => 2, + '_rgt' => 3, + 'parent_id' => '0', + 'depth' => 1, + ], true); + $child->exists = true; - $this->assertSame(2, $node->getParentId()); - $this->assertSame($target->getParentId(), $node->getParentId()); - $this->assertTrue($node->isSiblingOf($target)); - $this->assertTreeNotBroken(); + $this->assertTrue($child->isChildOf($parent)); + $this->assertTrue($child->isDescendantOf($parent)); } - public function testFailsToInsertIntoChild(): void + public function testSiblingsUseTheConfiguredParentColumn(): void { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Node must not be a descendant.'); + DB::table('custom_column_categories')->insert([ + ['id' => 1, 'lft' => 1, 'rgt' => 6, 'level' => 0, 'ancestor_id' => null], + ['id' => 2, 'lft' => 2, 'rgt' => 3, 'level' => 1, 'ancestor_id' => 1], + ['id' => 3, 'lft' => 4, 'rgt' => 5, 'level' => 1, 'ancestor_id' => 1], + ]); - $node = $this->findCategory('notebooks'); - $target = $node->children()->first(); + $node = CustomColumnCategoryModel::with('siblings')->findOrFail(2); + $relation = $node->siblings(); - $node->afterNode($target)->save(); + $this->assertEquals([3], $node->siblings->pluck('id')->all()); + $this->assertSame('ancestor_id', $relation->getForeignKeyName()); + $this->assertSame('ancestor_id', $node->ancestors()->getForeignKeyName()); + $this->assertSame('ancestor_id', $node->descendants()->getForeignKeyName()); + $this->assertSame( + 'custom_column_categories.ancestor_id', + $relation->getQualifiedForeignKeyName(), + ); + $this->assertSame( + 'custom_column_categories.ancestor_id', + $node->ancestors()->getQualifiedForeignKeyName(), + ); + $this->assertSame( + 'custom_column_categories.ancestor_id', + $node->descendants()->getQualifiedForeignKeyName(), + ); + $this->assertTrue(CustomColumnCategoryModel::whereKey(2)->has('siblings')->exists()); } - public function testFailsToAppendIntoItself(): void + public function testCustomColumnNamesMaintainTheTree(): void { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Node must not be a descendant.'); - - $node = $this->findCategory('notebooks'); + $tree = static fn (): array => DB::table('custom_column_categories') + ->orderBy('lft') + ->get() + ->mapWithKeys(static fn (object $row): array => [$row->name => [ + (int) $row->lft, + (int) $row->rgt, + (int) $row->level, + $row->ancestor_id === null ? null : (int) $row->ancestor_id, + ]]) + ->all(); - $node->appendToNode($node)->save(); - } + $root = CustomColumnCategoryModel::create([ + 'name' => 'root', + 'children' => [ + ['name' => 'first'], + ['name' => 'second'], + ], + ]); + [$first, $second] = $root->children->all(); - public function testFailsToPrependIntoItself(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Node must not be a descendant.'); + // The relationship and the attribute reach the custom parent column's mutator. + $first->children()->create(['name' => 'child']); + CustomColumnCategoryModel::create(['name' => 'assigned', 'ancestor_id' => $second->getKey()]); - $node = $this->findCategory('notebooks'); + $this->assertTrue($second->up()); + $this->assertTrue($first->delete()); - $node->prependToNode($node)->save(); - } + $expected = [ + 'root' => [1, 6, 0, null], + 'second' => [2, 5, 1, $root->getKey()], + 'assigned' => [3, 4, 2, $second->getKey()], + ]; - public function testStructuralTargetsMustHavePositiveStoredBounds(): void - { - $target = $this->findCategory('apple') - ->setLft(0) - ->setRgt(0); + $this->assertSame($expected, $tree()); + $this->assertSame(0, CustomColumnCategoryModel::getTotalErrors()); - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Node must be part of a tree.'); + DB::table('custom_column_categories')->update(['lft' => 0, 'rgt' => 0, 'level' => 0]); - (new Category(['name' => 'test']))->appendToNode($target); + $this->assertSame(3, CustomColumnCategoryModel::fixTree()); + $this->assertSame($expected, $tree()); } - public function testRefreshNodeReadsCurrentLowLevelStructuralWrites(): void + public function testTreeRootSelectionDistinguishesInferenceNullZeroAndEmptyString(): void { - $node = Category::findOrFail(11); - - Category::query()->makeGap(21, 2); - $node->refreshNode(); - - $this->assertSame([23, 24], $node->getBounds()); - - Category::query()->moveNode(11, 1); - $node->refreshNode(); - - $this->assertSame([1, 2], $node->getBounds()); - } + $nodes = new Collection([ + $this->makeCollectionNode(1, 1, 2, null), + $this->makeCollectionNode(2, 3, 4, 0), + $this->makeCollectionNode(3, 5, 6, ''), + ]); - public function testRawNodeWithCompleteScopeDoesNotPreflightPersistedIdentity(): void - { - $node = Category::findOrFail(3); - DB::flushQueryLog(); + $this->assertEquals([1], $nodes->toTree(null)->pluck('id')->all()); + $this->assertEquals([2], $nodes->toTree(0)->pluck('id')->all()); + $this->assertEquals([3], $nodes->toTree('')->pluck('id')->all()); + $this->assertEquals([1], $nodes->toTree()->pluck('id')->all()); - $node->rawNode( - $node->getLft(), - $node->getRgt(), - $node->getParentId(), - $node->getDepth() + 1, - )->save(); + $partial = new Collection([ + $this->makeCollectionNode(2, 3, 4, 0), + $this->makeCollectionNode(3, 5, 6, 0), + ]); - $this->assertSame(0, $this->countStructuralIdentityReloads()); - $this->assertSame( - $node->getDepth(), - Category::findOrFail($node->getKey())->getDepth(), - ); + $this->assertEquals([2, 3], $partial->toTree()->pluck('id')->all()); + $this->assertTrue($partial->toTree(null)->isEmpty()); } - #[DataProvider('structuralMutationMethods')] - public function testStructuralMutationTargetsMustUseTheSameTree(string $method): void + public function testTreeRootSelectionSupportsUuidAndUlidKeys(): void { - $connection = DB::getDefaultConnection(); - - config([ - 'database.connections.nested_set_other' => config( - "database.connections.{$connection}", - ), - ]); - - $source = $this->findCategory('apple'); - $target = $this->findCategory('notebooks') - ->setConnection('nested_set_other'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Nodes must be in the same tree.'); - - $source->{$method}($target); - } - - public static function structuralMutationMethods(): array - { - return [ - 'append' => ['appendToNode'], - 'prepend' => ['prependToNode'], - 'before' => ['beforeNode'], - 'after' => ['afterNode'], - ]; - } - - public function testStructuralPredicatesRequireTheSameStore(): void - { - $connection = DB::getDefaultConnection(); - - config([ - 'database.connections.nested_set_other' => config( - "database.connections.{$connection}", - ), - ]); - - $child = $this->findCategory('apple'); - $parent = $this->findCategory('notebooks') - ->setConnection('nested_set_other'); - $sibling = Category::findOrFail(4) - ->setConnection('nested_set_other'); - - $this->assertFalse($child->isChildOf($parent)); - $this->assertFalse($child->isDescendantOf($parent)); - $this->assertFalse($child->isSiblingOf($sibling)); - } - - public function testWithoutRootWorks(): void - { - $this->assertSame( - [2, 3, 4, 5, 6, 7, 8, 9, 10], - Category::withoutRoot()->orderBy('id')->pluck('id')->all(), - ); - } - - public function testStructuralReadQueriesQualifyTheirColumnsAfterJoins(): void - { - $query = Category::query() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id'); - - $this->assertSame( - [1, 11], - (clone $query)->whereIsRoot()->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [2, 3, 4, 5, 6, 7, 8, 9, 10], - (clone $query)->withoutRoot()->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [2, 3, 4, 5, 6, 7, 8, 9, 10], - (clone $query)->hasParent()->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [3, 4, 6, 8, 9, 10, 11], - (clone $query)->whereIsLeaf()->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [1, 2, 5, 7], - (clone $query)->hasChildren()->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [1, 2, 3, 4], - (clone $query)->whereIsBefore(5)->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [6, 7, 8, 9, 10, 11], - (clone $query)->whereIsAfter(5)->orderBy('categories.id')->pluck('id')->all(), - ); - $this->assertSame( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], - (clone $query)->defaultOrder()->pluck('id')->all(), - ); - - $node = Category::findOrFail(5); - - $this->assertSame( - [6, 7, 8, 9, 10, 11], - $node->nextNodes() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->orderBy('categories.id') - ->pluck('id') - ->all(), - ); - $this->assertSame( - [1, 2, 3, 4], - $node->prevNodes() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->orderBy('categories.id') - ->pluck('id') - ->all(), - ); - $this->assertSame( - [5], - Category::findOrFail(2) - ->nextSiblings() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->pluck('id') - ->all(), - ); - $this->assertSame( - [2], - $node->prevSiblings() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->pluck('id') - ->all(), - ); - $this->assertSame( - [2], - $node->siblings() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->orderBy('categories.id') - ->pluck('id') - ->all(), - ); - $this->assertSame( - [2, 5], - $node->siblingsAndSelf() - ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') - ->select('categories.id') - ->orderBy('categories.id') - ->pluck('id') - ->all(), - ); - } - - public function testDefaultOrderClearsPreviousOrderBindings(): void - { - $ids = Category::query() - ->orderByRaw('case when name = ? then 0 else 1 end', ['apple']) - ->defaultOrder() - ->where('name', '=', 'store') - ->pluck('id') - ->all(); - - $this->assertSame([1], $ids); - } - - public function testDefaultOrderClearsPreviousUnionOrderBindings(): void - { - $ids = Category::query() - ->select(['id', '_lft']) - ->whereKey(1) - ->union(Category::query()->select(['id', '_lft'])->whereKey(3)) - ->orderByRaw('case when name = ? then 0 else 1 end', ['apple']) - ->defaultOrder() - ->get() - ->pluck('id') - ->all(); - - $this->assertSame([1, 3], $ids); - } - - public function testAncestorsReturnsAncestorsWithoutNodeItself(): void - { - $node = $this->findCategory('apple'); - $path = $this->getAll($node->ancestors()->pluck('name')); - - $this->assertEquals(['store', 'notebooks'], $path); - } - - public function testGetsAncestorsByStatic(): void - { - $path = $this->getAll(Category::ancestorsOf(3)->pluck('name')); - - $this->assertEquals(['store', 'notebooks'], $path); - } - - public function testGetsAncestorsDirect(): void - { - $path = $this->getAll(Category::find(8)->getAncestors()->pluck('id')); - - $this->assertEquals([1, 5, 7], $path); - } - - public function testDescendants(): void - { - $node = $this->findCategory('mobile'); - $descendants = $this->getAll($node->descendants()->pluck('name')); - $expected = ['nokia', 'samsung', 'galaxy', 'sony', 'lenovo']; - - $this->assertEquals($expected, $descendants); - - $descendants = $this->getAll($node->getDescendants()->pluck('name')); - - $this->assertEquals(count($descendants), $node->getDescendantCount()); - $this->assertEquals($expected, $descendants); - - $descendants = $this->getAll(Category::descendantsAndSelf(7)->pluck('name')); - $expected = ['samsung', 'galaxy']; - - $this->assertEquals($expected, $descendants); - } - - #[DataProvider('partialNodeStateMethods')] - public function testPersistedNodeStateCountsRequireLoadedBounds(string $method, array $columns): void - { - $node = Category::query()->select($columns)->findOrFail(5); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\Category] must have loaded bounds.', - ); - - $node->{$method}(); - } - - public static function partialNodeStateMethods(): array - { - return [ - 'height without left bound' => ['getNodeHeight', ['id', '_rgt']], - 'height without right bound' => ['getNodeHeight', ['id', '_lft']], - 'descendant count without left bound' => ['getDescendantCount', ['id', '_rgt']], - 'descendant count without right bound' => ['getDescendantCount', ['id', '_lft']], - ]; - } - - #[DataProvider('partialLeafProjections')] - public function testLeafPredicateTreatsMissingBoundsAsFalse(array $attributes): void - { - $node = new Category; - $node->setRawAttributes($attributes, true); - $node->exists = true; - - $this->assertFalse($node->isLeaf()); - } - - public static function partialLeafProjections(): array - { - return [ - 'missing left bound' => [['id' => 3, '_rgt' => 4]], - 'missing right bound' => [['id' => 3, '_lft' => 3]], - 'missing left bound at the coercion edge' => [['id' => 99, '_rgt' => 1]], - ]; - } - - public function testWithDepthWorks(): void - { - $nodes = $this->getAll(Category::withDepth()->limit(4)->pluck('depth')); - - $this->assertEquals([0, 1, 2, 2], $nodes); - } - - public function testWithDepthWithCustomKeyWorks(): void - { - $node = Category::whereIsRoot()->withDepth('level')->first(); - - $this->assertSame(0, $node['level']); - } - - public function testWithDepthWorksAlongWithDefaultKeys(): void - { - $node = Category::withDepth()->first(); - - $this->assertTrue(isset($node->name)); - } - - public function testParentIdAttributeAccessorAppendsNode(): void - { - $node = new Category(['name' => 'lg', 'parent_id' => 5]); - $node->save(); - - $this->assertEquals(5, $node->parent_id); - $this->assertEquals(5, $node->getParentId()); - - $node->parent_id = null; - $node->save(); - - $node->refreshNode(); - - $this->assertNull($node->parent_id); - $this->assertTrue($node->isRoot()); - } - - public function testParentIdAppendResolvesTheParentOnlyOnce(): void - { - $node = new Category(['name' => 'new phone']); - $node->parent_id = 5; - DB::flushQueryLog(); - - $node->save(); - - $this->assertSame(1, $this->countStructuralIdentityReloads()); - $this->assertSame(5, $node->getParentId()); - $this->assertTreeNotBroken(); - } - - public function testNodeIsDeletedWithDescendants(): void - { - $node = $this->findCategory('mobile'); - $node->forceDelete(); - - $this->assertTreeNotBroken(); - - $nodes = Category::whereIn('id', [5, 6, 7, 8, 9, 10])->count(); - $this->assertEquals(0, $nodes); - - $root = Category::root(); - $this->assertEquals(8, $root->getRgt()); - } - - public function testForceDeletingPartiallySelectedNodeRefreshesItsStructuralData(): void - { - $node = Category::query() - ->select(['id', 'parent_id']) - ->findOrFail(5); - - $node->forceDelete(); - - $this->assertSame(0, Category::whereIn('id', [5, 6, 7, 8, 9, 10])->count()); - $this->assertSame(8, Category::root()->getRgt()); - $this->assertTreeNotBroken(); - } - - public function testForceDeletingAStaleMissingModelDoesNotMutateTheTree(): void - { - $node = Category::findOrFail(3); - Category::findOrFail(3)->forceDelete(); - - $before = DB::table('categories') - ->orderBy('id') - ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->map(fn (object $row): array => (array) $row) - ->all(); - - try { - $node->forceDelete(); - $this->fail('Expected the missing nested set row to be rejected.'); - } catch (ModelNotFoundException) { - $this->assertSame( - $before, - DB::table('categories') - ->orderBy('id') - ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->map(fn (object $row): array => (array) $row) - ->all(), - ); - } - } - - public function testNodeIsSoftDeleted(): void - { - CarbonImmutable::setTestNow('2025-07-03 12:00:00'); - - $root = Category::root(); - - $samsung = $this->findCategory('samsung'); - $samsung->delete(); - - $this->assertTreeNotBroken(); - $this->assertNull($this->findCategory('galaxy')); - - CarbonImmutable::setTestNow('2025-07-03 12:00:01'); - - $node = $this->findCategory('mobile'); - $node->delete(); - - $nodes = Category::whereIn('id', [5, 6, 7, 8, 9, 10])->count(); - $this->assertEquals(0, $nodes); - - $originalRgt = $root->getRgt(); - $root->refreshNode(); - - $this->assertEquals($originalRgt, $root->getRgt()); - - $node = $this->findCategory('mobile', true); - $node->restore(); - - $this->assertNull($this->findCategory('samsung')); - $this->assertNotNull($this->findCategory('nokia')); - } - - public function testSoftDeletingPartiallySelectedNodeRefreshesItsStructuralData(): void - { - $node = Category::query() - ->select(['id', 'parent_id']) - ->findOrFail(7); - - $node->delete(); - - $this->assertNull(Category::find(7)); - $this->assertNull(Category::find(8)); - $this->assertNotNull(Category::withTrashed()->find(7)); - $this->assertNotNull(Category::withTrashed()->find(8)); - $this->assertTreeNotBroken(); - } - - public function testRestoringPartiallySelectedNodeRefreshesItsStructuralData(): void - { - Category::findOrFail(7)->delete(); - - $node = Category::withTrashed() - ->select(['id']) - ->findOrFail(7); - - $node->restore(); - - $this->assertNotNull(Category::find(7)); - $this->assertNotNull(Category::find(8)); - $this->assertTreeNotBroken(); - } - - public function testRestoredNodeDoesNotRetainItsPreviousDeletionTimestamp(): void - { - $node = $this->findCategory('mobile'); - $node->delete(); - - $node = $this->findCategory('mobile', true); - $node->restore(); - - $this->assertNull($node->deleted_at); - - $node->name = 'restored mobile'; - $node->save(); - - $this->assertNotNull($this->findCategory('restored mobile')); - } - - public function testReentrantRestoreUsesEachNodesExactPreviousDeletionTimestamp(): void - { - CarbonImmutable::setTestNow('2025-07-03 11:59:59'); - $this->findCategory('apple')->delete(); - - CarbonImmutable::setTestNow('2025-07-03 12:00:00'); - $this->findCategory('notebooks')->delete(); - - CarbonImmutable::setTestNow('2025-07-03 12:00:01'); - $this->findCategory('samsung')->delete(); - - CarbonImmutable::setTestNow('2025-07-03 12:00:02'); - $this->findCategory('mobile')->delete(); - - $nestedRestore = false; - - Category::restoring(function (Category $node) use (&$nestedRestore): void { - if ($node->getKey() !== 5 || $nestedRestore) { - return; - } - - $nestedRestore = true; - Category::withTrashed()->findOrFail(2)->restore(); - }); - - Category::withTrashed()->findOrFail(5)->restore(); - - $this->assertNull($this->findCategory('apple')); - $this->assertNotNull(Category::find(4)); - $this->assertNotNull($this->findCategory('nokia')); - $this->assertNull($this->findCategory('samsung')); - } - - public function testSoftDeletedNodeIsDeletedWhenParentIsDeleted(): void - { - $this->findCategory('samsung')->delete(); - - $this->findCategory('mobile')->forceDelete(); - - $this->assertTreeNotBroken(); - - $this->assertNull($this->findCategory('samsung', true)); - $this->assertNull($this->findCategory('sony')); - } - - public function testEventedDescendantDeletionRunsChildrenFirstInChunks(): void - { - $deleting = []; - - EventedCategoryModel::deleting(function (EventedCategoryModel $model) use (&$deleting): void { - $deleting[] = $model->getKey(); - }); - - $node = EventedCategoryModel::findOrFail(5); - DB::flushQueryLog(); - - $node->delete(); - - $this->assertSame([5, 10, 9, 8, 7, 6], $deleting); - - foreach ([5, 6, 7, 8, 9, 10] as $id) { - $this->assertNotNull(EventedCategoryModel::withTrashed()->findOrFail($id)->deleted_at); - } - - $this->assertSame(4, count(array_filter( - array_column(DB::getQueryLog(), 'query'), - static fn (string $query): bool => str_contains($query, 'order by "_lft" desc limit 2'), - ))); - } - - public function testEventedDescendantDeletionPropagatesVetoesForTransactionRollback(): void - { - EventedCategoryModel::deleting( - fn (EventedCategoryModel $model) => $model->getKey() === 8 ? false : null, - ); - - try { - DB::transaction(fn () => EventedCategoryModel::findOrFail(5)->delete()); - $this->fail('Expected the descendant deletion veto to propagate.'); - } catch (LogicException $exception) { - $this->assertSame( - sprintf( - 'Deleting nested set descendant [%s] with key [8] was vetoed.', - EventedCategoryModel::class, - ), - $exception->getMessage(), - ); - } - - foreach ([5, 6, 7, 8, 9, 10] as $id) { - $this->assertNull(EventedCategoryModel::withTrashed()->findOrFail($id)->deleted_at); - } - } - - public function testEventedForceDeletionIncludesTrashedDescendantsAndClosesTheGap(): void - { - EventedCategoryModel::findOrFail(8)->delete(); - - $deleting = []; - - EventedCategoryModel::deleting(function (EventedCategoryModel $model) use (&$deleting): void { - $deleting[] = $model->getKey(); - }); - - EventedCategoryModel::findOrFail(5)->forceDelete(); - - $this->assertSame([5, 10, 9, 8, 7, 6], $deleting); - $this->assertSame(8, EventedCategoryModel::findOrFail(1)->getRgt()); - - foreach ([5, 6, 7, 8, 9, 10] as $id) { - $this->assertNull(EventedCategoryModel::withTrashed()->find($id)); - } - } - - public function testFailsToSaveNodeUntilParentIsSaved(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Node must be part of a tree.'); - - $node = new Category(['name' => 'Node']); - $parent = new Category(['name' => 'Parent']); - - $node->appendToNode($parent)->save(); - } - - public function testSiblings(): void - { - $node = $this->findCategory('samsung'); - $siblings = $this->getAll($node->siblings()->pluck('id')); - $next = $this->getAll($node->nextSiblings()->pluck('id')); - $prev = $this->getAll($node->prevSiblings()->pluck('id')); - - $this->assertEquals([6, 9, 10], $siblings); - $this->assertEquals([9, 10], $next); - $this->assertEquals([6], $prev); - - $siblings = $this->getAll($node->getSiblings()->pluck('id')); - $next = $this->getAll($node->getNextSiblings()->pluck('id')); - $prev = $this->getAll($node->getPrevSiblings()->pluck('id')); - - $this->assertEquals([6, 9, 10], $siblings); - $this->assertEquals([9, 10], $next); - $this->assertEquals([6], $prev); - - $next = $node->getNextSibling(); - $prev = $node->getPrevSibling(); - - $this->assertEquals(9, $next->id); - $this->assertEquals(6, $prev->id); - } - - public function testPredicatesRequirePersistedRowsAndUseLogicalRowIdentity(): void - { - $root = Category::findOrFail(1); - $parent = Category::findOrFail(2); - $child = Category::findOrFail(3); - $sibling = Category::findOrFail(4); - $unsaved = new Category([ - 'parent_id' => $parent->getKey(), - '_lft' => $child->getLft(), - '_rgt' => $child->getRgt(), - ]); - - $sameRow = new Category; - $sameRow->setRawAttributes($child->getAttributes(), true); - $sameRow->exists = true; - $sameRow->setConnection($child->getConnection()->getName()); - - $this->assertTrue($child->isDescendantOf($root)); - $this->assertTrue($root->isAncestorOf($child)); - $this->assertTrue($child->isChildOf($parent)); - $this->assertTrue($child->isSiblingOf($sibling)); - $this->assertTrue($child->isSelfOrDescendantOf($sameRow)); - $this->assertTrue($child->isSelfOrAncestorOf($sameRow)); - $this->assertFalse($child->isDescendantOf($sameRow)); - $this->assertFalse($child->isAncestorOf($sameRow)); - $this->assertFalse($child->isSiblingOf($sameRow)); - $this->assertFalse($unsaved->isDescendantOf($root)); - $this->assertFalse($unsaved->isSelfOrDescendantOf($child)); - $this->assertFalse($unsaved->isChildOf($parent)); - $this->assertFalse($unsaved->isSiblingOf($sibling)); - } - - public function testPredicatesTreatZeroAsARealPersistedParentKey(): void - { - $parent = new Category; - $parent->setRawAttributes([ - 'id' => 0, - '_lft' => 1, - '_rgt' => 4, - 'parent_id' => null, - 'depth' => 0, - ], true); - $parent->exists = true; - - $child = new Category; - $child->setRawAttributes([ - 'id' => 12, - '_lft' => 2, - '_rgt' => 3, - 'parent_id' => '0', - 'depth' => 1, - ], true); - $child->exists = true; - - $this->assertTrue($child->isChildOf($parent)); - $this->assertTrue($child->isDescendantOf($parent)); - } - - public function testPartiallySelectedRowsWithoutKeysAreNotTheSameNode(): void - { - $first = Category::query() - ->select(['_lft', '_rgt', 'parent_id', 'depth']) - ->whereKey(3) - ->firstOrFail(); - $second = Category::query() - ->select(['_lft', '_rgt', 'parent_id', 'depth']) - ->whereKey(4) - ->firstOrFail(); - - $this->assertFalse($first->isSelfOrDescendantOf($second)); - $this->assertFalse($first->isSelfOrAncestorOf($second)); - } - - #[DataProvider('partialAncestryPredicates')] - public function testAncestryPredicatesTreatMissingBoundsAsFalse( - array $descendantColumns, - array $ancestorColumns, - ): void { - $descendant = Category::query() - ->select($descendantColumns) - ->findOrFail(7); - $ancestor = Category::query() - ->select($ancestorColumns) - ->findOrFail(5); - - $this->assertFalse($descendant->isDescendantOf($ancestor)); - $this->assertFalse($descendant->isSelfOrDescendantOf($ancestor)); - $this->assertFalse($ancestor->isAncestorOf($descendant)); - $this->assertFalse($ancestor->isSelfOrAncestorOf($descendant)); - } - - public static function partialAncestryPredicates(): array - { - return [ - 'missing descendant left bound' => [ - ['id', '_rgt'], - ['id', '_lft', '_rgt'], - ], - 'missing ancestor left bound' => [ - ['id', '_lft', '_rgt'], - ['id', '_rgt'], - ], - 'missing ancestor right bound' => [ - ['id', '_lft', '_rgt'], - ['id', '_lft'], - ], - ]; - } - - public function testSelfInclusiveAncestryPredicatesNeedOnlyPersistedIdentity(): void - { - $same = Category::query()->select(['id'])->findOrFail(3); - - $this->assertTrue($same->isSelfOrDescendantOf($same)); - $this->assertTrue($same->isSelfOrAncestorOf($same)); - } - - public function testSiblingsAreRealLazyLoadableRelations(): void - { - $node = $this->findCategory('samsung'); - - $this->assertEquals([6, 9, 10], $this->getAll($node->siblings->pluck('id'))); - $this->assertTrue($node->relationLoaded('siblings')); - - $node->unsetRelation('siblings'); - - $this->assertEquals([6, 7, 9, 10], $this->getAll($node->siblingsAndSelf->pluck('id'))); - $this->assertTrue($node->relationLoaded('siblingsAndSelf')); - } - - public function testSiblingsEagerLoadWithExactParentBuckets(): void - { - $nodes = Category::whereIn('id', [3, 7]) - ->defaultOrder() - ->get(); - - $nodes->load(['siblings', 'siblingsAndSelf']); - - $this->assertEquals([4], $this->getAll($nodes->find(3)->siblings->pluck('id'))); - $this->assertEquals([3, 4], $this->getAll($nodes->find(3)->siblingsAndSelf->pluck('id'))); - $this->assertEquals([6, 9, 10], $this->getAll($nodes->find(7)->siblings->pluck('id'))); - $this->assertEquals([6, 7, 9, 10], $this->getAll($nodes->find(7)->siblingsAndSelf->pluck('id'))); - } - - public function testStrictSiblingRelationsRequireASelectedParentKey(): void - { - $node = Category::query() - ->select(['parent_id']) - ->where('name', '=', 'apple') - ->firstOrFail(); - - $this->assertNull($node->getKey()); - $this->assertSame( - [3, 4], - $node->siblingsAndSelf()->orderBy('id')->pluck('id')->all(), - ); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set relation parent for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column.', - ); - - $node->siblings()->get(); - } - - #[DataProvider('strictSiblingEagerParentIds')] - public function testStrictSiblingEagerLoadingRequiresTheRelatedKey(array $parentIds): void - { - $nodes = Category::whereIn('id', $parentIds)->get(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set relation eager load for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column.', - ); - - $nodes->load([ - 'siblings' => fn ($query) => $query->select(['parent_id', 'name']), - ]); - } - - public static function strictSiblingEagerParentIds(): array - { - return [ - 'single parent' => [[3]], - 'multiple parents' => [[3, 6]], - ]; - } - - #[DataProvider('siblingRelations')] - public function testSiblingEagerLoadingRequiresTheRelatedParentColumn(string $relation): void - { - $nodes = Category::whereIn('id', [1, 3])->get(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set relation eager load for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [parent_id] column.', - ); - - $nodes->load([ - $relation => fn ($query) => $query->select(['id', 'name']), - ]); - } - - public static function siblingRelations(): array - { - return [ - 'siblings' => ['siblings'], - 'siblings and self' => ['siblingsAndSelf'], - ]; - } - - public function testSiblingRelationsRequireSelectedParentage(): void - { - $node = Category::query()->select(['id'])->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set relation parent for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [parent_id] column.', - ); - - $node->siblings()->get(); - } - - #[DataProvider('eagerRelationParents')] - public function testPersistedPartialEagerParentsAreRejected( - string $relation, - int $parentId, - string $requiredColumn, - bool $partialFirst, - ): void { - $partial = Category::query()->select(['id'])->findOrFail($parentId); - $complete = Category::findOrFail($parentId); - $models = $partialFirst - ? [$partial, $complete] - : [$complete, $partial]; - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation parent for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\Category] requires the [{$requiredColumn}] column.", - ); - - $complete->newCollection($models)->load($relation); - } - - public static function eagerRelationParents(): array - { - return [ - 'ancestors, partial first' => ['ancestors', 3, '_lft', true], - 'ancestors, complete first' => ['ancestors', 3, '_lft', false], - 'descendants, partial first' => ['descendants', 2, '_lft', true], - 'descendants, complete first' => ['descendants', 2, '_lft', false], - 'siblings, partial first' => ['siblings', 3, 'parent_id', true], - 'siblings, complete first' => ['siblings', 3, 'parent_id', false], - 'siblings and self, partial first' => ['siblingsAndSelf', 3, 'parent_id', true], - 'siblings and self, complete first' => ['siblingsAndSelf', 3, 'parent_id', false], - ]; - } - - #[DataProvider('incompleteRelationParents')] - public function testIncompleteEagerParentsAreRejected(string $relation, string $requiredColumn): void - { - $nodes = Category::query() - ->select(['id']) - ->whereIn('id', [3, 7]) - ->get(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation parent for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\Category] requires the [{$requiredColumn}] column.", - ); - - $nodes->load($relation); - } - - #[DataProvider('incompleteRelationParents')] - public function testIncompleteLazyParentsAreRejected(string $relation, string $requiredColumn): void - { - $node = Category::query()->select(['id'])->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation parent for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\Category] requires the [{$requiredColumn}] column.", - ); - - $node->{$relation}()->toSql(); - } - - #[DataProvider('relationNames')] - public function testUnsavedRelationParentsRemainEmpty(string $relation): void - { - $node = new Category; - DB::flushQueryLog(); - - $this->assertTrue($node->getRelationValue($relation)->isEmpty()); - $this->assertSame([], DB::getQueryLog()); - } - - public static function relationNames(): array - { - return array_map( - static fn (array $case): array => [$case[0]], - static::incompleteRelationParents(), - ); - } - - public function testUnsavedDuplicateDoesNotSuppressACompleteEagerParent(): void - { - $complete = Category::findOrFail(3); - $unsaved = new Category; - $unsaved->setRawAttributes($complete->getAttributes(), true); - - $models = new Collection([$unsaved, $complete]); - $models->load('ancestors'); - - $this->assertTrue($unsaved->ancestors->isEmpty()); - $this->assertSame([1, 2], $complete->ancestors->pluck('id')->all()); - } - - #[DataProvider('incompleteRelationParents')] - public function testDestructiveRelationQueriesRejectIncompletePersistedParents( - string $relation, - string $requiredColumn, - ): void { - $node = Category::query()->select(['id'])->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation parent for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\Category] requires the [{$requiredColumn}] column.", - ); - - $node->{$relation}()->delete(); - } - - public static function incompleteRelationParents(): array - { - return [ - 'ancestors' => ['ancestors', '_lft'], - 'descendants' => ['descendants', '_lft'], - 'siblings' => ['siblings', 'parent_id'], - 'siblings and self' => ['siblingsAndSelf', 'parent_id'], - ]; - } - - public function testRootSiblingsSupportEagerAndExistenceQueries(): void - { - $nodes = Category::whereIn('id', [1, 11]) - ->defaultOrder() - ->get(); - - $nodes->load('siblings'); - - $this->assertEquals([11], $this->getAll($nodes->find(1)->siblings->pluck('id'))); - $this->assertEquals([1], $this->getAll($nodes->find(11)->siblings->pluck('id'))); - $this->assertEquals([1, 11], $this->getAll( - Category::has('siblings')->whereIn('id', [1, 11])->orderBy('id')->pluck('id'), - )); - } - - public function testSiblingsSupportExistenceCounts(): void - { - $this->assertEquals([3], $this->getAll( - Category::has('siblings')->whereIn('id', [3, 8])->pluck('id'), - )); - - $this->assertEquals([6, 7, 9, 10], $this->getAll( - Category::has('siblings', '>', 2)->orderBy('id')->pluck('id'), - )); - } - - public function testSiblingsUseTheConfiguredParentColumn(): void - { - Schema::create('custom_parent_categories', function (Blueprint $table): void { - $table->id(); - $table->unsignedInteger('_lft')->default(0); - $table->unsignedInteger('_rgt')->default(0); - $table->unsignedSmallInteger('depth')->default(0); - $table->unsignedBigInteger('ancestor_id')->nullable(); - }); - - DB::table('custom_parent_categories')->insert([ - ['id' => 1, '_lft' => 1, '_rgt' => 6, 'depth' => 0, 'ancestor_id' => null], - ['id' => 2, '_lft' => 2, '_rgt' => 3, 'depth' => 1, 'ancestor_id' => 1], - ['id' => 3, '_lft' => 4, '_rgt' => 5, 'depth' => 1, 'ancestor_id' => 1], - ]); - - $node = CustomParentCategoryModel::with('siblings')->findOrFail(2); - $relation = $node->siblings(); - - $this->assertEquals([3], $node->siblings->pluck('id')->all()); - $this->assertSame('ancestor_id', $relation->getForeignKeyName()); - $this->assertSame('ancestor_id', $node->ancestors()->getForeignKeyName()); - $this->assertSame('ancestor_id', $node->descendants()->getForeignKeyName()); - $this->assertSame( - 'custom_parent_categories.ancestor_id', - $relation->getQualifiedForeignKeyName(), - ); - $this->assertSame( - 'custom_parent_categories.ancestor_id', - $node->ancestors()->getQualifiedForeignKeyName(), - ); - $this->assertSame( - 'custom_parent_categories.ancestor_id', - $node->descendants()->getQualifiedForeignKeyName(), - ); - $this->assertTrue(CustomParentCategoryModel::whereKey(2)->has('siblings')->exists()); - } - - public function testFetchesReversed(): void - { - $node = $this->findCategory('sony'); - $siblings = $node->prevSiblings()->reversed()->value('id'); - - $this->assertEquals(7, $siblings); - } - - public function testToTreeBuildsWithDefaultOrder(): void - { - $tree = Category::whereBetween('_lft', [8, 17])->defaultOrder()->get()->toTree(); - - $this->assertEquals(1, count($tree)); - - $root = $tree->first(); - $this->assertEquals('mobile', $root->name); - $this->assertEquals(4, count($root->children)); - $this->assertSame([6, 7, 9, 10], $root->children->modelKeys()); - } - - public function testToTreeBuildsWithCustomOrder(): void - { - $tree = Category::whereBetween('_lft', [8, 17]) - ->orderBy('name') - ->get() - ->toTree(); - - $this->assertEquals(1, count($tree)); - - $root = $tree->first(); - $this->assertEquals('mobile', $root->name); - $this->assertEquals(4, count($root->children)); - $this->assertSame([10, 6, 7, 9], $root->children->modelKeys()); - $this->assertNotSame($root, $root->children->first()->parent); - $this->assertSame($root->getKey(), $root->children->first()->parent->getKey()); - $this->assertSame([], $root->children->first()->parent->getRelations()); - } - - #[DataProvider('treeBuildingProjectionRequirements')] - public function testTreeBuildingRequiresStructuralProjection( - string $method, - array $columns, - string $requiredColumn, - ): void { - $nodes = Category::defaultOrder()->get($columns); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs(sprintf( - 'Nested set tree building for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [%s] column in the projection.', - $requiredColumn, - )); - - $nodes->{$method}(); - } - - public static function treeBuildingProjectionRequirements(): array - { - return [ - 'link nodes without key' => ['linkNodes', ['_lft', 'parent_id', 'name'], 'id'], - 'tree without key' => ['toTree', ['_lft', 'parent_id', 'name'], 'id'], - 'flat tree without key' => ['toFlatTree', ['_lft', 'parent_id', 'name'], 'id'], - 'link nodes without parent' => ['linkNodes', ['id', '_lft', 'name'], 'parent_id'], - 'tree without parent' => ['toTree', ['id', '_lft', 'name'], 'parent_id'], - 'flat tree without parent' => ['toFlatTree', ['id', '_lft', 'name'], 'parent_id'], - ]; - } - - #[DataProvider('treeBuildingMethods')] - public function testInferredRootTreeBuildingRequiresTheLeftBound(string $method): void - { - $nodes = Category::query() - ->whereBetween('_lft', [2, 7]) - ->defaultOrder() - ->get(['id', 'parent_id', 'name']); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set tree building for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [_lft] column in the projection.', - ); - - $nodes->{$method}(); - } - - #[DataProvider('treeBuildingMethods')] - public function testTreeBuildingRequiresAKeyOnTheSuppliedRoot(string $method): void - { - $root = Category::query() - ->select(['_lft', '_rgt', 'name']) - ->findOrFail(2); - $nodes = Category::defaultOrder()->get(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set tree building for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column on the supplied root.', - ); - - $nodes->{$method}($root); - } - - #[DataProvider('treeBuildingMethods')] - public function testTreeBuildingRejectsRootModelsWithoutNestedSet(string $method): void - { - $root = new class extends Model { - }; - $nodes = Category::defaultOrder()->get(); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIs(sprintf( - 'Model [%s] must be node.', - $root::class, - )); - - $nodes->{$method}($root); - } - - public static function treeBuildingMethods(): array - { - return [ - 'tree' => ['toTree'], - 'flat tree' => ['toFlatTree'], - ]; - } - - public function testExplicitRootTreeBuildingDoesNotRequireTheLeftBound(): void - { - $root = Category::query()->select(['id'])->findOrFail(2); - $nodes = Category::query() - ->whereBetween('_lft', [2, 7]) - ->get(['id', 'parent_id', 'name']); - - $this->assertSame([3, 4], $nodes->toTree($root)->modelKeys()); - $this->assertSame([3, 4], $nodes->toFlatTree($root)->modelKeys()); - } - - public function testToTreeWithSpecifiedRoot(): void - { - $node = $this->findCategory('mobile'); - $nodes = Category::whereBetween('_lft', [8, 17])->get(); - - $tree1 = Collection::make($nodes)->toTree(5); - $tree2 = Collection::make($nodes)->toTree($node); - - $this->assertEquals(4, $tree1->count()); - $this->assertEquals(4, $tree2->count()); - } - - public function testTreeRootSelectionDistinguishesInferenceNullZeroAndEmptyString(): void - { - $nodes = new Collection([ - $this->makeCollectionNode(1, 1, 2, null), - $this->makeCollectionNode(2, 3, 4, 0), - $this->makeCollectionNode(3, 5, 6, ''), - ]); - - $this->assertEquals([1], $nodes->toTree(null)->pluck('id')->all()); - $this->assertEquals([2], $nodes->toTree(0)->pluck('id')->all()); - $this->assertEquals([3], $nodes->toTree('')->pluck('id')->all()); - $this->assertEquals([1], $nodes->toTree()->pluck('id')->all()); - - $partial = new Collection([ - $this->makeCollectionNode(2, 3, 4, 0), - $this->makeCollectionNode(3, 5, 6, 0), - ]); - - $this->assertEquals([2, 3], $partial->toTree()->pluck('id')->all()); - $this->assertTrue($partial->toTree(null)->isEmpty()); - } - - public function testTreeRootSelectionSupportsUuidAndUlidKeys(): void - { - $uuid = '018f3a2b-0000-7000-8000-000000000001'; - $ulid = '01J9ZTR3WQ4Z78F7N4MFSRMK7H'; - $nodes = new Collection([ - $uuidRoot = $this->makeCollectionNode( - $uuid, - 1, - 4, - null, - new StringKeyCategoryModel, - ), - $this->makeCollectionNode( - '018f3a2b-0000-7000-8000-000000000002', - 2, - 3, - $uuid, - new StringKeyCategoryModel, - ), - $ulidRoot = $this->makeCollectionNode( - $ulid, - 5, - 8, - null, - new StringKeyCategoryModel, - ), - $this->makeCollectionNode( - '01J9ZTR3WQ4Z78F7N4MFSRMK7J', - 6, - 7, - $ulid, - new StringKeyCategoryModel, - ), - ]); - - $this->assertSame( - ['018f3a2b-0000-7000-8000-000000000002'], - $nodes->toTree($uuidRoot)->modelKeys(), - ); - $this->assertSame( - ['01J9ZTR3WQ4Z78F7N4MFSRMK7J'], - $nodes->toTree($ulid)->modelKeys(), - ); - } - - public function testLinkNodesClearsStaleRelationsAndUsesSharedRelationFreeParents(): void - { - $nodes = Category::defaultOrder()->get(); - $leaf = $nodes->find(8); - - $leaf->setRelation('parent', new Category(['name' => 'stale'])); - $leaf->setRelation('children', new Collection([new Category])); - - $nodes->linkNodes(); - - $siblings = $nodes->whereIn('id', [6, 7, 9, 10])->values(); - $parent = $siblings->first()->parent; - - $this->assertTrue($leaf->relationLoaded('children')); - $this->assertTrue($leaf->children->isEmpty()); - $this->assertSame(7, $leaf->parent->getKey()); - $this->assertSame([], $leaf->parent->getRelations()); - - foreach ($siblings as $sibling) { - $this->assertSame($parent, $sibling->parent); - } - } - - public function testLinkedTreeSerializesWithoutParentChildCycles(): void - { - $tree = Category::defaultOrder()->get()->toTree(); - $decoded = json_decode($tree->toJson(), true, flags: JSON_THROW_ON_ERROR); - - $this->assertSame(1, $decoded[0]['id']); - $this->assertSame(1, $decoded[0]['children'][0]['parent']['id']); - $this->assertArrayNotHasKey('children', $decoded[0]['children'][0]['parent']); - } - - public function testFlatTreeHandlesDeepCollectionsIteratively(): void - { - $nodes = []; - - for ($id = 1; $id <= 2000; ++$id) { - $nodes[] = $this->makeCollectionNode( - $id, - $id, - 4001 - $id, - $id === 1 ? null : $id - 1, - ); - } - - $flat = (new Collection($nodes))->toFlatTree(); - - $this->assertCount(2000, $flat); - $this->assertSame(1, $flat->first()->getKey()); - $this->assertSame(2000, $flat->last()->getKey()); - } - - public function testToTreeBuildsMultipleRootNodes(): void - { - $tree = Category::withoutRoot()->get()->toTree(); - - $this->assertSame([2, 5], $tree->modelKeys()); - } - - public function testToTreeBuildsWithRootItemIdProvided(): void - { - $tree = Category::whereBetween('_lft', [8, 17])->get()->toTree(5); - - $this->assertEquals(4, count($tree)); - - $root = $tree[1]; - $this->assertEquals('samsung', $root->name); - $this->assertEquals(1, count($root->children)); - } - - public function testRetrievesNextNode(): void - { - $node = $this->findCategory('apple'); - $next = $node->nextNodes()->first(); - - $this->assertEquals('lenovo', $next->name); - } - - public function testRetrievesPrevNode(): void - { - $node = $this->findCategory('apple'); - $next = $node->getPrevNode(); - - $this->assertEquals('notebooks', $next->name); - } - - public function testWhereIsBeforeAndAfterById(): void - { - $before = Category::whereIsBefore(4) - ->defaultOrder() - ->pluck('name') - ->all(); - $after = Category::whereIsAfter(4) - ->defaultOrder() - ->pluck('name') - ->all(); - - $this->assertSame(['store', 'notebooks', 'apple'], $before); - $this->assertSame(['mobile', 'nokia', 'samsung', 'galaxy', 'sony', 'lenovo', 'store_2'], $after); - } - - public function testStructuralCoordinateLookupsRespectTheOuterSoftDeleteMode(): void - { - DB::table('categories') - ->whereIn('id', [3, 5]) - ->update(['deleted_at' => CarbonImmutable::now()]); - - $this->assertSame( - [1, 2, 3, 4], - Category::withTrashed()->whereIsBefore(5)->orderBy('id')->pluck('id')->all(), - ); - $this->assertSame( - [3], - Category::onlyTrashed()->whereIsBefore(5)->pluck('id')->all(), - ); - $this->assertSame( - [1, 5, 7], - Category::withTrashed()->whereAncestorOf(8)->orderBy('id')->pluck('id')->all(), - ); - $this->assertSame( - [5], - Category::onlyTrashed()->whereAncestorOf(8)->pluck('id')->all(), - ); - } - - public function testMultipleAppendageWorks(): void - { - $parent = $this->findCategory('mobile'); - $child = new Category(['name' => 'test']); - $subchild = new Category(['name' => 'sub']); - $sibling = new Category(['name' => 'test2']); - - $parent->appendNode($child); - $child->appendNode($subchild); - $parent->appendNode($sibling); - - $this->assertSame($parent->getKey(), $child->getParentId()); - $this->assertSame($child->getKey(), $subchild->getParentId()); - $this->assertSame($parent->getKey(), $sibling->getParentId()); - $this->assertTreeNotBroken(); - } - - public function testDefaultCategoryIsSavedAsRoot(): void - { - $node = new Category(['name' => 'test']); - $node->save(); - - $this->assertEquals(23, $node->_lft); - $this->assertTreeNotBroken(); - - $this->assertTrue($node->isRoot()); - } - - public function testExistingCategorySavedAsRoot(): void - { - $node = $this->findCategory('samsung'); - $node->saveAsRoot(); - - $this->assertSame(0, Category::findOrFail($node->getKey())->getDepth()); - $this->assertSame(1, $this->findCategory('galaxy')->getDepth()); - $this->assertTreeNotBroken(); - $this->assertTrue($node->isRoot()); - } - - public function testSavingPartiallySelectedChildAsRootHydratesItsParentage(): void - { - $node = Category::query()->select(['id'])->findOrFail(7); - - $this->assertTrue($node->saveAsRoot()); - - $node = Category::findOrFail(7); - - $this->assertNull($node->getParentId()); - $this->assertSame(0, $node->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testSavingPartiallySelectedRootAsRootDoesNotReorderIt(): void - { - $before = Category::findOrFail(1)->getBounds(); - $node = Category::query()->select(['id'])->findOrFail(1); - - $this->assertTrue($node->saveAsRoot()); - - $this->assertSame($before, Category::findOrFail(1)->getBounds()); - $this->assertTreeNotBroken(); - } - - public function testSavingNewExplicitRootAssignsValidBounds(): void - { - $node = new Category(['name' => 'explicit root']); - $node->parent_id = null; - - $this->assertTrue($node->saveAsRoot()); - - $this->assertGreaterThan(0, $node->getLft()); - $this->assertSame($node->getLft() + 1, $node->getRgt()); - $this->assertSame(0, $node->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testMakeRootAlwaysRepositionsAnExistingRoot(): void - { - $node = Category::findOrFail(1); - - $this->assertTrue($node->makeRoot()->save()); - - $this->assertSame([3, 22], $node->getBounds()); - $this->assertNull($node->getParentId()); - $this->assertTreeNotBroken(); - } - - public function testAssigningNullParentToPartialChildDoesNotLookLikeAnExistingRoot(): void - { - $node = Category::query()->select(['id'])->findOrFail(7); - - $node->parent_id = null; - $node->save(); - - $this->assertNull(Category::findOrFail(7)->getParentId()); - $this->assertSame(0, Category::findOrFail(7)->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testNodeMovesDownSeveralPositions(): void - { - $node = $this->findCategory('nokia'); - - $this->assertTrue($node->down(2)); - - $this->assertEquals($node->_lft, 15); - } - - public function testNodeMovesUpSeveralPositions(): void - { - $node = $this->findCategory('sony'); - - $this->assertTrue($node->up(2)); - - $this->assertEquals($node->_lft, 9); - } - - #[DataProvider('nonPositiveMoveAmounts')] - public function testNonPositiveSiblingMovementIsANoOp(string $method, int $amount): void - { - $node = Category::findOrFail(9); - DB::flushQueryLog(); - - $this->assertFalse($node->{$method}($amount)); - $this->assertSame([], DB::getQueryLog()); - } - - public static function nonPositiveMoveAmounts(): array - { - return [ - 'up zero' => ['up', 0], - 'up negative' => ['up', -1], - 'down zero' => ['down', 0], - 'down negative' => ['down', -1], - ]; - } - - public function testCountsTreeErrors(): void - { - $this->assertTreeNotBroken(); - } - - public function testCountsInvalidIntervals(): void - { - Category::whereKey(3)->update(['_lft' => 0]); - - $this->assertSame(1, Category::countErrors()['invalid_intervals']); - $this->assertTrue(Category::isBroken()); - } - - public function testCountsDuplicateEndpointsAndSkipsAmbiguousCrossingAnalysis(): void - { - Category::whereKey(4)->update(['_lft' => 3]); - - $errors = Category::countErrors(); - - $this->assertSame(1, $errors['duplicate_endpoints']); - $this->assertSame(0, $errors['crossing_intervals']); - } - - public function testCountsMissingEndpointRanges(): void - { - DB::table('categories')->where('id', 4)->delete(); - - $this->assertSame(1, Category::countErrors()['missing_endpoints']); - } - - public function testCountsCrossingIntervalsWithUniqueContiguousEndpoints(): void - { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'a', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'b', '_lft' => 2, '_rgt' => 5, 'parent_id' => null, 'depth' => 0], - ['id' => 3, 'name' => 'c', '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'depth' => 0], - ]); - - $errors = Category::countErrors(); - - $this->assertSame(0, $errors['invalid_intervals']); - $this->assertSame(0, $errors['duplicate_endpoints']); - $this->assertSame(0, $errors['missing_endpoints']); - $this->assertGreaterThan(0, $errors['crossing_intervals']); - } - - public function testCountsMissingAndWrongParents(): void - { - Category::whereKey(4)->update(['parent_id' => 24]); - Category::whereKey(8)->update(['parent_id' => 2]); - - $errors = Category::countErrors(); - - $this->assertSame(1, $errors['missing_parent']); - $this->assertSame(1, $errors['wrong_parent']); - } - - public function testWrongParentAndWrongDepthCanBeReportedTogether(): void - { - Category::whereKey(8)->update(['parent_id' => 5]); - - $errors = Category::countErrors(); - - $this->assertSame(1, $errors['wrong_parent']); - $this->assertSame(1, $errors['wrong_depth']); - } - - public function testIsBrokenShortCircuitsBeforeExpensiveChecks(): void - { - Category::whereKey(3)->update(['_lft' => 0]); - DB::flushQueryLog(); - - $this->assertTrue(Category::isBroken()); - $this->assertCount(1, DB::getQueryLog()); - } - - public function testCreatesNode(): void - { - $node = Category::create(['name' => 'test']); - - $this->assertEquals(23, $node->getLft()); - } - - public function testCreatesViaRelationship(): void - { - $node = $this->findCategory('apple'); - - $child = $node->children()->create(['name' => 'test']); - - $this->assertSame($node->getKey(), $child->getParentId()); - $this->assertSame(3, $child->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testCreatesTree(): void - { - $node = Category::create( - [ - 'name' => 'test', - 'children' => [ - ['name' => 'test2'], - ['name' => 'test3'], - ], - ] - ); - - $this->assertTreeNotBroken(); - - $this->assertTrue(isset($node->children)); - $this->assertSame($node->getKey(), $node->children[0]->parent->getKey()); - $this->assertSame($node->children[0]->parent, $node->children[1]->parent); - $this->assertSame([], $node->children[0]->parent->getRelations()); - $this->assertSame($node->getBounds(), $node->children[0]->parent->getBounds()); - json_decode($node->toJson(), true, flags: JSON_THROW_ON_ERROR); - - $node = $this->findCategory('test'); - - $this->assertCount(2, $node->children); - $this->assertEquals('test2', $node->children[0]->name); - } - - public function testRecursiveCreateUsesOnePreparationReadPerParticipantAndOneFinalRootRefresh(): void - { - DB::flushQueryLog(); - - Category::create([ - 'name' => 'test', - 'children' => [ - ['name' => 'one'], - ['name' => 'two'], - ['name' => 'three'], - ['name' => 'four'], - ], - ]); - - $this->assertSame(13, $this->countStructuralIdentityReloads()); - $this->assertTreeNotBroken(); - } - - public function testDescendantsOfNonExistingNode(): void - { - $node = new Category; - - $this->assertTrue($node->getDescendants()->isEmpty()); - } - - #[DataProvider('nodeObjectPositionalQueries')] - public function testNodeObjectPositionalQueriesRequireLoadedBounds(string $method): void - { - $node = Category::query()->select(['id'])->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\Category] must have loaded bounds.', - ); - - Category::query()->{$method}($node)->get(); - } - - public static function nodeObjectPositionalQueries(): array - { - return [ - 'ancestors' => ['whereAncestorOf'], - 'descendants' => ['whereDescendantOf'], - 'before' => ['whereIsBefore'], - 'after' => ['whereIsAfter'], - ]; - } - - #[DataProvider('nodeObjectPositionalQueries')] - public function testNodeObjectPositionalQueriesRejectModelsWithoutNestedSet(string $method): void - { - $node = new class extends Model { - }; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageIs(sprintf( - 'Model [%s] must be node.', - $node::class, - )); - - Category::query()->{$method}($node)->get(); - } - - #[DataProvider('directNodePositionQueries')] - public function testDirectNodePositionQueriesRequireLoadedBounds(string $method): void - { - $node = Category::query()->select(['id', 'parent_id'])->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\Category] must have loaded bounds.', - ); - - $node->{$method}()->get(); - } - - public static function directNodePositionQueries(): array - { - return [ - 'next nodes' => ['nextNodes'], - 'previous nodes' => ['prevNodes'], - 'next siblings' => ['nextSiblings'], - 'previous siblings' => ['prevSiblings'], - ]; - } - - public function testDirectSiblingQueriesRequireLoadedParentage(): void - { - $node = Category::query() - ->select(['id', '_lft', '_rgt', 'depth']) - ->findOrFail(7); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\Category] must have a loaded parent.', - ); - - $node->nextSiblings()->get(); - } - - public function testNodeObjectPositionalQueriesRejectAnotherStore(): void - { - $node = Category::findOrFail(7); - $node->setTable('other_categories'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\Category] uses connection [testing] and table [other_categories], but query model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] uses connection [testing] and table [categories].', - ); - - Category::query()->whereDescendantOf($node)->get(); - } - - public function testNodeObjectPositionalQueriesAcceptSameStoreModelAliases(): void - { - $node = EventedCategoryModel::findOrFail(2); - - $this->assertSame( - [3, 4], - Category::query() - ->whereDescendantOf($node) - ->orderBy('id') - ->pluck('id') - ->all(), - ); - } - - public function testWhereDescendantsOf(): void - { - $this->expectException(ModelNotFoundException::class); - $this->expectExceptionMessageIs( - 'No query results for model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] 124', - ); - - Category::whereDescendantOf(124)->get(); - } - - public function testAncestorsByNode(): void - { - $category = $this->findCategory('apple'); - $ancestors = $this->getAll(Category::whereAncestorOf($category)->pluck('id')); - - $this->assertEquals([1, 2], $ancestors); - } - - public function testAncestorsByNodeWithoutSelectedKey(): void - { - $category = Category::query() - ->select(['_lft', '_rgt']) - ->where('name', '=', 'apple') - ->firstOrFail(); - - $this->assertNull($category->getKey()); - $this->assertSame( - [1, 2], - Category::whereAncestorOf($category)->orderBy('id')->pluck('id')->all(), - ); - $this->assertSame( - [1, 2, 3], - Category::whereAncestorOf($category, true)->orderBy('id')->pluck('id')->all(), - ); - - $root = Category::query() - ->select(['_lft', '_rgt']) - ->findOrFail(1); - - $this->assertTrue(Category::whereAncestorOf($root)->get()->isEmpty()); - $this->assertSame([1], Category::whereAncestorOf($root, true)->pluck('id')->all()); - } - - #[DataProvider('eagerRelatedProjections')] - public function testEagerMatchingUsesOnlyTruthfulRelatedProjections( - string $relation, - array $parentIds, - array $columns, - array $expected, - ?string $requiredColumn, - ): void { - $categories = Category::query()->whereIn('id', $parentIds)->get(); - - if ($requiredColumn !== null) { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation eager load for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\Category] requires the [{$requiredColumn}] column.", - ); - } - - $categories->load([ - $relation => fn ($query) => $query->select($columns), - ]); - - foreach ($expected as $parentId => $relatedIds) { - $this->assertSame( - $relatedIds, - $categories->find($parentId) - ->getRelation($relation) - ->pluck('id') - ->sort() - ->values() - ->all(), - ); - } - } - - public static function eagerRelatedProjections(): array - { - return [ - 'ancestor without left bound' => [ - 'ancestors', - [3], - ['id', '_rgt'], - [], - '_lft', - ], - 'ancestors without right bound across multiple parents' => [ - 'ancestors', - [3, 8], - ['id', '_lft'], - [], - '_rgt', - ], - 'descendants without left bound across multiple parents' => [ - 'descendants', - [2, 5], - ['id', '_rgt'], - [], - '_lft', - ], - 'descendants need no related right bound' => [ - 'descendants', - [2, 5], - ['id', '_lft'], - [2 => [3, 4], 5 => [6, 7, 8, 9, 10]], - null, - ], - ]; - } - - public function testDescendantsByNode(): void - { - $category = $this->findCategory('notebooks'); - $res = $this->getAll(Category::whereDescendantOf($category)->pluck('id')); - - $this->assertEquals([3, 4], $res); - } - - public function testMultipleDeletionsDoNotBreakTree(): void - { - $category = $this->findCategory('mobile'); - - foreach ($category->children()->take(2)->get() as $child) { - $child->forceDelete(); - } - - $this->assertSame(0, Category::whereIn('id', [6, 7, 8])->count()); - $this->assertSame(2, Category::whereIn('id', [9, 10])->count()); - $this->assertTreeNotBroken(); - } - - public function testTreeIsFixed(): void - { - Category::where('id', '=', 5)->update(['_lft' => 14]); - Category::where('id', '=', 8)->update(['parent_id' => 2]); - Category::where('id', '=', 11)->update(['_lft' => 20]); - Category::where('id', '=', 2)->update(['parent_id' => 24]); - - $fixed = Category::fixTree(); - - $this->assertTrue($fixed > 0); - $this->assertTreeNotBroken(); - - $node = Category::find(8); - - $this->assertEquals(2, $node->getParentId()); - - $node = Category::find(2); - - $this->assertEquals(null, $node->getParentId()); - } - - public function testFixTreePromotesOrphanedBranchesToRoots(): void - { - Category::whereKey(2)->update(['parent_id' => 24]); - - Category::fixTree(); - - $node = Category::find(2); - - $this->assertNull($node->getParentId()); - $this->assertSame(0, $node->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testFixTreePersistsExpandedRootBounds(): void - { - $root = Category::findOrFail(1); - - Category::whereKey(11)->update(['parent_id' => 1]); - Category::fixTree(); - - $root->refreshNode(); - - $this->assertSame(22, $root->getRgt()); - $this->assertTreeNotBroken(); - } - - public function testFixTreeIgnoresVisibilityGlobalScopes(): void - { - $this->assertNull(GloballyScopedCategoryModel::find(8)); - - Category::whereKey(8)->update(['_lft' => 999]); - - GloballyScopedCategoryModel::fixTree(); - - $this->assertTreeNotBroken(); - $this->assertTrue(Category::find(8)->isDescendantOf(Category::find(7))); - } - - public function testFixTreeSelectsExplicitObserverColumns(): void - { - $names = []; - - Category::saving(function (Category $model) use (&$names): void { - $names[] = $model->name; - }); - - Category::whereKey(8)->update(['_lft' => 11]); - - Category::fixTree(extraColumns: ['name']); - - $this->assertNotEmpty($names); - $this->assertNotContains(null, $names); - $this->assertTreeNotBroken(); - } - - public function testFixTreeVetoRollsBackEarlierRepairWrites(): void - { - Category::whereKey(2)->update(['parent_id' => null]); - - $before = DB::table('categories') - ->orderBy('id') - ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->map(fn (object $row): array => (array) $row) - ->all(); - $saves = 0; - $vetoedKey = null; - - Category::saving(function (Category $model) use (&$saves, &$vetoedKey): ?bool { - if (++$saves !== 2) { - return null; - } - - $vetoedKey = $model->getKey(); - - return false; - }); - - try { - DB::transaction(fn (): int => Category::fixTree()); - $this->fail('Expected the repair veto to propagate.'); - } catch (LogicException $exception) { - $this->assertSame( - sprintf( - 'Saving nested set node [%s] with key [%s] during repair was vetoed.', - Category::class, - $vetoedKey, - ), - $exception->getMessage(), - ); - } - - $this->assertSame( - $before, - DB::table('categories') - ->orderBy('id') - ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->map(fn (object $row): array => (array) $row) - ->all(), - ); - } - - public function testFixTreeRepairsDeepParentChainsIteratively(): void - { - DB::table('categories')->delete(); - - $rows = []; - $count = 200; - - for ($id = 1; $id <= $count; ++$id) { - $rows[] = [ - 'id' => $id, - 'name' => 'node ' . $id, - '_lft' => 0, - '_rgt' => 0, - 'parent_id' => $id === 1 ? null : $id - 1, - 'depth' => 0, - ]; - } - - DB::table('categories')->insert($rows); - - Category::fixTree(); - - $root = Category::find(1); - $leaf = Category::find($count); - - $this->assertSame([1, $count * 2], $root->getBounds()); - $this->assertSame([$count, $count + 1], $leaf->getBounds()); - $this->assertSame($count - 1, $leaf->getDepth()); - $this->assertTreeNotBroken(); - } - - #[DataProvider('invalidSubtreeParents')] - public function testFixSubtreePromotesInvalidBranchesToChildrenOfTheSuppliedRoot( - int|string|null $parentId, - ): void { - Category::whereKey(6)->update(['parent_id' => $parentId]); - - Category::fixSubtree($root = Category::find(5)); - - $node = Category::find(6); - - $this->assertSame($root->getKey(), $node->getParentId()); - $this->assertSame($root->getDepth() + 1, $node->getDepth()); - $this->assertTreeNotBroken(); - } - - public static function invalidSubtreeParents(): array - { - return [ - 'missing parent' => [99], - 'parent outside subtree' => [1], - 'null parent' => [null], - ]; - } - - public function testFixSubtreeBreaksParentCyclesWithoutCorruptingIntervals(): void - { - Category::whereKey(7)->update(['parent_id' => 8]); - Category::whereKey(8)->update(['parent_id' => 7]); - - Category::fixSubtree(Category::find(5)); - - $this->assertSame(5, Category::find(7)->getParentId()); - $this->assertSame(7, Category::find(8)->getParentId()); - $this->assertTreeNotBroken(); - } - - public function testFixSubtreePersistsRowsShiftedByItsGapUpdate(): void - { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'first', '_lft' => 2, '_rgt' => 3, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'second', '_lft' => 4, '_rgt' => 5, 'parent_id' => 1, 'depth' => 1], - ]); - - Category::fixSubtree(Category::findOrFail(1)); - - $this->assertSame([1, 6], Category::findOrFail(1)->getBounds()); - $this->assertSame([2, 3], Category::findOrFail(2)->getBounds()); - $this->assertSame([4, 5], Category::findOrFail(3)->getBounds()); - $this->assertTreeNotBroken(); - } - - public function testFixSubtreeRejectsParentageOutsideItsStoredBounds(): void - { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'inside', '_lft' => 2, '_rgt' => 3, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'outside', '_lft' => 6, '_rgt' => 7, 'parent_id' => 1, 'depth' => 1], - ]); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set subtree for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] with key [1] has parentage that crosses its stored bounds.', - ); - - Category::fixSubtree(Category::findOrFail(1)); - } - - #[DataProvider('subtreeOperations')] - public function testSubtreeOperationsHydratePartialRootFromThePersistedRow(string $operation): void - { - $root = Category::query() - ->select(['id', 'parent_id']) - ->findOrFail(5); - $root->setRelation('children', new Collection([new Category])); - DB::flushQueryLog(); - - if ($operation === 'fix') { - Category::fixSubtree($root); - } else { - Category::rebuildSubtree($root, []); - } - - $this->assertSame([8, 19], $root->getBounds()); - $this->assertSame(1, $root->getDepth()); - $this->assertFalse($root->relationLoaded('children')); - $this->assertSame(1, $this->countStructuralIdentityReloads()); - $this->assertTreeNotBroken(); - } - - public static function subtreeOperations(): array - { - return [ - 'repair' => ['fix'], - 'rebuild' => ['rebuild'], - ]; - } - - #[DataProvider('invalidRepairRoots')] - public function testFixSubtreeRejectsUnpersistedOrKeylessRootBeforeWriting( - string $rootState, - string $expectedMessage, - ): void { - $columns = ['id', 'name', '_lft', '_rgt', 'parent_id', 'depth']; - $before = DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(fn (object $row): array => (array) $row) - ->all(); - - if ($rootState === 'unpersisted') { - $root = new Category; - $root->setRawAttributes(Category::findOrFail(5)->getAttributes()); - } else { - $root = Category::query() - ->select(['name', '_lft', '_rgt', 'parent_id', 'depth']) - ->findOrFail(5); - } - - try { - Category::fixSubtree($root); - $this->fail('Expected the invalid subtree root to be rejected.'); - } catch (LogicException $exception) { - $this->assertSame($expectedMessage, $exception->getMessage()); - } - - $this->assertSame( - $before, - DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(fn (object $row): array => (array) $row) - ->all(), - ); - } - - public static function invalidRepairRoots(): array - { - return [ - 'unpersisted root' => [ - 'unpersisted', - 'Nested set subtree repair root [Hypervel\Tests\NestedSet\Fixtures\Models\Category] with key [5] must be persisted.', - ], - 'keyless root' => [ - 'keyless', - 'Nested set model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] requires the [id] column to be selected.', - ], - ]; - } - - public function testSubtreeOperationsRejectRootsFromAnotherStore(): void - { - $root = Category::findOrFail(5); - $root->setTable('other_categories'); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set subtree repair root [Hypervel\Tests\NestedSet\Fixtures\Models\Category] uses connection [testing] and table [other_categories], but query model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] uses connection [testing] and table [categories].', - ); - - Category::fixSubtree($root); - } - - #[DataProvider('invalidSubtreeRootBounds')] - public function testSubtreeOperationsRejectInvalidStoredRootBounds( - string $operation, - int $lft, - int $rgt, - ): void { - DB::table('categories')->where('id', '=', 5)->update([ - '_lft' => $lft, - '_rgt' => $rgt, - ]); - $root = Category::findOrFail(5); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set subtree for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] with key [5] has invalid stored bounds.', - ); - - $operation === 'fix' - ? Category::fixSubtree($root) - : Category::rebuildSubtree($root, []); - } - - public static function invalidSubtreeRootBounds(): array - { - return [ - 'repair with inverted bounds' => ['fix', 9, 8], - 'rebuild with inverted bounds' => ['rebuild', 9, 8], - 'repair with non-positive bounds' => ['fix', 0, 1], - 'rebuild with non-positive bounds' => ['rebuild', 0, 1], - ]; - } - - public function testSubtreeRepairRejectsAMissingRootThroughModelNotFound(): void - { - $root = Category::findOrFail(5); - DB::table('categories')->where('id', '=', 5)->delete(); - - $this->expectException(ModelNotFoundException::class); - - Category::fixSubtree($root); - } - - public function testSubtreeRepairUsesTheCurrentPersistedRootCoordinates(): void - { - $root = Category::findOrFail(5); - $root->setLft(2)->setRgt(3)->setDepth(99); - - Category::fixSubtree($root); - - $this->assertSame([8, 19], $root->getBounds()); - $this->assertSame(1, $root->getDepth()); - $this->assertTreeNotBroken(); - } - - public function testSubtreeIsFixed(): void - { - Category::where('id', '=', 8)->update(['_lft' => 11]); - - $fixed = Category::fixSubtree(Category::find(5)); - $this->assertEquals($fixed, 1); - $this->assertTreeNotBroken(); - $this->assertEquals(Category::find(8)->getLft(), 12); - } - - public function testParentIdDirtiness(): void - { - $node = $this->findCategory('apple'); - $node->parent_id = 5; - - $this->assertTrue($node->isDirty('parent_id')); - - $node = $this->findCategory('apple'); - $node->parent_id = null; - - $this->assertTrue($node->isDirty('parent_id')); - } - - public function testIsDirtyMovement(): void - { - $node = $this->findCategory('apple'); - $otherNode = $this->findCategory('samsung'); - - $this->assertFalse($node->isDirty()); - - $node->afterNode($otherNode); - - $this->assertTrue($node->isDirty()); - - $node = $this->findCategory('apple'); - $otherNode = $this->findCategory('samsung'); - - $this->assertFalse($node->isDirty()); - - $node->appendToNode($otherNode); - - $this->assertTrue($node->isDirty()); - } - - public function testRootNodesMoving(): void - { - $node = $this->findCategory('store'); - $node->down(); - - $this->assertEquals(3, $node->getLft()); - } - - public function testDescendantsRelation(): void - { - $node = $this->findCategory('notebooks'); - $result = $node->descendants; - - $this->assertEquals(2, $result->count()); - $this->assertEquals('apple', $result->first()->name); - } - - public function testDescendantsEagerlyLoaded(): void - { - $nodes = Category::whereIn('id', [2, 5])->get(); - - $nodes->load('descendants'); - - $this->assertEquals(2, $nodes->count()); - $this->assertTrue($nodes->first()->relationLoaded('descendants')); - } - - public function testDescendantEagerMatchingPreservesCustomOrder(): void - { - $nodes = Category::whereIn('id', [2, 5])->get(); - - $nodes->load(['descendants' => fn ($query) => $query->orderBy('name')]); + $uuid = '018f3a2b-0000-7000-8000-000000000001'; + $ulid = '01J9ZTR3WQ4Z78F7N4MFSRMK7H'; + $nodes = new Collection([ + $uuidRoot = $this->makeCollectionNode( + $uuid, + 1, + 4, + null, + new StringKeyCategoryModel, + ), + $this->makeCollectionNode( + '018f3a2b-0000-7000-8000-000000000002', + 2, + 3, + $uuid, + new StringKeyCategoryModel, + ), + $ulidRoot = $this->makeCollectionNode( + $ulid, + 5, + 8, + null, + new StringKeyCategoryModel, + ), + $this->makeCollectionNode( + '01J9ZTR3WQ4Z78F7N4MFSRMK7J', + 6, + 7, + $ulid, + new StringKeyCategoryModel, + ), + ]); - $this->assertEquals( - ['apple', 'lenovo'], - $this->getAll($nodes->find(2)->descendants->pluck('name')), + $this->assertSame( + ['018f3a2b-0000-7000-8000-000000000002'], + $nodes->toTree($uuidRoot)->modelKeys(), ); - $this->assertEquals( - ['galaxy', 'lenovo', 'nokia', 'samsung', 'sony'], - $this->getAll($nodes->find(5)->descendants->pluck('name')), + $this->assertSame( + ['01J9ZTR3WQ4Z78F7N4MFSRMK7J'], + $nodes->toTree($ulid)->modelKeys(), ); } - public function testDescendantsRelationQuery(): void - { - $nodes = Category::has('descendants')->whereIn('id', [2, 3])->get(); - - $this->assertEquals(1, $nodes->count()); - $this->assertEquals(2, $nodes->first()->getKey()); - - $nodes = Category::has('descendants', '>', 2)->get(); - - $this->assertEquals(2, $nodes->count()); - $this->assertEquals(1, $nodes[0]->getKey()); - $this->assertEquals(5, $nodes[1]->getKey()); - } - - public function testParentRelationQuery(): void + public function testNodeObjectPositionalQueriesAcceptSameStoreModelAliases(): void { - $nodes = Category::has('parent')->whereIn('id', [1, 2]); + $node = EventedCategoryModel::findOrFail(2); - $this->assertEquals(1, $nodes->count()); - $this->assertEquals(2, $nodes->first()->getKey()); + $this->assertSame( + [3, 4], + Category::query() + ->whereDescendantOf($node) + ->orderBy('id') + ->pluck('id') + ->all(), + ); } - public function testRebuildTree(): void + public function testColumnPatchRendersZeroHeightAsAddition(): void { - $root = Category::findOrFail(1); - - $fixed = Category::rebuildTree([ - [ - 'id' => 1, - 'children' => [ - ['id' => 10], - [ - 'id' => 3, - 'name' => 'apple v2', - 'parent_id' => 999, - '_lft' => 999, - '_rgt' => 1000, - 'depth' => 99, - 'children' => [['name' => 'new node']], - ], - ['id' => 2], - ], - ], - ]); - - $this->assertTrue($fixed > 0); - $this->assertTreeNotBroken(); - - $root->refreshNode(); - - $this->assertSame(Category::findOrFail(1)->getRgt(), $root->getRgt()); - - $node = Category::find(3); - - $this->assertEquals(1, $node->getParentId()); - $this->assertEquals('apple v2', $node->name); - $this->assertEquals(4, $node->getLft()); - $this->assertSame(1, $node->getDepth()); - - $node = $this->findCategory('new node'); - - $this->assertNotNull($node); - $this->assertEquals(3, $node->getParentId()); + $this->assertSame( + 'case when "_lft" >= 5 then "_lft" + 0 else "_lft" end', + $this->renderColumnPatch('"_lft"', ['height' => 0, 'cut' => 5]), + ); } - public function testUnchangedRebuildDoesNotWriteOrReloadNodeIdentity(): void + public function testColumnPatchRendersZeroDistanceAsAddition(): void { - $this->resetRebuildQueryFixture(); - DB::flushQueryLog(); - - Category::rebuildTree([ - [ - 'id' => 1, - 'children' => [ - ['id' => 2], - ['id' => 3, 'children' => [['id' => 4]]], - ], - ], - ]); - - $queries = array_column(DB::getQueryLog(), 'query'); - - $this->assertCount(1, $queries); - $this->assertMatchesRegularExpression('/^select \* from /i', $queries[0]); + $this->assertSame( + 'case when "_lft" between 1 and 2 then "_lft" + 0 ' + . 'when "_lft" between 1 and 2 then "_lft" + 0 else "_lft" end', + $this->renderColumnPatch('"_lft"', [ + 'distance' => 0, + 'height' => 0, + 'lft' => 1, + 'rgt' => 2, + 'from' => 1, + 'to' => 2, + ]), + ); } - public function testChangedRebuildDoesNotReloadNodeIdentityPerRow(): void + /** + * Render a column patch for the given parameters. + */ + private function renderColumnPatch(string $column, array $params): string { - $this->resetRebuildQueryFixture(); - DB::flushQueryLog(); - - Category::rebuildTree([ - [ - 'id' => 1, - 'children' => [ - ['id' => 4], - ['id' => 2], - ['id' => 3], - ], - ], - ]); - - $queries = array_column(DB::getQueryLog(), 'query'); + $builder = Category::query(); + $expression = (new ReflectionMethod(QueryBuilder::class, 'columnPatch'))->invoke($builder, $column, $params); - $this->assertFalse(collect($queries)->contains( - static fn (string $query): bool => preg_match( - '/^select .*_lft.*_rgt.*depth.*parent_id.*limit 1$/i', - $query, - ) === 1, - )); - $this->assertSame(1, Category::findOrFail(4)->getParentId()); - $this->assertTreeNotBroken(); + return (string) $expression->getValue($builder->getQuery()->getGrammar()); } - public function testRebuildSubtree(): void + public function testNodeDataIsRobustAgainstSelectAddingGlobalScope(): void { - $root = Category::with('children')->findOrFail(7); - $fixed = Category::rebuildSubtree($root, [ - ['name' => 'new node'], - ['id' => '8'], - ]); - - $this->assertTrue($fixed > 0); - $this->assertTrue($root->hasMoved()); - $this->assertFalse($root->relationLoaded('children')); - $this->assertTreeNotBroken(); - - $node = $this->findCategory('new node'); + $data = SelectScopedCategoryModel::query()->getNodeData(3); - $this->assertNotNull($node); - $this->assertEquals($node->getLft(), 12); + $this->assertSame(['_lft', '_rgt', 'depth'], array_keys($data)); + $this->assertEquals([3, 4, 2], array_values($data)); } - public function testRebuildSubtreePersistsRowsShiftedByItsGapUpdate(): void + public function testMovingNodeWithSelectAddingGlobalScope(): void { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'first', '_lft' => 2, '_rgt' => 3, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'second', '_lft' => 4, '_rgt' => 5, 'parent_id' => 1, 'depth' => 1], - ]); + $node = SelectScopedCategoryModel::query()->findOrFail(3); - Category::rebuildSubtree(Category::findOrFail(1), [ - ['id' => 2], - ['id' => 3], - ]); + $this->assertTrue($node->down()); - $this->assertSame([1, 6], Category::findOrFail(1)->getBounds()); - $this->assertSame([2, 3], Category::findOrFail(2)->getBounds()); - $this->assertSame([4, 5], Category::findOrFail(3)->getBounds()); $this->assertTreeNotBroken(); + $this->assertSame(5, $node->fresh()->getLft()); } - public function testRebuildSubtreeRejectsParentageOutsideItsStoredBounds(): void + public function testCountErrorsIgnoresGlobalScope(): void { - DB::table('categories')->delete(); - DB::table('categories')->insert([ - ['id' => 1, 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], - ['id' => 2, 'name' => 'inside', '_lft' => 2, '_rgt' => 3, 'parent_id' => 1, 'depth' => 1], - ['id' => 3, 'name' => 'outside', '_lft' => 6, '_rgt' => 7, 'parent_id' => 1, 'depth' => 1], - ]); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set subtree for [Hypervel\Tests\NestedSet\Fixtures\Models\Category] with key [1] has parentage that crosses its stored bounds.', - ); - - Category::rebuildSubtree(Category::findOrFail(1), []); - } - - public function testRebuildSubtreeRepairsAnExistingOrphanedBranch(): void - { - Category::whereKey(8)->update(['parent_id' => 99]); + $this->assertNull(GloballyScopedCategoryModel::find(8)); + $this->assertSame(Category::countErrors(), GloballyScopedCategoryModel::countErrors()); + $this->assertSame(0, GloballyScopedCategoryModel::getTotalErrors()); + $this->assertFalse(GloballyScopedCategoryModel::isBroken()); - Category::rebuildSubtree($root = Category::find(7), []); + Category::whereKey(8)->update(['_lft' => 999]); - $node = Category::find(8); + $errors = GloballyScopedCategoryModel::countErrors(); - $this->assertSame($root->getKey(), $node->getParentId()); - $this->assertSame($root->getDepth() + 1, $node->getDepth()); - $this->assertTreeNotBroken(); + $this->assertSame(1, $errors['invalid_intervals']); + $this->assertSame(Category::countErrors(), $errors); + $this->assertTrue(GloballyScopedCategoryModel::isBroken()); } - public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void + public function testFixTreeIgnoresGlobalScope(): void { - $columns = ['id', 'name', '_lft', '_rgt', 'parent_id', 'depth']; - $before = DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(fn (object $row): array => (array) $row) - ->all(); - $saves = 0; - $vetoedKey = null; - - Category::saving(function (Category $model) use (&$saves, &$vetoedKey): ?bool { - if (++$saves !== 3) { - return null; - } - - $vetoedKey = $model->getKey(); - - return false; - }); - - try { - DB::transaction(fn (): int => Category::rebuildTree([ - [ - 'id' => 1, - 'name' => 'updated store', - 'children' => [ - ['id' => 11, 'name' => 'updated second store'], - ], - ], - ['id' => 2, 'name' => 'updated notebooks'], - ])); - $this->fail('Expected the rebuild veto to propagate.'); - } catch (LogicException $exception) { - $this->assertSame( - sprintf( - 'Saving nested set node [%s] with key [%s] during repair was vetoed.', - Category::class, - $vetoedKey, - ), - $exception->getMessage(), - ); - } + // Repair sees the hidden galaxy, so a healthy tree needs no changes. + $this->assertSame(0, GloballyScopedCategoryModel::fixTree()); + $this->assertSame(0, GloballyScopedCategoryModel::fixSubtree(GloballyScopedCategoryModel::findOrFail(7))); - $this->assertSame( - $before, - DB::table('categories') - ->orderBy('id') - ->get($columns) - ->map(fn (object $row): array => (array) $row) - ->all(), - ); - } - - public function testRebuildTreeWithDeletion(): void - { - Category::rebuildTree([['name' => 'all deleted']], true); + Category::whereKey(8)->update(['_lft' => 999]); + $this->assertGreaterThan(0, GloballyScopedCategoryModel::fixTree()); $this->assertTreeNotBroken(); - - $nodes = Category::get(); - - $this->assertEquals(1, $nodes->count()); - $this->assertEquals('all deleted', $nodes->first()->name); - - $nodes = Category::withTrashed()->get(); - - $this->assertTrue($nodes->count() > 1); + $this->assertTrue(Category::find(8)->isDescendantOf(Category::find(7))); } +} - public function testRebuildFailsWithInvalidPK(): void - { - $this->expectException(ModelNotFoundException::class); - $this->expectExceptionMessageIs( - 'No query results for model [Hypervel\Tests\NestedSet\Fixtures\Models\Category] 24', - ); +class CustomColumnCategoryModel extends Model +{ + use HasNode; - Category::rebuildTree([['id' => 24]]); - } + public bool $timestamps = false; - public function testFlatTree(): void - { - $node = $this->findCategory('mobile'); - $tree = $node->descendants()->orderBy('name')->get()->toFlatTree(); + protected ?string $table = 'custom_column_categories'; - $this->assertCount(5, $tree); - $this->assertEquals('samsung', $tree[2]->name); - $this->assertEquals('galaxy', $tree[3]->name); - } + protected array $fillable = ['name', 'ancestor_id']; - public function testWhereIsLeaf(): void + /** + * Get the lft key name. + */ + public function getLftName(): string { - $categories = Category::leaves(); - - $this->assertEquals(7, $categories->count()); - $this->assertEquals('apple', $categories->first()->name); - $this->assertTrue($categories->first()->isLeaf()); - - $category = Category::whereIsRoot()->first(); - - $this->assertFalse($category->isLeaf()); + return 'lft'; } - public function testEagerLoadAncestors(): void + /** + * Get the rgt key name. + */ + public function getRgtName(): string { - $queryLogCount = count(DB::getQueryLog()); - $categories = Category::with('ancestors')->orderBy('name')->get(); - - $this->assertEquals($queryLogCount + 2, count(DB::getQueryLog())); - - $expectedShape = [ - 'apple (3)}' => 'store (1) > notebooks (2)', - 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', - 'lenovo (4)}' => 'store (1) > notebooks (2)', - 'lenovo (10)}' => 'store (1) > mobile (5)', - 'mobile (5)}' => 'store (1)', - 'nokia (6)}' => 'store (1) > mobile (5)', - 'notebooks (2)}' => 'store (1)', - 'samsung (7)}' => 'store (1) > mobile (5)', - 'sony (9)}' => 'store (1) > mobile (5)', - 'store (1)}' => '', - 'store_2 (11)}' => '', - ]; - - $output = []; - - foreach ($categories as $category) { - $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() - ? implode(' > ', $category->ancestors->map(function ($cat) { - return "{$cat->name} ({$cat->id})"; - })->toArray()) - : ''; - } - - $this->assertEquals($expectedShape, $output); + return 'rgt'; } - public function testLazyLoadAncestors(): void + /** + * Get the depth column name. + */ + public function getDepthName(): string { - $queryLogCount = count(DB::getQueryLog()); - $categories = Category::orderBy('name')->get(); - - $this->assertEquals($queryLogCount + 1, count(DB::getQueryLog())); - - $expectedShape = [ - 'apple (3)}' => 'store (1) > notebooks (2)', - 'galaxy (8)}' => 'store (1) > mobile (5) > samsung (7)', - 'lenovo (4)}' => 'store (1) > notebooks (2)', - 'lenovo (10)}' => 'store (1) > mobile (5)', - 'mobile (5)}' => 'store (1)', - 'nokia (6)}' => 'store (1) > mobile (5)', - 'notebooks (2)}' => 'store (1)', - 'samsung (7)}' => 'store (1) > mobile (5)', - 'sony (9)}' => 'store (1) > mobile (5)', - 'store (1)}' => '', - 'store_2 (11)}' => '', - ]; - - $output = []; - - foreach ($categories as $category) { - $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() - ? implode(' > ', $category->ancestors->map(function ($cat) { - return "{$cat->name} ({$cat->id})"; - })->toArray()) - : ''; - } - - // assert that there is number of original query + 1 + number of rows to fulfill the relation - $this->assertEquals($queryLogCount + 12, count(DB::getQueryLog())); - - $this->assertEquals($expectedShape, $output); + return 'level'; } - public function testWhereHasCountQueryForAncestors(): void + /** + * Get the parent ID column name. + */ + public function getParentIdName(): string { - $categories = $this->getAll(Category::has('ancestors', '>', 2)->pluck('name')); - - $this->assertEquals(['galaxy'], $categories); - - $categories = $this->getAll(Category::whereHas('ancestors', function ($query) { - $query->where('id', 5); - })->pluck('name')); - - $this->assertEquals(['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], $categories); + return 'ancestor_id'; } - public function testExistenceQueriesRetainTheConnectionWithoutReplicatingModels(): void + /** + * Append the node to the assigned parent. + */ + public function setAncestorIdAttribute(int|string|null $value): void { - $replicatedModels = []; - $connection = DB::getDefaultConnection(); - - config([ - 'database.connections.nested_set_relation' => config( - "database.connections.{$connection}", - ), - ]); - - Category::replicating(function (Category $model) use (&$replicatedModels): void { - $replicatedModels[] = $model; - }); - - $parent = (new Category)->setConnection('nested_set_relation'); - $relation = $parent->descendants(); - $query = $relation->getRelationExistenceQuery( - $relation->getQuery(), - $parent->newQuery(), - ); - - $this->assertSame([], $replicatedModels); - $this->assertSame($parent->getConnectionName(), $query->getModel()->getConnectionName()); + $this->setParentIdAttribute($value); } +} - public function testNestedWhereHasCorrelatesAgainstTheImmediatelyEnclosingRelation(): void - { - $this->assertSame( - [1, 5], - Category::whereHas( - 'descendants', - fn ($query) => $query->whereHas('descendants'), - )->orderBy('id')->pluck('id')->all(), - ); - - $this->assertSame( - [1, 5, 7], - Category::whereHas( - 'descendants', - fn ($query) => $query->whereHas( - 'ancestors', - fn ($query) => $query->where('name', 'samsung'), - ), - )->orderBy('id')->pluck('id')->all(), - ); - } +class EventedCategoryModel extends Category +{ + protected ?string $table = 'categories'; - public function testAncestorsAreOrderedFromRootToParent(): void + /** + * Determine whether descendant model events should be fired during deletion and restoration. + */ + protected function shouldFireDescendantEvents(): bool { - $node = Category::with('ancestors')->findOrFail(8); - - $this->assertEquals( - ['store', 'mobile', 'samsung'], - $this->getAll($node->ancestors->pluck('name')), - ); - $this->assertStringContainsString( - 'order by', - strtolower($node->ancestors()->toSql()), - ); + return true; } - public function testReplication(): void + /** + * Get the number of descendants loaded per evented deletion or restoration chunk. + */ + protected function getDescendantChunkSize(): int { - $category = $this->findCategory('nokia'); - $category = $category->replicate(); - $category->save(); - $category->refreshNode(); - - $this->assertNull($category->getParentId()); + return 2; + } +} - $category = $this->findCategory('nokia'); - $category = $category->replicate(); - $category->parent_id = 1; - $category->save(); +class ConstrainedCategoryModel extends Model +{ + use HasNode; - $category->refreshNode(); + public bool $timestamps = false; - $this->assertEquals(1, $category->getParentId()); - } + protected ?string $table = 'constrained_categories'; - protected function getAll(array|BaseCollection $items): array - { - return is_array($items) ? $items : $items->all(); - } + protected array $fillable = ['name']; +} - private function countStructuralIdentityReloads(): int +class EventedConstrainedCategoryModel extends ConstrainedCategoryModel +{ + /** + * Determine whether descendant model events should be fired during deletion and restoration. + */ + protected function shouldFireDescendantEvents(): bool { - return count(array_filter( - array_column(DB::getQueryLog(), 'query'), - static fn (string $query): bool => preg_match( - '/^select .*_lft.*_rgt.*depth.* from .* limit 1$/i', - $query, - ) === 1, - )); - } - - protected function makeCollectionNode( - int|string $id, - int $lft, - int $rgt, - int|string|null $parentId, - ?Category $node = null, - ): Category { - $node ??= new Category; - $node->setRawAttributes([ - 'id' => $id, - '_lft' => $lft, - '_rgt' => $rgt, - 'parent_id' => $parentId, - 'depth' => 0, - ], true); - $node->exists = true; - - return $node; + return true; } } -class CustomParentCategoryModel extends Model +class HiddenGalaxyCategoryModel extends Model { use HasNode; public bool $timestamps = false; - protected ?string $table = 'custom_parent_categories'; + protected ?string $table = 'categories'; /** - * Get the parent ID column name. + * Register the visibility scope. */ - public function getParentIdName(): string + protected static function booted(): void { - return 'ancestor_id'; + static::addGlobalScope( + 'visible', + fn (EloquentBuilder $query): EloquentBuilder => $query->where('name', '<>', 'galaxy'), + ); } } -class EventedCategoryModel extends Category +class CustomEventCategoryModel extends Category { protected ?string $table = 'categories'; - /** - * Determine whether descendant model events should be fired during deletion. - */ - protected function shouldFireDescendantEvents(): bool - { - return true; - } + protected array $dispatchesEvents = [ + 'saving' => CategoryLifecycleEvent::class, + 'deleting' => CategoryLifecycleEvent::class, + 'deleted' => CategoryLifecycleEvent::class, + 'restoring' => CategoryLifecycleEvent::class, + 'restored' => CategoryLifecycleEvent::class, + ]; +} +class CategoryLifecycleEvent +{ /** - * Get the descendant deletion chunk size. + * Create a new event instance. */ - protected function getDescendantDeleteChunkSize(): int + public function __construct(public CustomEventCategoryModel $category) { - return 2; } } @@ -3498,7 +793,23 @@ protected static function booted(): void { static::addGlobalScope( 'visible', - fn (EloquentBuilder $query) => $query->where('name', '<>', 'galaxy'), + fn (EloquentBuilder $query): EloquentBuilder => $query->where('name', '<>', 'galaxy'), + ); + } +} + +class SelectScopedCategoryModel extends Category +{ + protected ?string $table = 'categories'; + + /** + * Register a scope that selects an extra column. + */ + protected static function booted(): void + { + static::addGlobalScope( + 'with_extra_select', + fn (EloquentBuilder $query): EloquentBuilder => $query->addSelect('*')->selectRaw('1 as extra_attribute'), ); } } diff --git a/tests/NestedSet/NodeTestBase.php b/tests/NestedSet/NodeTestBase.php new file mode 100644 index 0000000000..3efce035b4 --- /dev/null +++ b/tests/NestedSet/NodeTestBase.php @@ -0,0 +1,4341 @@ + + */ + protected string $category; + + /** + * Get the fixture migration path. + */ + abstract protected function getMigrationPath(): string; + + /** + * Get the key of a seeded fixture category. + */ + abstract protected function key(int $number): int|string; + + /** + * Get the keys of seeded fixture categories. + */ + protected function keys(int ...$numbers): array + { + return array_map($this->key(...), $numbers); + } + + /** + * Use a table prefix so raw nested set SQL is checked against prefixed tables. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->make('config'); + + $config->set( + 'database.connections.' . $config->string('database.default') . '.prefix', + 'prfx_', + ); + } + + /** + * Get the migration options. + */ + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => $this->getMigrationPath(), + ]; + } + + /** + * Seed the fixture tree inside the test coroutine and transaction. + */ + protected function afterRefreshingDatabase(): void + { + DB::enableQueryLog(); + + DB::table('categories') + ->insert($this->getMockCategories()); + } + + /** + * Get the initial category records. + */ + protected function getMockCategories(): array + { + return [ + ['id' => $this->key(1), 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => $this->key(2), 'depth' => 2], + ['id' => $this->key(4), 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => $this->key(2), 'depth' => 2], + ['id' => $this->key(5), 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(6), 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => $this->key(5), 'depth' => 2], + ['id' => $this->key(7), 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => $this->key(5), 'depth' => 2], + ['id' => $this->key(8), 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => $this->key(7), 'depth' => 3], + ['id' => $this->key(9), 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => $this->key(5), 'depth' => 2], + ['id' => $this->key(10), 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => $this->key(5), 'depth' => 2], + ['id' => $this->key(11), 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null, 'depth' => 0], + ]; + } + + /** + * Replace the seeded tree with the small rebuild query fixture. + */ + private function resetRebuildQueryFixture(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => 8, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'branch', '_lft' => 2, '_rgt' => 3, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'parent', '_lft' => 4, '_rgt' => 7, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(4), 'name' => 'child', '_lft' => 5, '_rgt' => 6, 'parent_id' => $this->key(3), 'depth' => 2], + ]); + } + + /** + * Stop query logging on the test coroutine's connection. + */ + protected function tearDownInCoroutine(): void + { + DB::flushQueryLog(); + DB::disableQueryLog(); + } + + /** + * Assert that the tree has no structural errors. + */ + protected function assertTreeNotBroken(): void + { + $this->assertSame([ + 'invalid_intervals' => 0, + 'duplicate_endpoints' => 0, + 'missing_endpoints' => 0, + 'crossing_intervals' => 0, + 'missing_parent' => 0, + 'wrong_parent' => 0, + 'wrong_depth' => 0, + ], $this->category::countErrors()); + } + + /** + * Assert that the node's bounds match the stored row. + */ + protected function assertNodeReceivesValidValues(Category $node): void + { + $lft = $node->getLft(); + $rgt = $node->getRgt(); + $nodeInDb = $this->findCategory($node->name); + + $this->assertEquals( + [$nodeInDb->getLft(), $nodeInDb->getRgt()], + [$lft, $rgt], + 'Node is not synced with database after save.' + ); + } + + /** + * Find a category by name. + */ + public function findCategory(string $name, bool $withTrashed = false): ?Category + { + $category = new $this->category; + $query = $withTrashed ? $category->withTrashed() : $category->newQuery(); + + return $query->whereName($name)->first(); + } + + /** + * Get the node's structural column values. + */ + protected function nodeValues(Category $node): array + { + return [$node->_lft, $node->_rgt, $node->parent_id, $node->depth]; + } + + /** + * Get every persisted row of a fixture table in key order. + */ + protected function persistedRows(string $table = 'categories'): array + { + return DB::table($table) + ->orderBy('id') + ->get() + ->map(fn (object $row): array => (array) $row) + ->all(); + } + + public function testTreeNotBroken(): void + { + $this->assertTreeNotBroken(); + $this->assertFalse($this->category::isBroken()); + } + + public function testGetsNodeData(): void + { + $data = $this->category::getNodeData($this->key(3)); + + $this->assertEquals(['_lft' => 3, '_rgt' => 4, 'depth' => 2], $data); + $this->assertEquals( + ['_lft' => 3, '_rgt' => 4, 'depth' => 2], + $this->category::query()->from('categories as aliased_categories')->getNodeData($this->key(3)), + ); + } + + public function testGetsPlainNodeData(): void + { + $data = $this->category::getPlainNodeData($this->key(3)); + + $this->assertEquals([3, 4], $data); + } + + public function testZeroHeightGapDoesNotIssueAQuery(): void + { + DB::flushQueryLog(); + + $this->assertSame(0, $this->category::query()->makeGap(5, 0)); + $this->assertSame([], DB::getQueryLog()); + } + + public function testLowLevelMoveDerivesDepthWhenTheCallerOmitsIt(): void + { + $retrieved = 0; + + $this->category::retrieved(function () use (&$retrieved): void { + ++$retrieved; + }); + + $this->assertSame(0, $this->category::query()->depthForPosition(21)); + $this->assertSame(3, $this->category::query()->depthForPosition(12)); + + $this->category::query()->moveNode($this->key(2), 12); + + // Depth lookups read plain rows instead of hydrating partial models. + $this->assertSame(0, $retrieved); + $this->assertSame(3, $this->category::findOrFail($this->key(2))->getDepth()); + $this->assertSame(4, $this->category::findOrFail($this->key(3))->getDepth()); + } + + #[DataProvider('invalidLowLevelNodeData')] + public function testLowLevelMoveRejectsInvalidNodeData(array $nodeData): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Node data for [{$this->category}] must contain [_lft], [_rgt], and [depth].", + ); + + $this->category::query()->moveNode($this->key(2), 12, nodeData: $nodeData); + } + + /** + * Get node data missing a required structural value. + */ + public static function invalidLowLevelNodeData(): array + { + return [ + 'missing depth' => [['_lft' => 2, '_rgt' => 7]], + 'null left bound' => [['_lft' => null, '_rgt' => 7, 'depth' => 1]], + ]; + } + + public function testMovePreparesEachParticipantAtTheSaveBoundary(): void + { + $node = $this->category::findOrFail($this->key(3)); + $parent = $this->category::findOrFail($this->key(5)); + DB::flushQueryLog(); + + $node->appendToNode($parent)->save(); + + $this->assertSame(3, $this->countStructuralIdentityReloads()); + } + + public function testParticipantPreparationDoesNotFireRetrievedListeners(): void + { + $node = $this->category::findOrFail($this->key(3)); + $parent = $this->category::findOrFail($this->key(5)); + $retrieved = 0; + + $this->category::retrieved(function () use (&$retrieved): void { + ++$retrieved; + }); + + $node->appendToNode($parent)->save(); + $node->refreshNode(); + + $this->assertSame(0, $retrieved); + $this->assertTreeNotBroken(); + } + + public function testRolledBackMoveCanBeSavedAgain(): void + { + $node = $this->category::findOrFail($this->key(3)); + $parent = $this->category::findOrFail($this->key(5)); + $veto = new LogicException('The move was vetoed.'); + $vetoed = false; + $caught = null; + + $this->category::updating(function () use (&$vetoed): ?bool { + if ($vetoed) { + return null; + } + + $vetoed = true; + + return false; + }); + + $node->appendToNode($parent); + + try { + DB::transaction(function () use ($node, $veto): void { + if (! $node->save()) { + throw $veto; + } + }); + } catch (LogicException $exception) { + $caught = $exception; + } + + $this->assertSame($veto, $caught); + $this->assertSame($this->key(2), $this->category::findOrFail($this->key(3))->getParentId()); + + $this->assertTrue($node->save()); + + $this->assertSame($this->key(5), $this->category::findOrFail($this->key(3))->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testMoveRolledBackAfterSavingCanBeQueuedAgain(): void + { + $node = $this->category::findOrFail($this->key(3)); + $parent = $this->category::findOrFail($this->key(5)); + $rollback = new LogicException('Roll back the saved move.'); + $caught = null; + + try { + DB::transaction(function () use ($node, $parent, $rollback): never { + $this->assertTrue($node->appendToNode($parent)->save()); + + throw $rollback; + }); + } catch (LogicException $exception) { + $caught = $exception; + } + + $this->assertSame($rollback, $caught); + $this->assertSame($this->key(2), $this->category::findOrFail($this->key(3))->getParentId()); + + $this->assertTrue($node->appendToNode($parent)->save()); + + $persisted = $this->category::findOrFail($this->key(3)); + + $this->assertSame($this->key(5), $persisted->getParentId()); + $this->assertSame($persisted->getBounds(), $node->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testSavingAgainFromTheCreatedListenerDoesNotReplayTheAction(): void + { + $listened = false; + + $this->category::created(function (Category $model) use (&$listened): void { + if ($listened) { + return; + } + + $listened = true; + $this->category::create(['name' => 'later root']); + $model->name = 'renamed root'; + $model->save(); + }); + + $root = $this->category::create(['name' => 'root']); + $persisted = $this->category::findOrFail($root->getKey()); + + $this->assertSame([23, 24], $persisted->getBounds()); + $this->assertSame('renamed root', $persisted->name); + $this->assertSame([25, 26], $this->findCategory('later root')->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testActionQueuedByAnObserverWaitsForTheNextSave(): void + { + $parent = $this->category::findOrFail($this->key(5)); + $queued = false; + + $this->category::created(function (Category $model) use ($parent, &$queued): void { + if (! $queued) { + $queued = true; + $model->appendToNode($parent); + } + }); + + $node = new $this->category(['name' => 'queued']); + $node->save(); + + $this->assertNull($this->category::findOrFail($node->getKey())->getParentId()); + + $this->assertTrue($node->save()); + + $this->assertSame($this->key(5), $this->category::findOrFail($node->getKey())->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testMovingANewSourceDoesNotPreflightTheSource(): void + { + $parent = $this->category::findOrFail($this->key(5)); + DB::flushQueryLog(); + + (new $this->category(['name' => 'new child'])) + ->appendToNode($parent) + ->save(); + + $this->assertSame(2, $this->countStructuralIdentityReloads()); + $this->assertTreeNotBroken(); + } + + public function testEveryMovePreparesEachParticipantOnlyOnce(): void + { + $firstSource = $this->category::findOrFail($this->key(3)); + $firstTarget = $this->category::findOrFail($this->key(5)); + $secondSource = $this->category::findOrFail($this->key(6)); + $secondTarget = $this->category::findOrFail($this->key(2)); + + $firstSource->appendToNode($firstTarget)->save(); + DB::flushQueryLog(); + + $secondSource->appendToNode($secondTarget)->save(); + + $this->assertSame(3, $this->countStructuralIdentityReloads()); + $this->assertTreeNotBroken(); + } + + #[DataProvider('deletedStructuralParticipants')] + public function testDeletedStructuralParticipantsFailBeforeWriting(string $participant): void + { + $source = $this->category::findOrFail($this->key(3)); + $target = $this->category::findOrFail($this->key(5)); + + $this->category::query()->moveNode($this->key(11), 1); + DB::table('categories') + ->where('id', $participant === 'source' ? $source->getKey() : $target->getKey()) + ->delete(); + + $columns = ['id', '_lft', '_rgt', 'parent_id', 'depth']; + $before = DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(static fn (object $row): array => (array) $row) + ->all(); + $caught = null; + + try { + $source->appendToNode($target)->save(); + $this->fail('Expected the deleted structural participant to be rejected.'); + } catch (ModelNotFoundException $exception) { + $caught = $exception; + } + + $this->assertNotNull($caught); + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(static fn (object $row): array => (array) $row) + ->all(), + ); + } + + /** + * Get the structural participants that may be deleted before a write. + */ + public static function deletedStructuralParticipants(): array + { + return [ + 'source' => ['source'], + 'target' => ['target'], + ]; + } + + public function testReceivesValidValuesWhenAppendedTo(): void + { + $node = new $this->category(['name' => 'test']); + $root = $this->category::root(); + + $accepted = [$root->_rgt, $root->_rgt + 1, $root->id, $root->depth + 1]; + + $root->appendNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals($accepted, $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isDescendantOf($root)); + } + + public function testReceivesValidValuesWhenPrependedTo(): void + { + $root = $this->category::root(); + $node = new $this->category(['name' => 'test']); + $root->prependNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$root->_lft + 1, $root->_lft + 2, $root->id, $root->depth + 1], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertTrue($node->isDescendantOf($root)); + $this->assertTrue($root->isAncestorOf($node)); + $this->assertTrue($node->isChildOf($root)); + } + + public function testReceivesValidValuesWhenInsertedAfter(): void + { + $target = $this->findCategory('apple'); + $node = new $this->category(['name' => 'test']); + $node->afterNode($target)->save(); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$target->_rgt + 1, $target->_rgt + 2, $target->parent->id, $target->depth], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + $this->assertFalse($node->isDirty()); + $this->assertTrue($node->isSiblingOf($target)); + } + + public function testInsertAfterRefreshesTargetAndInvalidatesStructuralRelations(): void + { + $node = $this->category::with(['parent', 'siblings'])->findOrFail($this->key(3)); + $target = $this->category::with(['parent', 'siblings'])->findOrFail($this->key(9)); + + $this->assertTrue($node->insertAfterNode($target)); + + $storedTarget = $this->category::findOrFail($this->key(9)); + + $this->assertSame($storedTarget->getLft(), $target->getLft()); + $this->assertSame($storedTarget->getRgt(), $target->getRgt()); + $this->assertFalse($node->relationLoaded('parent')); + $this->assertFalse($node->relationLoaded('siblings')); + $this->assertFalse($target->relationLoaded('parent')); + $this->assertFalse($target->relationLoaded('siblings')); + } + + public function testReceivesValidValuesWhenInsertedBefore(): void + { + $target = $this->findCategory('apple'); + $node = new $this->category(['name' => 'test']); + $node->beforeNode($target)->save(); + + $this->assertTrue($node->hasMoved()); + $this->assertEquals([$target->_lft, $target->_lft + 1, $target->parent->id, $target->depth], $this->nodeValues($node)); + $this->assertTreeNotBroken(); + } + + public function testCategoryMoveLevelUp(): void + { + $node = $this->findCategory('galaxy'); + $target = $this->findCategory('notebooks'); + + $this->assertSame(1, $target->getDepth()); + $this->assertSame(3, $node->getDepth()); + + $target->appendNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertNodeReceivesValidValues($node); + $this->assertTreeNotBroken(); + + $this->assertSame(1, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + } + + public function testCategoryMoveLevelSame(): void + { + $node = $this->findCategory('apple'); + $target = $this->findCategory('notebooks'); + + $this->assertSame(1, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + + $target->appendNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + + $this->assertSame(1, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + } + + public function testCategoryMoveLevelDown(): void + { + $node = $this->findCategory('apple'); + $target = $this->findCategory('samsung'); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + + $target->appendNode($node); + + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(3, $node->getDepth()); + } + + public function testCategoryMoveBeforeUp(): void + { + $node = $this->findCategory('galaxy'); + $target = $this->findCategory('apple'); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(3, $node->getDepth()); + + $node->insertBeforeNode($target); + + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + } + + public function testCategoryMoveBeforeSame(): void + { + $node = $this->findCategory('apple'); + $target = $this->findCategory('samsung'); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + + $node->insertBeforeNode($target); + + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + + $this->assertSame(2, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + } + + public function testCategoryMoveBeforeDown(): void + { + $node = $this->findCategory('apple'); + $target = $this->findCategory('galaxy'); + + $this->assertSame(3, $target->getDepth()); + $this->assertSame(2, $node->getDepth()); + + $node->insertBeforeNode($target); + + $this->assertTrue($node->hasMoved()); + $this->assertTreeNotBroken(); + $this->assertNodeReceivesValidValues($node); + + $this->assertSame(3, $target->getDepth()); + $this->assertSame(3, $node->getDepth()); + } + + #[DataProvider('staleSiblingMovements')] + public function testSiblingMovementRereadsAfterAnEarlierStructuralShift( + string $method, + int $nodeId, + ): void { + $node = $this->category::findOrFail($this->key($nodeId)); + + $this->category::query()->moveNode($this->key(11), 1); + + $this->assertTrue($node->{$method}()); + $this->assertSame( + $this->keys(4, 3), + $this->category::where('parent_id', $this->key(2))->defaultOrder()->pluck('id')->all(), + ); + $this->assertTreeNotBroken(); + } + + /** + * Get sibling movements made after an earlier structural shift. + */ + public static function staleSiblingMovements(): array + { + return [ + 'move the first sibling down' => ['down', 3], + 'move the second sibling up' => ['up', 4], + ]; + } + + #[DataProvider('structuralMovementCases')] + public function testStructuralMovesMaintainSubtreeDepth( + string $method, + string $nodeName, + string $targetName, + int $expectedDepth, + string $childName, + ): void { + $node = $this->findCategory($nodeName); + $target = $this->findCategory($targetName); + + if ($method === 'append') { + $target->appendNode($node); + } else { + $node->insertBeforeNode($target); + } + + $this->assertSame($expectedDepth, $node->getDepth()); + $this->assertSame($expectedDepth, $this->findCategory($nodeName)->getDepth()); + $this->assertSame($expectedDepth + 1, $this->findCategory($childName)->getDepth()); + $this->assertTreeNotBroken(); + } + + /** + * Get structural moves with their expected subtree depth. + */ + public static function structuralMovementCases(): array + { + return [ + 'append level up' => ['append', 'samsung', 'store', 1, 'galaxy'], + 'append same level' => ['append', 'samsung', 'notebooks', 2, 'galaxy'], + 'append level down' => ['append', 'notebooks', 'samsung', 3, 'apple'], + 'insert before level up' => ['before', 'samsung', 'notebooks', 1, 'galaxy'], + 'insert before same level' => ['before', 'samsung', 'sony', 2, 'galaxy'], + 'insert before level down' => ['before', 'notebooks', 'galaxy', 3, 'apple'], + ]; + } + + public function testBeforeNodeDerivesDepthWhenTheTargetDepthWasNotSelected(): void + { + $node = $this->findCategory('samsung'); + $target = $this->category::query() + ->select(['id', 'name', '_lft', '_rgt', 'parent_id']) + ->findOrFail($this->key(3)); + + $node->insertBeforeNode($target); + + $this->assertSame(2, $this->category::findOrFail($node->getKey())->getDepth()); + $this->assertSame(3, $this->findCategory('galaxy')->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testAppendNewNodeDerivesDepthFromHandPositionedParentWithoutLoadedDepth(): void + { + $parent = new $this->category; + $parent->setRawAttributes([ + 'id' => $this->key(7), + '_lft' => 11, + '_rgt' => 14, + 'parent_id' => $this->key(5), + ]); + + $node = new $this->category(['name' => 'new phone']); + $node->appendToNode($parent)->save(); + $node = $node->fresh(); + + $this->assertSame($this->key(7), $node->getParentId()); + $this->assertSame(3, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testAppendExistingSubtreeRefreshesParentWithoutSelectedDepth(): void + { + $node = $this->findCategory('notebooks'); + $parent = $this->category::query() + ->select(['id', '_lft', '_rgt', 'parent_id']) + ->findOrFail($this->key(7)); + + $node->appendToNode($parent)->save(); + + $this->assertSame(3, $this->category::findOrFail($this->key(2))->getDepth()); + $this->assertSame(4, $this->category::findOrFail($this->key(3))->getDepth()); + $this->assertSame(4, $this->category::findOrFail($this->key(4))->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testMovingPartiallySelectedSourceRefreshesItsStructuralData(): void + { + $node = $this->category::query() + ->select(['id', 'parent_id']) + ->findOrFail($this->key(2)); + + $node->appendToNode($this->findCategory('samsung'))->save(); + + $this->assertSame(3, $this->category::findOrFail($this->key(2))->getDepth()); + $this->assertSame(4, $this->category::findOrFail($this->key(3))->getDepth()); + $this->assertSame(4, $this->category::findOrFail($this->key(4))->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testPersistedMutationParticipantRequiresASelectedKey(): void + { + $node = $this->category::query() + ->select(['_lft', '_rgt', 'depth', 'parent_id']) + ->whereKey($this->key(2)) + ->firstOrFail(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set model [{$this->category}] requires the [id] column to be selected.", + ); + + $node->appendToNode($this->category::findOrFail($this->key(5)))->save(); + } + + public function testRefreshingPersistedNodeRequiresASelectedKey(): void + { + $node = $this->category::query() + ->select(['_lft', '_rgt', 'depth']) + ->whereKey($this->key(3)) + ->firstOrFail(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set model [{$this->category}] requires the [id] column to be selected.", + ); + + $node->refreshNode(); + } + + public function testBeforeNodeRefreshesMissingTargetParentage(): void + { + $node = $this->category::findOrFail($this->key(6)); + $target = $this->category::query() + ->select(['id', '_lft', '_rgt', 'depth']) + ->findOrFail($this->key(3)); + + $node->insertBeforeNode($target); + + $this->assertSame($this->key(2), $this->category::findOrFail($this->key(6))->getParentId()); + $this->assertTrue($this->category::findOrFail($this->key(6))->isSiblingOf($this->category::findOrFail($this->key(3)))); + $this->assertTreeNotBroken(); + } + + public function testDeferredSiblingInsertionRevalidatesTheTargetsCurrentParent(): void + { + $node = $this->category::findOrFail($this->key(3)); + $target = $this->category::findOrFail($this->key(9)); + + $node->beforeNode($target); + $target->appendToNode($this->category::findOrFail($this->key(2)))->save(); + $node->save(); + + $node = $this->category::findOrFail($this->key(3)); + $target = $this->category::findOrFail($this->key(9)); + + $this->assertSame($this->key(2), $node->getParentId()); + $this->assertSame($target->getParentId(), $node->getParentId()); + $this->assertTrue($node->isSiblingOf($target)); + $this->assertTreeNotBroken(); + } + + public function testFailsToInsertIntoChild(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Node must not be a descendant.'); + + $node = $this->findCategory('notebooks'); + $target = $node->children()->first(); + + $node->afterNode($target)->save(); + } + + public function testFailsToAppendIntoItself(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Node must not be a descendant.'); + + $node = $this->findCategory('notebooks'); + + $node->appendToNode($node)->save(); + } + + public function testFailsToPrependIntoItself(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Node must not be a descendant.'); + + $node = $this->findCategory('notebooks'); + + $node->prependToNode($node)->save(); + } + + public function testStructuralTargetsMustHavePositiveStoredBounds(): void + { + $target = $this->findCategory('apple') + ->setLft(0) + ->setRgt(0); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Node must be part of a tree.'); + + (new $this->category(['name' => 'test']))->appendToNode($target); + } + + public function testRefreshNodeReadsCurrentLowLevelStructuralWrites(): void + { + $node = $this->category::findOrFail($this->key(11)); + + $this->category::query()->makeGap(21, 2); + $node->refreshNode(); + + $this->assertSame([23, 24], $node->getBounds()); + + $this->category::query()->moveNode($this->key(11), 1); + $node->refreshNode(); + + $this->assertSame([1, 2], $node->getBounds()); + } + + public function testRawNodePendingActionSaves(): void + { + $node = new $this->category(['name' => 'raw']); + + $node->rawNode(23, 24, null, 0); + + $this->assertTrue($node->save()); + $this->assertSame([23, 24], $node->fresh()->getBounds()); + } + + public function testRawNodeStoresAMissingDepthAsZero(): void + { + $node = new $this->category(['name' => 'raw']); + + $node->rawNode(23, 24, null, null); + + $this->assertTrue($node->save()); + $this->assertSame(0, $node->fresh()->getDepth()); + } + + public function testRawNodeWithCompleteScopeDoesNotPreflightPersistedIdentity(): void + { + $node = $this->category::findOrFail($this->key(3)); + DB::flushQueryLog(); + + $node->rawNode( + $node->getLft(), + $node->getRgt(), + $node->getParentId(), + $node->getDepth() + 1, + )->save(); + + $this->assertSame(0, $this->countStructuralIdentityReloads()); + $this->assertSame( + $node->getDepth(), + $this->category::findOrFail($node->getKey())->getDepth(), + ); + } + + #[DataProvider('structuralMutationMethods')] + public function testStructuralMutationTargetsMustUseTheSameTree(string $method): void + { + $connection = DB::getDefaultConnection(); + + config([ + 'database.connections.nested_set_other' => config( + "database.connections.{$connection}", + ), + ]); + + $source = $this->findCategory('apple'); + $target = $this->findCategory('notebooks') + ->setConnection('nested_set_other'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Nodes must be in the same tree.'); + + $source->{$method}($target); + } + + /** + * Get the structural mutation methods that take a target node. + */ + public static function structuralMutationMethods(): array + { + return [ + 'append' => ['appendToNode'], + 'prepend' => ['prependToNode'], + 'before' => ['beforeNode'], + 'after' => ['afterNode'], + ]; + } + + public function testStructuralPredicatesRequireTheSameStore(): void + { + $connection = DB::getDefaultConnection(); + + config([ + 'database.connections.nested_set_other' => config( + "database.connections.{$connection}", + ), + ]); + + $child = $this->findCategory('apple'); + $parent = $this->findCategory('notebooks') + ->setConnection('nested_set_other'); + $sibling = $this->category::findOrFail($this->key(4)) + ->setConnection('nested_set_other'); + + $this->assertFalse($child->isChildOf($parent)); + $this->assertFalse($child->isDescendantOf($parent)); + $this->assertFalse($child->isSiblingOf($sibling)); + } + + public function testWithoutRootWorks(): void + { + $this->assertSame( + $this->keys(2, 3, 4, 5, 6, 7, 8, 9, 10), + $this->category::withoutRoot()->orderBy('id')->pluck('id')->all(), + ); + } + + public function testStructuralReadQueriesQualifyTheirColumnsAfterJoins(): void + { + $query = $this->category::query() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id'); + + $this->assertSame( + $this->keys(3, 4, 6, 8, 9, 10, 11), + (clone $query)->whereIsLeaf()->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 2, 5, 7), + (clone $query)->hasChildren()->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 2, 3, 4), + (clone $query)->whereIsBefore($this->key(5))->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(6, 7, 8, 9, 10, 11), + (clone $query)->whereIsAfter($this->key(5))->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11), + (clone $query)->defaultOrder()->pluck('id')->all(), + ); + $this->assertSame( + ['_lft' => 3, '_rgt' => 4, 'depth' => 2], + (clone $query)->getNodeData($this->key(3)), + ); + $this->assertSame([3, 4], (clone $query)->getPlainNodeData($this->key(3))); + + $node = $this->category::findOrFail($this->key(5)); + + $this->assertSame( + $this->keys(6, 7, 8, 9, 10, 11), + $node->nextNodes() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->orderBy('categories.id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(1, 2, 3, 4), + $node->prevNodes() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->orderBy('categories.id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(5), + $this->category::findOrFail($this->key(2)) + ->nextSiblings() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(2), + $node->prevSiblings() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(2), + $node->siblings() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->orderBy('categories.id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(2, 5), + $node->siblingsAndSelf() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id') + ->orderBy('categories.id') + ->pluck('id') + ->all(), + ); + } + + public function testDefaultOrderClearsPreviousOrderBindings(): void + { + $ids = $this->category::query() + ->orderByRaw('case when name = ? then 0 else 1 end', ['apple']) + ->defaultOrder() + ->where('name', '=', 'store') + ->pluck('id') + ->all(); + + $this->assertSame($this->keys(1), $ids); + } + + public function testDefaultOrderClearsPreviousUnionOrderBindings(): void + { + $ids = $this->category::query() + ->select(['id', '_lft']) + ->whereKey($this->key(1)) + ->union($this->category::query()->select(['id', '_lft'])->whereKey($this->key(3))) + ->orderByRaw('case when name = ? then 0 else 1 end', ['apple']) + ->defaultOrder() + ->get() + ->pluck('id') + ->all(); + + $this->assertSame($this->keys(1, 3), $ids); + } + + public function testAncestorsReturnsAncestorsWithoutNodeItself(): void + { + $node = $this->findCategory('apple'); + $path = $node->ancestors()->pluck('name')->all(); + + $this->assertEquals(['store', 'notebooks'], $path); + } + + public function testGetsAncestorsByStatic(): void + { + $path = $this->category::ancestorsOf($this->key(3))->pluck('name')->all(); + + $this->assertEqualsCanonicalizing(['store', 'notebooks'], $path); + } + + public function testGetsAncestorsDirect(): void + { + $path = $this->category::find($this->key(8))->getAncestors()->pluck('id')->all(); + + $this->assertEquals($this->keys(1, 5, 7), $path); + } + + public function testGetRelationMethodsQueryFreshRowsWithoutReplacingLoadedRelations(): void + { + $node = $this->category::with(['ancestors', 'descendants', 'siblings'])->findOrFail($this->key(7)); + + DB::table('categories')->whereIn('id', $this->keys(5, 8, 9))->update(['name' => 'renamed']); + + $this->assertContains('renamed', $node->getAncestors()->pluck('name')->all()); + $this->assertContains('renamed', $node->getDescendants()->pluck('name')->all()); + $this->assertContains('renamed', $node->getSiblings()->pluck('name')->all()); + $this->assertNotContains('renamed', $node->ancestors->pluck('name')->all()); + $this->assertNotContains('renamed', $node->descendants->pluck('name')->all()); + $this->assertNotContains('renamed', $node->siblings->pluck('name')->all()); + } + + public function testDescendants(): void + { + $node = $this->findCategory('mobile'); + $descendants = $node->descendants()->pluck('name')->all(); + $expected = ['nokia', 'samsung', 'galaxy', 'sony', 'lenovo']; + + $this->assertEqualsCanonicalizing($expected, $descendants); + + $descendants = $node->getDescendants()->pluck('name')->all(); + + $this->assertEquals(count($descendants), $node->getDescendantCount()); + $this->assertEqualsCanonicalizing($expected, $descendants); + + $descendants = $this->category::descendantsAndSelf($this->key(7))->pluck('name')->all(); + $expected = ['samsung', 'galaxy']; + + $this->assertEqualsCanonicalizing($expected, $descendants); + } + + public function testDescendantsOfExcludesSelf(): void + { + $this->assertEqualsCanonicalizing( + $this->keys(6, 7, 8, 9, 10), + $this->category::descendantsOf($this->key(5))->pluck('id')->all(), + ); + } + + public function testAncestorsAndSelfIncludesNode(): void + { + $this->assertEqualsCanonicalizing( + $this->keys(1, 5, 7, 8), + $this->category::ancestorsAndSelf($this->key(8))->pluck('id')->all(), + ); + } + + public function testWhereNotDescendantOfExcludesSubtree(): void + { + $this->assertSame( + $this->keys(1, 2, 3, 4, 5, 11), + $this->category::whereNotDescendantOf($this->key(5))->orderBy('id')->pluck('id')->all(), + ); + } + + public function testOrWhereDescendantOfUnionsSubtrees(): void + { + $this->assertSame( + $this->keys(3, 4, 6, 7, 8, 9, 10), + $this->category::whereDescendantOf($this->key(2)) + ->orWhereDescendantOf($this->key(5)) + ->orderBy('id') + ->pluck('id') + ->all(), + ); + } + + public function testAncestorAndDescendantConstraintShortcuts(): void + { + $this->assertSame( + $this->keys(1, 5, 7, 8), + $this->category::whereAncestorOrSelf($this->key(8))->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 2, 5, 7), + $this->category::whereAncestorOf($this->key(3)) + ->orWhereAncestorOf($this->key(8)) + ->orderBy('id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(7, 8), + $this->category::whereDescendantOrSelf($this->key(7))->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 3, 4, 11), + $this->category::whereDescendantOf($this->key(2)) + ->orWhereNotDescendantOf($this->key(1)) + ->orderBy('id') + ->pluck('id') + ->all(), + ); + $this->assertSame( + $this->keys(2, 3, 4, 11), + $this->category::whereNodeBetween([2, 7]) + ->orWhereNodeBetween([21, 22]) + ->orderBy('id') + ->pluck('id') + ->all(), + ); + } + + #[DataProvider('partialNodeStateMethods')] + public function testPersistedNodeStateCountsRequireLoadedBounds(string $method, array $columns): void + { + $node = $this->category::query()->select($columns)->findOrFail($this->key(5)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->category}] must have loaded bounds.", + ); + + $node->{$method}(); + } + + /** + * Get node state methods with projections missing a required bound. + */ + public static function partialNodeStateMethods(): array + { + return [ + 'height without left bound' => ['getNodeHeight', ['id', '_rgt']], + 'height without right bound' => ['getNodeHeight', ['id', '_lft']], + 'descendant count without left bound' => ['getDescendantCount', ['id', '_rgt']], + 'descendant count without right bound' => ['getDescendantCount', ['id', '_lft']], + ]; + } + + #[DataProvider('partialLeafProjections')] + public function testLeafPredicateTreatsMissingBoundsAsFalse(array $attributes): void + { + $node = new $this->category; + $node->setRawAttributes($attributes, true); + $node->exists = true; + + $this->assertFalse($node->isLeaf()); + } + + /** + * Get leaf projections missing a bound. + */ + public static function partialLeafProjections(): array + { + return [ + 'missing left bound' => [['id' => 3, '_rgt' => 4]], + 'missing right bound' => [['id' => 3, '_lft' => 3]], + 'missing left bound at the coercion edge' => [['id' => 99, '_rgt' => 1]], + ]; + } + + public function testWithDepthWorks(): void + { + $nodes = $this->category::withDepth()->defaultOrder()->limit(4)->pluck('depth')->all(); + + $this->assertEquals([0, 1, 2, 2], $nodes); + } + + public function testWithDepthUsesStoredDepthColumnWhenAvailable(): void + { + DB::table('categories')->where('id', $this->key(3))->update(['depth' => 7]); + + $node = $this->category::withDepth('level')->findOrFail($this->key(3)); + + $this->assertSame(7, $node['level']); + } + + public function testWithDepthWithCustomKeyWorks(): void + { + $node = $this->category::whereIsRoot()->withDepth('level')->first(); + + $this->assertSame(0, $node['level']); + } + + public function testWithDepthWorksAlongWithDefaultKeys(): void + { + $node = $this->category::withDepth()->first(); + + $this->assertTrue(isset($node->name)); + } + + public function testParentIdAttributeAccessorAppendsNode(): void + { + $node = new $this->category(['name' => 'lg', 'parent_id' => $this->key(5)]); + $node->save(); + + $this->assertEquals($this->key(5), $node->parent_id); + $this->assertEquals($this->key(5), $node->getParentId()); + + $node->parent_id = null; + $node->save(); + + $node->refreshNode(); + + $this->assertNull($node->parent_id); + $this->assertTrue($node->isRoot()); + } + + public function testParentIdAppendResolvesTheParentOnlyOnce(): void + { + $node = new $this->category(['name' => 'new phone']); + $node->parent_id = $this->key(5); + DB::flushQueryLog(); + + $node->save(); + + $this->assertSame(1, $this->countStructuralIdentityReloads()); + $this->assertSame($this->key(5), $node->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testParentIdMutatorReturnsTheNode(): void + { + $node = new $this->category(['name' => 'node']); + + $this->assertSame($node, $node->setAttribute('parent_id', $this->key(5))); + $this->assertSame($node, $node->setAttribute('parent_id', $this->key(5))); + $this->assertSame($node, $node->setAttribute('parent_id', null)); + } + + public function testParentIdAssignmentForgetsTheLoadedParent(): void + { + $node = $this->category::with('parent')->findOrFail($this->key(3)); + $node->parent_id = $this->key(5); + + $this->assertSame($this->key(5), $node->parent->getKey()); + } + + public function testFailsToSaveNodeUntilNotInserted(): void + { + $this->expectException(QueryException::class); + + $node = new $this->category; + $node->save(); + } + + public function testNodeIsDeletedWithDescendants(): void + { + $node = $this->findCategory('mobile'); + $node->forceDelete(); + + $this->assertTreeNotBroken(); + + $nodes = $this->category::whereIn('id', $this->keys(5, 6, 7, 8, 9, 10))->count(); + $this->assertEquals(0, $nodes); + + $root = $this->category::root(); + $this->assertEquals(8, $root->getRgt()); + } + + public function testForceDeletingPartiallySelectedNodeRefreshesItsStructuralData(): void + { + $node = $this->category::query() + ->select(['id', 'parent_id']) + ->findOrFail($this->key(5)); + + $node->forceDelete(); + + $this->assertSame(0, $this->category::whereIn('id', $this->keys(5, 6, 7, 8, 9, 10))->count()); + $this->assertSame(8, $this->category::root()->getRgt()); + $this->assertTreeNotBroken(); + } + + public function testForceDeletingAStaleMissingModelDoesNotMutateTheTree(): void + { + $node = $this->category::findOrFail($this->key(3)); + $this->category::findOrFail($this->key(3))->forceDelete(); + + $before = $this->persistedRows(); + + $this->assertFalse($node->forceDelete()); + $this->assertSame($before, $this->persistedRows()); + } + + public function testDestroyingANodeWithItsDescendantDeletesTheSubtreeOnce(): void + { + $this->category::forceDestroy([$this->key(5), $this->key(7)]); + + $this->assertSame(0, $this->category::withTrashed()->whereIn('id', $this->keys(5, 6, 7, 8, 9, 10))->count()); + $this->assertSame(8, $this->category::root()->getRgt()); + $this->assertTreeNotBroken(); + } + + public function testNodeIsSoftDeleted(): void + { + CarbonImmutable::setTestNow('2025-07-03 12:00:00'); + + $root = $this->category::root(); + + $samsung = $this->findCategory('samsung'); + $samsung->delete(); + + $this->assertTreeNotBroken(); + $this->assertNull($this->findCategory('galaxy')); + + CarbonImmutable::setTestNow('2025-07-03 12:00:01'); + + $node = $this->findCategory('mobile'); + $node->delete(); + + $nodes = $this->category::whereIn('id', $this->keys(5, 6, 7, 8, 9, 10))->count(); + $this->assertEquals(0, $nodes); + + $originalRgt = $root->getRgt(); + $root->refreshNode(); + + $this->assertEquals($originalRgt, $root->getRgt()); + + $node = $this->findCategory('mobile', true); + $node->restore(); + + $this->assertNull($this->findCategory('samsung')); + $this->assertNotNull($this->findCategory('nokia')); + } + + public function testSoftDeletingPartiallySelectedNodeRefreshesItsStructuralData(): void + { + $node = $this->category::query() + ->select(['id', 'parent_id']) + ->findOrFail($this->key(7)); + + $node->delete(); + + $this->assertNull($this->category::find($this->key(7))); + $this->assertNull($this->category::find($this->key(8))); + $this->assertNotNull($this->category::withTrashed()->find($this->key(7))); + $this->assertNotNull($this->category::withTrashed()->find($this->key(8))); + $this->assertTreeNotBroken(); + } + + public function testRestoringPartiallySelectedNodeRefreshesItsStructuralData(): void + { + $this->category::findOrFail($this->key(7))->delete(); + + $node = $this->category::withTrashed() + ->select(['id']) + ->findOrFail($this->key(7)); + + $node->restore(); + + $this->assertNotNull($this->category::find($this->key(7))); + $this->assertNotNull($this->category::find($this->key(8))); + $this->assertTreeNotBroken(); + } + + public function testRestoredNodeDoesNotRetainItsPreviousDeletionTimestamp(): void + { + $node = $this->findCategory('mobile'); + $node->delete(); + + $node = $this->findCategory('mobile', true); + $node->restore(); + + $this->assertNull($node->deleted_at); + + $node->name = 'restored mobile'; + $node->save(); + + $this->assertNotNull($this->findCategory('restored mobile')); + } + + public function testReentrantRestoreUsesEachNodesExactPreviousDeletionTimestamp(): void + { + CarbonImmutable::setTestNow('2025-07-03 11:59:59'); + $this->findCategory('apple')->delete(); + + CarbonImmutable::setTestNow('2025-07-03 12:00:00'); + $this->findCategory('notebooks')->delete(); + + CarbonImmutable::setTestNow('2025-07-03 12:00:01'); + $this->findCategory('samsung')->delete(); + + CarbonImmutable::setTestNow('2025-07-03 12:00:02'); + $this->findCategory('mobile')->delete(); + + $nestedRestore = false; + + $this->category::restoring(function (Category $node) use (&$nestedRestore): void { + if ($node->getKey() !== $this->key(5) || $nestedRestore) { + return; + } + + $nestedRestore = true; + $this->category::withTrashed()->findOrFail($this->key(2))->restore(); + }); + + $this->category::withTrashed()->findOrFail($this->key(5))->restore(); + + $this->assertNull($this->findCategory('apple')); + $this->assertNotNull($this->category::find($this->key(4))); + $this->assertNotNull($this->findCategory('nokia')); + $this->assertNull($this->findCategory('samsung')); + } + + public function testRestoreIncludesDescendantsDeletedAtTheSameStoredTime(): void + { + CarbonImmutable::setTestNow('2025-07-03 12:00:00'); + + $this->findCategory('samsung')->delete(); + $this->findCategory('mobile')->delete(); + + $this->findCategory('mobile', true)->restore(); + + $this->assertNotNull($this->findCategory('samsung')); + $this->assertNotNull($this->findCategory('galaxy')); + } + + public function testSoftDeletedNodeIsDeletedWhenParentIsDeleted(): void + { + $this->findCategory('samsung')->delete(); + + $this->findCategory('mobile')->forceDelete(); + + $this->assertTreeNotBroken(); + + $this->assertNull($this->findCategory('samsung', true)); + $this->assertNull($this->findCategory('sony')); + } + + public function testFailsToSaveNodeUntilParentIsSaved(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Node must be part of a tree.'); + + $node = new $this->category(['name' => 'Node']); + $parent = new $this->category(['name' => 'Parent']); + + $node->appendToNode($parent)->save(); + } + + public function testSiblings(): void + { + $node = $this->findCategory('samsung'); + $siblings = $node->siblings()->pluck('id')->all(); + $next = $node->nextSiblings()->pluck('id')->all(); + $prev = $node->prevSiblings()->pluck('id')->all(); + + $this->assertEqualsCanonicalizing($this->keys(6, 9, 10), $siblings); + $this->assertEqualsCanonicalizing($this->keys(9, 10), $next); + $this->assertEquals($this->keys(6), $prev); + + $siblings = $node->getSiblings()->pluck('id')->all(); + $next = $node->getNextSiblings()->pluck('id')->all(); + $prev = $node->getPrevSiblings()->pluck('id')->all(); + + $this->assertEqualsCanonicalizing($this->keys(6, 9, 10), $siblings); + $this->assertEqualsCanonicalizing($this->keys(9, 10), $next); + $this->assertEquals($this->keys(6), $prev); + + $next = $node->getNextSibling(); + $prev = $node->getPrevSibling(); + + $this->assertEquals($this->key(9), $next->id); + $this->assertEquals($this->key(6), $prev->id); + } + + public function testPredicatesRequirePersistedRowsAndUseLogicalRowIdentity(): void + { + $root = $this->category::findOrFail($this->key(1)); + $parent = $this->category::findOrFail($this->key(2)); + $child = $this->category::findOrFail($this->key(3)); + $sibling = $this->category::findOrFail($this->key(4)); + $unsaved = new $this->category([ + 'parent_id' => $parent->getKey(), + '_lft' => $child->getLft(), + '_rgt' => $child->getRgt(), + ]); + + $sameRow = new $this->category; + $sameRow->setRawAttributes($child->getAttributes(), true); + $sameRow->exists = true; + $sameRow->setConnection($child->getConnection()->getName()); + + $this->assertTrue($child->isDescendantOf($root)); + $this->assertTrue($root->isAncestorOf($child)); + $this->assertTrue($child->isChildOf($parent)); + $this->assertTrue($child->isSiblingOf($sibling)); + $this->assertTrue($child->isSelfOrDescendantOf($sameRow)); + $this->assertTrue($child->isSelfOrAncestorOf($sameRow)); + $this->assertFalse($child->isDescendantOf($sameRow)); + $this->assertFalse($child->isAncestorOf($sameRow)); + $this->assertFalse($child->isSiblingOf($sameRow)); + $this->assertFalse($unsaved->isDescendantOf($root)); + $this->assertFalse($unsaved->isSelfOrDescendantOf($child)); + $this->assertFalse($unsaved->isChildOf($parent)); + $this->assertFalse($unsaved->isSiblingOf($sibling)); + } + + public function testPartiallySelectedRowsWithoutKeysAreNotTheSameNode(): void + { + $first = $this->category::query() + ->select(['_lft', '_rgt', 'parent_id', 'depth']) + ->whereKey($this->key(3)) + ->firstOrFail(); + $second = $this->category::query() + ->select(['_lft', '_rgt', 'parent_id', 'depth']) + ->whereKey($this->key(4)) + ->firstOrFail(); + + $this->assertFalse($first->isSelfOrDescendantOf($second)); + $this->assertFalse($first->isSelfOrAncestorOf($second)); + } + + #[DataProvider('partialAncestryPredicates')] + public function testAncestryPredicatesTreatMissingBoundsAsFalse( + array $descendantColumns, + array $ancestorColumns, + ): void { + $descendant = $this->category::query() + ->select($descendantColumns) + ->findOrFail($this->key(7)); + $ancestor = $this->category::query() + ->select($ancestorColumns) + ->findOrFail($this->key(5)); + + $this->assertFalse($descendant->isDescendantOf($ancestor)); + $this->assertFalse($descendant->isSelfOrDescendantOf($ancestor)); + $this->assertFalse($ancestor->isAncestorOf($descendant)); + $this->assertFalse($ancestor->isSelfOrAncestorOf($descendant)); + } + + /** + * Get ancestry predicate cases missing a bound. + */ + public static function partialAncestryPredicates(): array + { + return [ + 'missing descendant left bound' => [ + ['id', '_rgt'], + ['id', '_lft', '_rgt'], + ], + 'missing ancestor left bound' => [ + ['id', '_lft', '_rgt'], + ['id', '_rgt'], + ], + 'missing ancestor right bound' => [ + ['id', '_lft', '_rgt'], + ['id', '_lft'], + ], + ]; + } + + public function testSelfInclusiveAncestryPredicatesNeedOnlyPersistedIdentity(): void + { + $same = $this->category::query()->select(['id'])->findOrFail($this->key(3)); + + $this->assertTrue($same->isSelfOrDescendantOf($same)); + $this->assertTrue($same->isSelfOrAncestorOf($same)); + } + + public function testIsSelfOrDescendantOf(): void + { + $mobile = $this->findCategory('mobile'); + $galaxy = $this->findCategory('galaxy'); + + $this->assertTrue($galaxy->isSelfOrDescendantOf($mobile)); + $this->assertTrue($galaxy->isSelfOrDescendantOf($galaxy)); + $this->assertFalse($mobile->isSelfOrDescendantOf($galaxy)); + } + + public function testIsSelfOrAncestorOf(): void + { + $mobile = $this->findCategory('mobile'); + $galaxy = $this->findCategory('galaxy'); + + $this->assertTrue($mobile->isSelfOrAncestorOf($galaxy)); + $this->assertTrue($mobile->isSelfOrAncestorOf($mobile)); + $this->assertFalse($galaxy->isSelfOrAncestorOf($mobile)); + } + + public function testStrictSiblingRelationsRequireASelectedParentKey(): void + { + $node = $this->category::query() + ->select(['parent_id']) + ->where('name', '=', 'apple') + ->firstOrFail(); + + $this->assertNull($node->getKey()); + $this->assertSame( + $this->keys(3, 4), + $node->siblingsAndSelf()->orderBy('id')->pluck('id')->all(), + ); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [id] column.", + ); + + $node->siblings()->get(); + } + + #[DataProvider('strictSiblingEagerParentIds')] + public function testStrictSiblingEagerLoadingRequiresTheRelatedKey(array $parentIds): void + { + $nodes = $this->category::whereIn('id', $this->keys(...$parentIds))->get(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation eager load for [{$this->category}] requires the [id] column.", + ); + + $nodes->load([ + 'siblings' => fn (SiblingsRelation $query): SiblingsRelation => $query->select(['parent_id', 'name']), + ]); + } + + /** + * Get parent keys for strict sibling eager loading. + */ + public static function strictSiblingEagerParentIds(): array + { + return [ + 'single parent' => [[3]], + 'multiple parents' => [[3, 6]], + ]; + } + + #[DataProvider('siblingRelations')] + public function testSiblingEagerLoadingRequiresTheRelatedParentColumn(string $relation): void + { + $nodes = $this->category::whereIn('id', $this->keys(1, 3))->get(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation eager load for [{$this->category}] requires the [parent_id] column.", + ); + + $nodes->load([ + $relation => fn (SiblingsRelation $query): SiblingsRelation => $query->select(['id', 'name']), + ]); + } + + /** + * Get the sibling relation names. + */ + public static function siblingRelations(): array + { + return [ + 'siblings' => ['siblings'], + 'siblings and self' => ['siblingsAndSelf'], + ]; + } + + public function testSiblingRelationsRequireSelectedParentage(): void + { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [parent_id] column.", + ); + + $node->siblings()->get(); + } + + #[DataProvider('eagerRelationParents')] + public function testPersistedPartialEagerParentsAreRejected( + string $relation, + int $parentId, + string $requiredColumn, + bool $partialFirst, + ): void { + $partial = $this->category::query()->select(['id'])->findOrFail($this->key($parentId)); + $complete = $this->category::findOrFail($this->key($parentId)); + $models = $partialFirst + ? [$partial, $complete] + : [$complete, $partial]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [{$requiredColumn}] column.", + ); + + $complete->newCollection($models)->load($relation); + } + + /** + * Get eager relation parents mixing partial and complete projections. + */ + public static function eagerRelationParents(): array + { + return [ + 'ancestors, partial first' => ['ancestors', 3, '_lft', true], + 'ancestors, complete first' => ['ancestors', 3, '_lft', false], + 'descendants, partial first' => ['descendants', 2, '_lft', true], + 'descendants, complete first' => ['descendants', 2, '_lft', false], + 'siblings, partial first' => ['siblings', 3, 'parent_id', true], + 'siblings, complete first' => ['siblings', 3, 'parent_id', false], + 'siblings and self, partial first' => ['siblingsAndSelf', 3, 'parent_id', true], + 'siblings and self, complete first' => ['siblingsAndSelf', 3, 'parent_id', false], + ]; + } + + #[DataProvider('incompleteRelationParents')] + public function testIncompleteEagerParentsAreRejected(string $relation, string $requiredColumn): void + { + $nodes = $this->category::query() + ->select(['id']) + ->whereIn('id', $this->keys(3, 7)) + ->get(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [{$requiredColumn}] column.", + ); + + $nodes->load($relation); + } + + #[DataProvider('incompleteRelationParents')] + public function testIncompleteLazyParentsAreRejected(string $relation, string $requiredColumn): void + { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [{$requiredColumn}] column.", + ); + + $node->{$relation}()->toSql(); + } + + #[DataProvider('relationNames')] + public function testUnsavedRelationParentsRemainEmpty(string $relation): void + { + $node = new $this->category; + DB::flushQueryLog(); + + $this->assertTrue($node->getRelationValue($relation)->isEmpty()); + $this->assertSame([], DB::getQueryLog()); + } + + /** + * Get the nested set relation names. + */ + public static function relationNames(): array + { + return array_map( + static fn (array $case): array => [$case[0]], + static::incompleteRelationParents(), + ); + } + + public function testUnsavedDuplicateDoesNotSuppressACompleteEagerParent(): void + { + $complete = $this->category::findOrFail($this->key(3)); + $unsaved = new $this->category; + $unsaved->setRawAttributes($complete->getAttributes(), true); + + $models = new Collection([$unsaved, $complete]); + $models->load('ancestors'); + + $this->assertTrue($unsaved->ancestors->isEmpty()); + $this->assertSame($this->keys(1, 2), $complete->ancestors->pluck('id')->all()); + } + + #[DataProvider('incompleteRelationParents')] + public function testDestructiveRelationQueriesRejectIncompletePersistedParents( + string $relation, + string $requiredColumn, + ): void { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->category}] requires the [{$requiredColumn}] column.", + ); + + $node->{$relation}()->delete(); + } + + /** + * Get each relation with the parent column it requires. + */ + public static function incompleteRelationParents(): array + { + return [ + 'ancestors' => ['ancestors', '_lft'], + 'descendants' => ['descendants', '_lft'], + 'siblings' => ['siblings', 'parent_id'], + 'siblings and self' => ['siblingsAndSelf', 'parent_id'], + ]; + } + + public function testFetchesReversed(): void + { + $node = $this->findCategory('sony'); + $siblings = $node->prevSiblings()->reversed()->value('id'); + + $this->assertEquals($this->key(7), $siblings); + } + + public function testToTreeBuildsWithDefaultOrder(): void + { + $tree = $this->category::whereBetween('_lft', [8, 17])->defaultOrder()->get()->toTree(); + + $this->assertEquals(1, count($tree)); + + $root = $tree->first(); + $this->assertEquals('mobile', $root->name); + $this->assertEquals(4, count($root->children)); + $this->assertSame($this->keys(6, 7, 9, 10), $root->children->modelKeys()); + } + + public function testToTreeKeepsEmptyChildrenRelationOnLeaves(): void + { + $tree = $this->category::defaultOrder()->get()->toTree(); + $notebooks = $tree->first()->children->firstWhere('name', 'notebooks'); + $apple = $notebooks->children->firstWhere('name', 'apple'); + + $this->assertTrue($apple->relationLoaded('children')); + $this->assertCount(0, $apple->children); + } + + public function testToTreeBuildsWithCustomOrder(): void + { + $tree = $this->category::whereBetween('_lft', [8, 17]) + ->orderBy('name') + ->get() + ->toTree(); + + $this->assertEquals(1, count($tree)); + + $root = $tree->first(); + $this->assertEquals('mobile', $root->name); + $this->assertEquals(4, count($root->children)); + $this->assertSame($this->keys(10, 6, 7, 9), $root->children->modelKeys()); + $this->assertNotSame($root, $root->children->first()->parent); + $this->assertSame($root->getAttributes(), $root->children->first()->parent->getAttributes()); + $this->assertSame([], $root->children->first()->parent->getRelations()); + } + + public function testLinkNodesKeepsCollectionOrderForChildren(): void + { + $nodes = $this->category::whereIn('id', $this->keys(5, 6, 9, 10))->orderBy('name')->get(); + + $nodes->linkNodes(); + + $mobile = $nodes->find($this->key(5)); + $lenovo = $nodes->find($this->key(10)); + + $this->assertSame(['lenovo', 'nokia', 'sony'], $mobile->children->pluck('name')->all()); + $this->assertSame($mobile->getAttributes(), $lenovo->parent->getAttributes()); + } + + #[DataProvider('treeBuildingProjectionRequirements')] + public function testTreeBuildingRequiresStructuralProjection( + string $method, + array $columns, + string $requiredColumn, + ): void { + $nodes = $this->category::defaultOrder()->get($columns); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs(sprintf( + 'Nested set tree building for [%s] requires the [%s] column in the projection.', + $this->category, + $requiredColumn, + )); + + $nodes->{$method}(); + } + + /** + * Get tree building methods with projections missing a required column. + */ + public static function treeBuildingProjectionRequirements(): array + { + return [ + 'link nodes without key' => ['linkNodes', ['_lft', 'parent_id', 'name'], 'id'], + 'tree without key' => ['toTree', ['_lft', 'parent_id', 'name'], 'id'], + 'flat tree without key' => ['toFlatTree', ['_lft', 'parent_id', 'name'], 'id'], + 'link nodes without parent' => ['linkNodes', ['id', '_lft', 'name'], 'parent_id'], + 'tree without parent' => ['toTree', ['id', '_lft', 'name'], 'parent_id'], + 'flat tree without parent' => ['toFlatTree', ['id', '_lft', 'name'], 'parent_id'], + ]; + } + + #[DataProvider('treeBuildingMethods')] + public function testInferredRootTreeBuildingRequiresTheLeftBound(string $method): void + { + $nodes = $this->category::query() + ->whereBetween('_lft', [2, 7]) + ->defaultOrder() + ->get(['id', 'parent_id', 'name']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set tree building for [{$this->category}] requires the [_lft] column in the projection.", + ); + + $nodes->{$method}(); + } + + #[DataProvider('treeBuildingMethods')] + public function testTreeBuildingRequiresAKeyOnTheSuppliedRoot(string $method): void + { + $root = $this->category::query() + ->select(['_lft', '_rgt', 'name']) + ->findOrFail($this->key(2)); + $nodes = $this->category::defaultOrder()->get(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set tree building for [{$this->category}] requires the [id] column on the supplied root.", + ); + + $nodes->{$method}($root); + } + + #[DataProvider('treeBuildingMethods')] + public function testTreeBuildingRejectsRootModelsWithoutNestedSet(string $method): void + { + $root = new class extends Model { + }; + $nodes = $this->category::defaultOrder()->get(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs(sprintf( + 'Model [%s] must be node.', + $root::class, + )); + + $nodes->{$method}($root); + } + + /** + * Get the collection tree building methods. + */ + public static function treeBuildingMethods(): array + { + return [ + 'tree' => ['toTree'], + 'flat tree' => ['toFlatTree'], + ]; + } + + public function testExplicitRootTreeBuildingDoesNotRequireTheLeftBound(): void + { + $root = $this->category::query()->select(['id'])->findOrFail($this->key(2)); + $nodes = $this->category::query() + ->whereBetween('_lft', [2, 7]) + ->defaultOrder() + ->get(['id', 'parent_id', 'name']); + + $this->assertSame($this->keys(3, 4), $nodes->toTree($root)->modelKeys()); + $this->assertSame($this->keys(3, 4), $nodes->toFlatTree($root)->modelKeys()); + } + + public function testToTreeWithSpecifiedRoot(): void + { + $node = $this->findCategory('mobile'); + $nodes = $this->category::whereBetween('_lft', [8, 17])->get(); + + $tree1 = Collection::make($nodes)->toTree($this->key(5)); + $tree2 = Collection::make($nodes)->toTree($node); + + $this->assertEquals(4, $tree1->count()); + $this->assertEquals(4, $tree2->count()); + } + + public function testLinkNodesClearsStaleRelationsAndUsesSharedRelationFreeParents(): void + { + $nodes = $this->category::defaultOrder()->get(); + $leaf = $nodes->find($this->key(8)); + + $leaf->setRelation('parent', new $this->category(['name' => 'stale'])); + $leaf->setRelation('children', new Collection([new $this->category])); + + $nodes->linkNodes(); + + $siblings = $nodes->whereIn('id', $this->keys(6, 7, 9, 10))->values(); + $parent = $siblings->first()->parent; + + $this->assertTrue($leaf->relationLoaded('children')); + $this->assertTrue($leaf->children->isEmpty()); + $this->assertSame($this->key(7), $leaf->parent->getKey()); + $this->assertSame([], $leaf->parent->getRelations()); + + foreach ($siblings as $sibling) { + $this->assertSame($parent, $sibling->parent); + } + } + + public function testLinkNodesKeepsLoadedParentsOutsideTheCollection(): void + { + $nodes = $this->category::with('parent') + ->whereDescendantOf($this->key(5)) + ->defaultOrder() + ->get(); + + $nodes->linkNodes(); + + $nokia = $nodes->find($this->key(6)); + + $this->assertTrue($nokia->relationLoaded('parent')); + $this->assertSame($this->key(5), $nokia->parent->getKey()); + } + + public function testLinkedTreeIsJsonSerializable(): void + { + $tree = $this->category::defaultOrder()->get()->toTree(); + $decoded = json_decode($tree->toJson(), true, flags: JSON_THROW_ON_ERROR); + + $this->assertSame($this->key(1), $decoded[0]['id']); + $this->assertSame($this->key(1), $decoded[0]['children'][0]['parent']['id']); + $this->assertArrayNotHasKey('children', $decoded[0]['children'][0]['parent']); + } + + public function testToFlatTreeHandlesDeepTreeIteratively(): void + { + $nodes = []; + + for ($id = 1; $id <= 2000; ++$id) { + $nodes[] = $this->makeCollectionNode( + $this->key($id), + $id, + 4001 - $id, + $id === 1 ? null : $this->key($id - 1), + ); + } + + $flat = (new Collection($nodes))->toFlatTree(); + + $this->assertSame($this->keys(...range(1, 2000)), $flat->modelKeys()); + } + + public function testToTreeBuildsWithDefaultOrderAndMultipleRootNodes(): void + { + $tree = $this->category::withoutRoot()->defaultOrder()->get()->toTree(); + + $this->assertSame($this->keys(2, 5), $tree->modelKeys()); + } + + public function testToTreeBuildsWithRootItemIdProvided(): void + { + $tree = $this->category::whereBetween('_lft', [8, 17])->defaultOrder()->get()->toTree($this->key(5)); + + $this->assertEquals(4, count($tree)); + + $root = $tree[1]; + $this->assertEquals('samsung', $root->name); + $this->assertEquals(1, count($root->children)); + } + + public function testRetrievesNextNode(): void + { + $node = $this->findCategory('apple'); + $next = $node->getNextNode(); + + $this->assertEquals('lenovo', $next->name); + } + + public function testRetrievesPrevNode(): void + { + $node = $this->findCategory('apple'); + $next = $node->getPrevNode(); + + $this->assertEquals('notebooks', $next->name); + } + + public function testGetNextNodeCrossesSubtreeBoundary(): void + { + $this->assertSame('sony', $this->findCategory('galaxy')->getNextNode()->name); + } + + public function testGetPrevNodeCrossesSubtreeBoundary(): void + { + $this->assertSame('galaxy', $this->findCategory('sony')->getPrevNode()->name); + } + + public function testWhereIsBeforeAndAfterById(): void + { + $before = $this->category::whereIsBefore($this->key(4)) + ->defaultOrder() + ->pluck('name') + ->all(); + $after = $this->category::whereIsAfter($this->key(4)) + ->defaultOrder() + ->pluck('name') + ->all(); + + $this->assertSame(['store', 'notebooks', 'apple'], $before); + $this->assertSame(['mobile', 'nokia', 'samsung', 'galaxy', 'sony', 'lenovo', 'store_2'], $after); + } + + public function testStructuralCoordinateLookupsRespectTheOuterSoftDeleteMode(): void + { + DB::table('categories') + ->whereIn('id', $this->keys(3, 5)) + ->update(['deleted_at' => CarbonImmutable::now()]); + + $this->assertSame( + $this->keys(1, 2, 3, 4), + $this->category::withTrashed()->whereIsBefore($this->key(5))->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(3), + $this->category::onlyTrashed()->whereIsBefore($this->key(5))->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 5, 7), + $this->category::withTrashed()->whereAncestorOf($this->key(8))->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(5), + $this->category::onlyTrashed()->whereAncestorOf($this->key(8))->pluck('id')->all(), + ); + } + + public function testMultipleAppendageWorks(): void + { + $parent = $this->findCategory('mobile'); + $child = new $this->category(['name' => 'test']); + $subchild = new $this->category(['name' => 'sub']); + $sibling = new $this->category(['name' => 'test2']); + + $parent->appendNode($child); + $child->appendNode($subchild); + $parent->appendNode($sibling); + + $this->assertSame($parent->getKey(), $child->getParentId()); + $this->assertSame($child->getKey(), $subchild->getParentId()); + $this->assertSame($parent->getKey(), $sibling->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testDefaultCategoryIsSavedAsRoot(): void + { + $node = new $this->category(['name' => 'test']); + $node->save(); + + $this->assertEquals(23, $node->_lft); + $this->assertTreeNotBroken(); + + $this->assertTrue($node->isRoot()); + } + + public function testExistingCategorySavedAsRoot(): void + { + $node = $this->findCategory('samsung'); + $node->saveAsRoot(); + + $this->assertSame(0, $this->category::findOrFail($node->getKey())->getDepth()); + $this->assertSame(1, $this->findCategory('galaxy')->getDepth()); + $this->assertTreeNotBroken(); + $this->assertTrue($node->isRoot()); + } + + public function testSavingPartiallySelectedChildAsRootHydratesItsParentage(): void + { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $this->assertTrue($node->saveAsRoot()); + + $node = $this->category::findOrFail($this->key(7)); + + $this->assertNull($node->getParentId()); + $this->assertSame(0, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testSavingPartiallySelectedRootAsRootDoesNotReorderIt(): void + { + $before = $this->category::findOrFail($this->key(1))->getBounds(); + $node = $this->category::query()->select(['id'])->findOrFail($this->key(1)); + + $this->assertTrue($node->saveAsRoot()); + + $this->assertSame($before, $this->category::findOrFail($this->key(1))->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testSavingNewExplicitRootAssignsValidBounds(): void + { + $node = new $this->category(['name' => 'explicit root']); + $node->parent_id = null; + + $this->assertTrue($node->saveAsRoot()); + + $this->assertGreaterThan(0, $node->getLft()); + $this->assertSame($node->getLft() + 1, $node->getRgt()); + $this->assertSame(0, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testMakeRootAlwaysRepositionsAnExistingRoot(): void + { + $node = $this->category::findOrFail($this->key(1)); + + $this->assertTrue($node->makeRoot()->save()); + + $this->assertSame([3, 22], $node->getBounds()); + $this->assertNull($node->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testAssigningNullParentToPartialChildDoesNotLookLikeAnExistingRoot(): void + { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $node->parent_id = null; + $node->save(); + + $this->assertNull($this->category::findOrFail($this->key(7))->getParentId()); + $this->assertSame(0, $this->category::findOrFail($this->key(7))->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testMultipleRootNodesAreSiblings(): void + { + $store = $this->findCategory('store'); + $store2 = $this->findCategory('store_2'); + + $this->assertTrue($store->isSiblingOf($store2)); + $this->assertTrue($store2->isSiblingOf($store)); + } + + public function testMultipleRootNodesAreNotChildren(): void + { + $store = $this->findCategory('store'); + $store2 = $this->findCategory('store_2'); + + $this->assertFalse($store->isChildOf($store2)); + $this->assertFalse($store2->isChildOf($store)); + } + + public function testMultipleRootNodesInToTree(): void + { + $tree = $this->category::defaultOrder()->get()->toTree(); + + $this->assertCount(2, $tree); + $this->assertSame('store', $tree->first()->name); + $this->assertSame('store_2', $tree->last()->name); + } + + public function testMultipleRootNodesInToFlatTree(): void + { + $tree = $this->category::defaultOrder()->get()->toFlatTree(); + + $this->assertCount(11, $tree); + $this->assertSame('store', $tree->first()->name); + $this->assertSame('store_2', $tree->last()->name); + } + + public function testNewRootNodeIsSiblingOfExisting(): void + { + $node = new $this->category(['name' => 'store_3']); + $node->save(); + + $this->assertTreeNotBroken(); + $this->assertTrue($node->isRoot()); + + $store = $this->findCategory('store'); + + $this->assertTrue($node->isSiblingOf($store)); + $this->assertTrue($store->isSiblingOf($node)); + } + + public function testSetParentIdToNullKeepsRoot(): void + { + $store = $this->findCategory('store'); + $store->parent_id = null; + + $this->assertTrue($store->isRoot()); + $this->assertFalse($store->isDirty()); + $this->assertTreeNotBroken(); + } + + public function testChildIsNotSiblingOfRoot(): void + { + $store = $this->findCategory('store'); + $notebooks = $this->findCategory('notebooks'); + + $this->assertFalse($store->isSiblingOf($notebooks)); + $this->assertFalse($notebooks->isSiblingOf($store)); + } + + public function testNodeMovesDownSeveralPositions(): void + { + $node = $this->findCategory('nokia'); + + $this->assertTrue($node->down(2)); + + $this->assertEquals($node->_lft, 15); + } + + public function testNodeMovesUpSeveralPositions(): void + { + $node = $this->findCategory('sony'); + + $this->assertTrue($node->up(2)); + + $this->assertEquals($node->_lft, 9); + } + + #[DataProvider('nonPositiveMoveAmounts')] + public function testNonPositiveSiblingMovementIsANoOp(string $method, int $amount): void + { + $node = $this->category::findOrFail($this->key(9)); + DB::flushQueryLog(); + + $this->assertFalse($node->{$method}($amount)); + $this->assertSame([], DB::getQueryLog()); + } + + /** + * Get sibling movements with non-positive amounts. + */ + public static function nonPositiveMoveAmounts(): array + { + return [ + 'up zero' => ['up', 0], + 'up negative' => ['up', -1], + 'down zero' => ['down', 0], + 'down negative' => ['down', -1], + ]; + } + + public function testCountsTreeErrors(): void + { + $this->assertTreeNotBroken(); + + $this->category::where('id', '=', $this->key(5))->update(['_lft' => 14]); + $this->category::where('id', '=', $this->key(8))->update(['parent_id' => $this->key(2)]); + $this->category::where('id', '=', $this->key(11))->update(['_lft' => 20]); + $this->category::where('id', '=', $this->key(4))->update(['parent_id' => $this->key(24)]); + + $this->assertSame([ + 'invalid_intervals' => 1, + 'duplicate_endpoints' => 2, + 'missing_endpoints' => 0, + 'crossing_intervals' => 0, + 'missing_parent' => 1, + 'wrong_parent' => 3, + 'wrong_depth' => 1, + ], $this->category::countErrors()); + } + + public function testCountsWrongParentErrors(): void + { + $this->category::where('id', '=', $this->key(8))->update(['parent_id' => $this->key(5)]); + + $this->assertSame([ + 'invalid_intervals' => 0, + 'duplicate_endpoints' => 0, + 'missing_endpoints' => 0, + 'crossing_intervals' => 0, + 'missing_parent' => 0, + 'wrong_parent' => 1, + 'wrong_depth' => 1, + ], $this->category::countErrors()); + } + + public function testCountsNestedWrongParentErrors(): void + { + $this->category::where('id', '=', $this->key(8))->update(['parent_id' => $this->key(1)]); + + $errors = $this->category::countErrors(); + + // A misparented node counts once, however many nodes lie between it and its stored parent. + $this->assertSame(1, $errors['wrong_parent']); + $this->assertSame(1, $errors['wrong_depth']); + + // With a matching depth, the node's endpoints disagree with the intervals around them. + $this->category::where('id', '=', $this->key(8))->update(['depth' => 1]); + + $errors = $this->category::countErrors(); + + $this->assertSame(0, $errors['wrong_parent']); + $this->assertSame(0, $errors['wrong_depth']); + $this->assertSame(2, $errors['crossing_intervals']); + } + + public function testIsBrokenDetectsWrongParentErrors(): void + { + $this->category::where('id', '=', $this->key(8))->update(['parent_id' => $this->key(5)]); + + $this->assertTrue($this->category::isBroken()); + } + + public function testIsBrokenDetectsDuplicateErrors(): void + { + $this->category::where('id', '=', $this->key(11))->update(['_lft' => 3]); + + DB::flushQueryLog(); + + $this->assertTrue($this->category::isBroken()); + + foreach (DB::getQueryLog() as $query) { + $this->assertStringNotContainsString('join', strtolower($query['query'])); + } + } + + public function testIsBrokenShortCircuitsOnOddness(): void + { + $this->category::where('id', '=', $this->key(5))->update([ + '_lft' => 14, + '_rgt' => 13, + ]); + + DB::flushQueryLog(); + + $this->assertTrue($this->category::isBroken()); + + $queries = DB::getQueryLog(); + + $this->assertCount(1, $queries); + $this->assertStringContainsString('_lft', $queries[0]['query']); + $this->assertStringNotContainsString('join', strtolower($queries[0]['query'])); + } + + public function testCountsInvalidIntervals(): void + { + $this->category::whereKey($this->key(3))->update(['_lft' => 0]); + + $this->assertSame(1, $this->category::countErrors()['invalid_intervals']); + $this->assertTrue($this->category::isBroken()); + } + + public function testCountsDuplicateEndpointsAndSkipsAmbiguousCrossingAnalysis(): void + { + $this->category::whereKey($this->key(4))->update(['_lft' => 3]); + + $errors = $this->category::countErrors(); + + $this->assertSame(1, $errors['duplicate_endpoints']); + $this->assertSame(0, $errors['crossing_intervals']); + } + + public function testCountsMissingEndpointRanges(): void + { + DB::table('categories')->where('id', $this->key(4))->delete(); + + $this->assertSame(1, $this->category::countErrors()['missing_endpoints']); + } + + public function testCountsCrossingIntervalsWithUniqueContiguousEndpoints(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'a', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'b', '_lft' => 2, '_rgt' => 5, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(3), 'name' => 'c', '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'depth' => 0], + ]); + + $errors = $this->category::countErrors(); + + $this->assertSame(0, $errors['invalid_intervals']); + $this->assertSame(0, $errors['duplicate_endpoints']); + $this->assertSame(0, $errors['missing_endpoints']); + $this->assertGreaterThan(0, $errors['crossing_intervals']); + } + + public function testCreatesNode(): void + { + $node = $this->category::create(['name' => 'test']); + + $this->assertEquals(23, $node->getLft()); + } + + public function testCreatesViaRelationship(): void + { + $node = $this->findCategory('apple'); + + $child = $node->children()->create(['name' => 'test']); + + $this->assertSame($node->getKey(), $child->getParentId()); + $this->assertSame(3, $child->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testCreatesTree(): void + { + $node = $this->category::create( + [ + 'name' => 'test', + 'children' => [ + ['name' => 'test2'], + ['name' => 'test3'], + ], + ] + ); + + $this->assertTreeNotBroken(); + + $this->assertTrue(isset($node->children)); + $this->assertSame($node->getKey(), $node->children[0]->parent->getKey()); + $this->assertSame($node->children[0]->parent, $node->children[1]->parent); + $this->assertSame([], $node->children[0]->parent->getRelations()); + $this->assertSame($node->getBounds(), $node->children[0]->parent->getBounds()); + json_decode($node->toJson(), true, flags: JSON_THROW_ON_ERROR); + + $node = $this->findCategory('test'); + + $this->assertSame(['test2', 'test3'], $node->children()->defaultOrder()->pluck('name')->all()); + } + + public function testRecursiveCreateUsesOnePreparationReadPerParticipantAndOneFinalRootRefresh(): void + { + DB::flushQueryLog(); + + $this->category::create([ + 'name' => 'test', + 'children' => [ + ['name' => 'one'], + ['name' => 'two'], + ['name' => 'three'], + ['name' => 'four'], + ], + ]); + + $this->assertSame(9, $this->countStructuralIdentityReloads()); + $this->assertTreeNotBroken(); + } + + public function testCreateReturnsBoundsWidenedByTheLastChildsDescendants(): void + { + $node = $this->category::create([ + 'name' => 'test', + 'children' => [ + ['name' => 'first'], + ['name' => 'last', 'children' => [['name' => 'grandchild']]], + ], + ]); + + $this->assertSame([23, 30], $node->getBounds()); + $this->assertSame($node->getBounds(), $this->findCategory('test')->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testDescendantsOfNonExistingNode(): void + { + $node = new $this->category; + + $this->assertTrue($node->getDescendants()->isEmpty()); + } + + #[DataProvider('nodeObjectPositionalQueries')] + public function testNodeObjectPositionalQueriesRequireLoadedBounds(string $method): void + { + $node = $this->category::query()->select(['id'])->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->category}] must have loaded bounds.", + ); + + $this->category::query()->{$method}($node)->get(); + } + + /** + * Get the positional query methods that accept a node. + */ + public static function nodeObjectPositionalQueries(): array + { + return [ + 'ancestors' => ['whereAncestorOf'], + 'descendants' => ['whereDescendantOf'], + 'before' => ['whereIsBefore'], + 'after' => ['whereIsAfter'], + ]; + } + + #[DataProvider('nodeObjectPositionalQueries')] + public function testNodeObjectPositionalQueriesRejectModelsWithoutNestedSet(string $method): void + { + $node = new class extends Model { + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageIs(sprintf( + 'Model [%s] must be node.', + $node::class, + )); + + $this->category::query()->{$method}($node)->get(); + } + + #[DataProvider('directNodePositionQueries')] + public function testDirectNodePositionQueriesRequireLoadedBounds(string $method): void + { + $node = $this->category::query()->select(['id', 'parent_id'])->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->category}] must have loaded bounds.", + ); + + $node->{$method}()->get(); + } + + /** + * Get the node's own positional query methods. + */ + public static function directNodePositionQueries(): array + { + return [ + 'next nodes' => ['nextNodes'], + 'previous nodes' => ['prevNodes'], + 'next siblings' => ['nextSiblings'], + 'previous siblings' => ['prevSiblings'], + ]; + } + + public function testDirectSiblingQueriesRequireLoadedParentage(): void + { + $node = $this->category::query() + ->select(['id', '_lft', '_rgt', 'depth']) + ->findOrFail($this->key(7)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->category}] must have a loaded parent.", + ); + + $node->nextSiblings()->get(); + } + + public function testNodeObjectPositionalQueriesRejectAnotherStore(): void + { + $node = $this->category::findOrFail($this->key(7)); + $node->setTable('other_categories'); + $connection = DB::getDefaultConnection(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->category}] uses connection [{$connection}] and table [other_categories], but query model [{$this->category}] uses connection [{$connection}] and table [categories].", + ); + + $this->category::query()->whereDescendantOf($node)->get(); + } + + public function testWhereDescendantsOf(): void + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessageIs( + "No query results for model [{$this->category}] {$this->key(124)}", + ); + + $this->category::whereDescendantOf($this->key(124))->get(); + } + + public function testAncestorsByNode(): void + { + $category = $this->findCategory('apple'); + $ancestors = $this->category::whereAncestorOf($category)->pluck('id')->all(); + + $this->assertEqualsCanonicalizing($this->keys(1, 2), $ancestors); + } + + public function testAncestorsByNodeWithoutSelectedKey(): void + { + $category = $this->category::query() + ->select(['_lft', '_rgt']) + ->where('name', '=', 'apple') + ->firstOrFail(); + + $this->assertNull($category->getKey()); + $this->assertSame( + $this->keys(1, 2), + $this->category::whereAncestorOf($category)->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(1, 2, 3), + $this->category::whereAncestorOf($category, true)->orderBy('id')->pluck('id')->all(), + ); + + $root = $this->category::query() + ->select(['_lft', '_rgt']) + ->findOrFail($this->key(1)); + + $this->assertTrue($this->category::whereAncestorOf($root)->get()->isEmpty()); + $this->assertSame($this->keys(1), $this->category::whereAncestorOf($root, true)->pluck('id')->all()); + } + + #[DataProvider('eagerRelatedProjections')] + public function testEagerMatchingUsesOnlyTruthfulRelatedProjections( + string $relation, + array $parentIds, + array $columns, + array $expected, + ?string $requiredColumn, + ): void { + $categories = $this->category::query()->whereIn('id', $this->keys(...$parentIds))->get(); + + if ($requiredColumn !== null) { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation eager load for [{$this->category}] requires the [{$requiredColumn}] column.", + ); + } + + $categories->load([ + $relation => fn (BaseRelation $query): BaseRelation => $query->select($columns), + ]); + + foreach ($expected as $parentId => $relatedIds) { + $this->assertSame( + $this->keys(...$relatedIds), + $categories->find($this->key($parentId)) + ->getRelation($relation) + ->pluck('id') + ->sort() + ->values() + ->all(), + ); + } + } + + /** + * Get eager related projections with their expected matches or missing column. + */ + public static function eagerRelatedProjections(): array + { + return [ + 'ancestor without left bound' => [ + 'ancestors', + [3], + ['id', '_rgt'], + [], + '_lft', + ], + 'ancestors without right bound across multiple parents' => [ + 'ancestors', + [3, 8], + ['id', '_lft'], + [], + '_rgt', + ], + 'descendants without left bound across multiple parents' => [ + 'descendants', + [2, 5], + ['id', '_rgt'], + [], + '_lft', + ], + 'descendants need no related right bound' => [ + 'descendants', + [2, 5], + ['id', '_lft'], + [2 => [3, 4], 5 => [6, 7, 8, 9, 10]], + null, + ], + ]; + } + + public function testDescendantsByNode(): void + { + $category = $this->findCategory('notebooks'); + $res = $this->category::whereDescendantOf($category)->pluck('id')->all(); + + $this->assertEqualsCanonicalizing($this->keys(3, 4), $res); + } + + public function testMultipleDeletionsDoNotBreakTree(): void + { + $category = $this->findCategory('mobile'); + + foreach ($category->children()->defaultOrder()->take(2)->get() as $child) { + $child->forceDelete(); + } + + $this->assertSame(0, $this->category::whereIn('id', $this->keys(6, 7, 8))->count()); + $this->assertSame(2, $this->category::whereIn('id', $this->keys(9, 10))->count()); + $this->assertTreeNotBroken(); + } + + public function testTreeIsFixed(): void + { + $this->category::where('id', '=', $this->key(5))->update(['_lft' => 14]); + $this->category::where('id', '=', $this->key(8))->update(['parent_id' => $this->key(2)]); + $this->category::where('id', '=', $this->key(11))->update(['_lft' => 20]); + $this->category::where('id', '=', $this->key(2))->update(['parent_id' => $this->key(24)]); + + $fixed = $this->category::fixTree(); + + $this->assertTrue($fixed > 0); + $this->assertTreeNotBroken(); + + $node = $this->category::find($this->key(8)); + + $this->assertEquals($this->key(2), $node->getParentId()); + + $node = $this->category::find($this->key(2)); + + $this->assertEquals(null, $node->getParentId()); + } + + public function testFixTreeRepairsBranchingParentBuckets(): void + { + $this->category::query()->update([ + '_lft' => 0, + '_rgt' => 0, + 'depth' => 0, + ]); + + $fixed = $this->category::fixTree(); + + $this->assertSame(11, $fixed); + $this->assertTreeNotBroken(); + + $galaxy = $this->category::find($this->key(8)); + + $this->assertSame(3, $galaxy->getDepth()); + $this->assertSame($this->keys(1, 5, 7), $galaxy->getAncestors()->pluck('id')->all()); + } + + public function testFixTreePromotesOrphanedBranchesToRoots(): void + { + $this->category::whereKey($this->key(2))->update(['parent_id' => $this->key(24)]); + + $this->category::fixTree(); + + $node = $this->category::find($this->key(2)); + + $this->assertNull($node->getParentId()); + $this->assertSame(0, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testFixTreePersistsExpandedRootBounds(): void + { + $root = $this->category::findOrFail($this->key(1)); + + $this->category::whereKey($this->key(11))->update(['parent_id' => $this->key(1)]); + $this->category::fixTree(); + + $root->refreshNode(); + + $this->assertSame(22, $root->getRgt()); + $this->assertTreeNotBroken(); + } + + public function testFixTreeSelectsExplicitObserverColumns(): void + { + $retrieved = []; + $saved = []; + + $this->category::retrieved(function (Category $model) use (&$retrieved): void { + $retrieved[] = $model->getKey(); + }); + $this->category::saving(function (Category $model) use (&$saved): void { + $saved[] = [$model->getKey(), $model->name]; + }); + + // Repair hydrates only the nodes it saves. + $this->assertSame(0, $this->category::fixTree(extraColumns: ['name'])); + $this->assertSame([], $retrieved); + $this->assertSame([], $saved); + + $this->category::whereKey($this->key(8))->update(['_lft' => 11]); + + $this->assertSame(1, $this->category::fixTree(extraColumns: ['name'])); + $this->assertSame([$this->key(8)], $retrieved); + $this->assertSame([[$this->key(8), 'galaxy']], $saved); + $this->assertTreeNotBroken(); + } + + public function testFixTreeVetoRollsBackEarlierRepairWrites(): void + { + $this->category::whereKey($this->key(2))->update(['parent_id' => null]); + + $before = DB::table('categories') + ->orderBy('id') + ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->map(fn (object $row): array => (array) $row) + ->all(); + $saves = 0; + $vetoedKey = null; + + $this->category::saving(function (Category $model) use (&$saves, &$vetoedKey): ?bool { + if (++$saves !== 2) { + return null; + } + + $vetoedKey = $model->getKey(); + + return false; + }); + + try { + DB::transaction(fn (): int => $this->category::fixTree()); + $this->fail('Expected the repair veto to propagate.'); + } catch (LogicException $exception) { + $this->assertSame( + sprintf( + 'Saving nested set node [%s] with key [%s] during repair was vetoed.', + $this->category, + $vetoedKey, + ), + $exception->getMessage(), + ); + } + + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + + public function testFixTreeHandlesDeepParentChainsIteratively(): void + { + DB::table('categories')->delete(); + + $rows = []; + $count = 200; + + for ($id = 1; $id <= $count; ++$id) { + $rows[] = [ + 'id' => $this->key($id), + 'name' => 'node ' . $id, + '_lft' => 0, + '_rgt' => 0, + 'parent_id' => $id === 1 ? null : $this->key($id - 1), + 'depth' => 0, + ]; + } + + DB::table('categories')->insert($rows); + + $fixed = $this->category::fixTree(); + + $root = $this->category::find($this->key(1)); + $leaf = $this->category::find($this->key($count)); + + $this->assertSame($count, $fixed); + $this->assertSame([1, $count * 2], $root->getBounds()); + $this->assertSame([$count, $count + 1], $leaf->getBounds()); + $this->assertSame($count - 1, $leaf->getDepth()); + $this->assertTreeNotBroken(); + } + + #[DataProvider('invalidSubtreeParents')] + public function testFixSubtreePromotesInvalidBranchesToChildrenOfTheSuppliedRoot( + ?int $parentId, + ): void { + $this->category::whereKey($this->key(6))->update([ + 'parent_id' => $parentId === null ? null : $this->key($parentId), + ]); + + $this->category::fixSubtree($root = $this->category::find($this->key(5))); + + $node = $this->category::find($this->key(6)); + + $this->assertSame($root->getKey(), $node->getParentId()); + $this->assertSame($root->getDepth() + 1, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + /** + * Get invalid parent keys for subtree repair. + */ + public static function invalidSubtreeParents(): array + { + return [ + 'missing parent' => [99], + 'parent outside subtree' => [1], + 'null parent' => [null], + ]; + } + + public function testFixSubtreeBreaksParentCyclesWithoutCorruptingIntervals(): void + { + $this->category::whereKey($this->key(7))->update(['parent_id' => $this->key(8)]); + $this->category::whereKey($this->key(8))->update(['parent_id' => $this->key(7)]); + + $this->category::fixSubtree($this->category::find($this->key(5))); + + $this->assertSame($this->key(5), $this->category::find($this->key(7))->getParentId()); + $this->assertSame($this->key(7), $this->category::find($this->key(8))->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testFixSubtreePersistsRowsShiftedByItsGapUpdate(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'first', '_lft' => 2, '_rgt' => 3, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'second', '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->key(1), 'depth' => 1], + ]); + + $saving = []; + + $this->category::saving(function (Category $model) use (&$saving): void { + $saving[] = $model->getKey(); + }); + + $fixed = $this->category::fixSubtree($this->category::findOrFail($this->key(1))); + + // The gap update counts the shifted child, which repair then saves back with the grown root. + $this->assertSame($this->keys(3, 1), $saving); + $this->assertSame(2, $fixed); + $this->assertSame([1, 6], $this->category::findOrFail($this->key(1))->getBounds()); + $this->assertSame([2, 3], $this->category::findOrFail($this->key(2))->getBounds()); + $this->assertSame([4, 5], $this->category::findOrFail($this->key(3))->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testFixSubtreeRejectsParentageOutsideItsStoredBounds(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'inside', '_lft' => 2, '_rgt' => 3, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'outside', '_lft' => 6, '_rgt' => 7, 'parent_id' => $this->key(1), 'depth' => 1], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set subtree for [{$this->category}] with key [{$this->key(1)}] has parentage that crosses its stored bounds.", + ); + + $this->category::fixSubtree($this->category::findOrFail($this->key(1))); + } + + #[DataProvider('subtreeOperations')] + public function testSubtreeOperationsHydratePartialRootFromThePersistedRow(string $operation): void + { + $root = $this->category::query() + ->select(['id', 'parent_id']) + ->findOrFail($this->key(5)); + $root->setRelation('children', new Collection([new $this->category])); + DB::flushQueryLog(); + + if ($operation === 'fix') { + $this->category::fixSubtree($root); + } else { + $this->category::rebuildSubtree($root, []); + } + + $this->assertSame([8, 19], $root->getBounds()); + $this->assertSame(1, $root->getDepth()); + $this->assertFalse($root->relationLoaded('children')); + $this->assertSame(1, $this->countStructuralIdentityReloads()); + $this->assertTreeNotBroken(); + } + + /** + * Get the subtree repair and rebuild operations. + */ + public static function subtreeOperations(): array + { + return [ + 'repair' => ['fix'], + 'rebuild' => ['rebuild'], + ]; + } + + #[DataProvider('invalidRepairRoots')] + public function testFixSubtreeRejectsUnpersistedOrKeylessRootBeforeWriting( + string $rootState, + string $messageFormat, + ): void { + $columns = ['id', 'name', '_lft', '_rgt', 'parent_id', 'depth']; + $before = DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(); + + if ($rootState === 'unpersisted') { + $root = new $this->category; + $root->setRawAttributes($this->category::findOrFail($this->key(5))->getAttributes()); + } else { + $root = $this->category::query() + ->select(['name', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail($this->key(5)); + } + + try { + $this->category::fixSubtree($root); + $this->fail('Expected the invalid subtree root to be rejected.'); + } catch (LogicException $exception) { + $this->assertSame( + sprintf($messageFormat, $this->category, $this->key(5)), + $exception->getMessage(), + ); + } + + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + + /** + * Get unpersisted or keyless roots for subtree repair. + */ + public static function invalidRepairRoots(): array + { + return [ + 'unpersisted root' => [ + 'unpersisted', + 'Nested set subtree repair root [%s] with key [%s] must be persisted.', + ], + 'keyless root' => [ + 'keyless', + 'Nested set model [%s] requires the [id] column to be selected.', + ], + ]; + } + + public function testSubtreeOperationsRejectRootsFromAnotherStore(): void + { + $root = $this->category::findOrFail($this->key(5)); + $root->setTable('other_categories'); + $connection = DB::getDefaultConnection(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set subtree repair root [{$this->category}] uses connection [{$connection}] and table [other_categories], but query model [{$this->category}] uses connection [{$connection}] and table [categories].", + ); + + $this->category::fixSubtree($root); + } + + #[DataProvider('invalidSubtreeRootBounds')] + public function testSubtreeOperationsRejectInvalidStoredRootBounds( + string $operation, + int $lft, + int $rgt, + ): void { + DB::table('categories')->where('id', '=', $this->key(5))->update([ + '_lft' => $lft, + '_rgt' => $rgt, + ]); + $root = $this->category::findOrFail($this->key(5)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set subtree for [{$this->category}] with key [{$this->key(5)}] has invalid stored bounds.", + ); + + $operation === 'fix' + ? $this->category::fixSubtree($root) + : $this->category::rebuildSubtree($root, []); + } + + /** + * Get subtree operations with invalid stored root bounds. + */ + public static function invalidSubtreeRootBounds(): array + { + return [ + 'repair with inverted bounds' => ['fix', 9, 8], + 'rebuild with inverted bounds' => ['rebuild', 9, 8], + 'repair with non-positive bounds' => ['fix', 0, 1], + 'rebuild with non-positive bounds' => ['rebuild', 0, 1], + ]; + } + + public function testSubtreeRepairRejectsAMissingRootThroughModelNotFound(): void + { + $root = $this->category::findOrFail($this->key(5)); + DB::table('categories')->where('id', '=', $this->key(5))->delete(); + + $this->expectException(ModelNotFoundException::class); + + $this->category::fixSubtree($root); + } + + public function testSubtreeRepairUsesTheCurrentPersistedRootCoordinates(): void + { + $root = $this->category::findOrFail($this->key(5)); + $root->setLft(2)->setRgt(3)->setDepth(99); + + $this->category::fixSubtree($root); + + $this->assertSame([8, 19], $root->getBounds()); + $this->assertSame(1, $root->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testSubtreeIsFixed(): void + { + $this->category::where('id', '=', $this->key(8))->update(['_lft' => 11]); + + $fixed = $this->category::fixSubtree($this->category::find($this->key(5))); + $this->assertEquals($fixed, 1); + $this->assertTreeNotBroken(); + $this->assertEquals($this->category::find($this->key(8))->getLft(), 12); + } + + public function testParentIdDirtiness(): void + { + $node = $this->findCategory('apple'); + $node->parent_id = $this->key(5); + + $this->assertTrue($node->isDirty('parent_id')); + + $node = $this->findCategory('apple'); + $node->parent_id = null; + + $this->assertTrue($node->isDirty('parent_id')); + } + + public function testIsDirtyMovement(): void + { + $node = $this->findCategory('apple'); + $otherNode = $this->findCategory('samsung'); + + $this->assertFalse($node->isDirty()); + + $node->afterNode($otherNode); + + $this->assertTrue($node->isDirty()); + + $node = $this->findCategory('apple'); + $otherNode = $this->findCategory('samsung'); + + $this->assertFalse($node->isDirty()); + + $node->appendToNode($otherNode); + + $this->assertTrue($node->isDirty()); + } + + public function testRootNodesMoving(): void + { + $node = $this->findCategory('store'); + $node->down(); + + $this->assertEquals(3, $node->getLft()); + } + + public function testDescendantsRelation(): void + { + $node = $this->findCategory('notebooks'); + $result = $node->descendants; + + $this->assertEqualsCanonicalizing(['apple', 'lenovo'], $result->pluck('name')->all()); + } + + public function testDescendantsEagerlyLoaded(): void + { + $nodes = $this->category::whereIn('id', $this->keys(2, 5))->get(); + + $nodes->load('descendants'); + + $this->assertEquals(2, $nodes->count()); + $this->assertTrue($nodes->first()->relationLoaded('descendants')); + } + + public function testNestedDescendantEagerLoadConstraintsAreDeduplicated(): void + { + $nodes = $this->category::whereIn('id', $this->keys(1, 5)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('descendants'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + $this->assertEquals(1, substr_count($relationQuery, ' between ')); + $this->assertEquals(9, $nodes->find($this->key(1))->descendants->count()); + $this->assertEquals(5, $nodes->find($this->key(5))->descendants->count()); + $this->assertTrue($nodes->find($this->key(5))->descendants->contains('id', $this->key(8))); + } + + public function testDisjointDescendantEagerLoadConstraintsAreRetained(): void + { + $nodes = $this->category::whereIn('id', $this->keys(2, 5)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('descendants'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + $this->assertEquals(2, substr_count($relationQuery, ' between ')); + $this->assertEqualsCanonicalizing($this->keys(3, 4), $nodes->find($this->key(2))->descendants->pluck('id')->all()); + $this->assertTrue($nodes->find($this->key(5))->descendants->contains('id', $this->key(8))); + } + + #[DataProvider('customOrderedDescendantParents')] + public function testIndexedDescendantEagerMatchingPreservesResultOrder(array $parentIds): void + { + $nodes = $this->category::whereIn('id', $this->keys(...$parentIds)) + ->defaultOrder() + ->get(); + + $nodes->load(['descendants' => fn (DescendantsRelation $query): DescendantsRelation => $query->orderBy('name')]); + + $expected = [ + 1 => ['apple', 'galaxy', 'lenovo', 'lenovo', 'mobile', 'nokia', 'notebooks', 'samsung', 'sony'], + 5 => ['galaxy', 'lenovo', 'nokia', 'samsung', 'sony'], + ]; + + foreach ($parentIds as $parentId) { + $this->assertEquals( + $expected[$parentId], + $nodes->find($this->key($parentId))->descendants->pluck('name')->all(), + ); + } + } + + /** + * Get parent keys for custom-ordered descendant eager loading. + */ + public static function customOrderedDescendantParents(): array + { + return [ + 'one parent' => [[5]], + 'nested parents' => [[1, 5]], + ]; + } + + public function testIndexedDescendantEagerMatchingPreservesDefaultResultOrder(): void + { + $nodes = $this->category::whereIn('id', $this->keys(2, 5)) + ->defaultOrder() + ->get(); + + $nodes->load(['descendants' => fn (DescendantsRelation $query): DescendantsRelation => $query->defaultOrder()]); + + $this->assertEquals( + ['apple', 'lenovo'], + $nodes->find($this->key(2))->descendants->pluck('name')->all(), + ); + $this->assertEquals( + ['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], + $nodes->find($this->key(5))->descendants->pluck('name')->all(), + ); + } + + #[DataProvider('customOrderedAncestorParents')] + public function testIndexedAncestorEagerMatchingPreservesResultOrder(array $parentIds): void + { + $nodes = $this->category::whereIn('id', $this->keys(...$parentIds)) + ->defaultOrder() + ->get(); + + $nodes->load(['ancestors' => fn (AncestorsRelation $query): AncestorsRelation => $query->reorder('name')]); + + $expected = [ + 3 => ['notebooks', 'store'], + 8 => ['mobile', 'samsung', 'store'], + ]; + + foreach ($parentIds as $parentId) { + $this->assertEquals( + $expected[$parentId], + $nodes->find($this->key($parentId))->ancestors->pluck('name')->all(), + ); + } + } + + /** + * Get parent keys for custom-ordered ancestor eager loading. + */ + public static function customOrderedAncestorParents(): array + { + return [ + 'one parent' => [[8]], + 'disjoint parents' => [[3, 8]], + ]; + } + + public function testEagerDescendantsSkipParentsWithoutPossibleDescendants(): void + { + $leaves = $this->category::whereIn('id', $this->keys(3, 4, 8))->get(); + + DB::flushQueryLog(); + + $leaves->load('descendants'); + + $this->assertSame([], DB::getQueryLog()); + + foreach ($leaves as $leaf) { + $this->assertTrue($leaf->relationLoaded('descendants')); + $this->assertTrue($leaf->descendants->isEmpty()); + } + + $nodes = $this->category::whereIn('id', $this->keys(2, 3, 8))->get(); + + DB::flushQueryLog(); + + $nodes->load('descendants'); + + $queries = DB::getQueryLog(); + + $this->assertCount(1, $queries); + $this->assertEquals(1, substr_count(strtolower($queries[0]['query']), ' between ')); + $this->assertEqualsCanonicalizing($this->keys(3, 4), $nodes->find($this->key(2))->descendants->pluck('id')->all()); + $this->assertTrue($nodes->find($this->key(3))->descendants->isEmpty()); + $this->assertTrue($nodes->find($this->key(8))->descendants->isEmpty()); + } + + public function testEagerRelationsHandleMoreThanAThousandParentIntervals(): void + { + DB::table('categories')->delete(); + + $branches = 1100; + $rows = [ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => $branches * 4 + 2, 'parent_id' => null, 'depth' => 0], + ]; + + for ($branch = 0; $branch < $branches; ++$branch) { + $lft = $branch * 4 + 2; + $rows[] = ['id' => $this->key($branch * 2 + 2), 'name' => 'branch', '_lft' => $lft, '_rgt' => $lft + 3, 'parent_id' => $this->key(1), 'depth' => 1]; + $rows[] = ['id' => $this->key($branch * 2 + 3), 'name' => 'leaf', '_lft' => $lft + 1, '_rgt' => $lft + 2, 'parent_id' => $this->key($branch * 2 + 2), 'depth' => 2]; + } + + foreach (array_chunk($rows, 500) as $chunk) { + DB::table('categories')->insert($chunk); + } + + $nodes = $this->category::defaultOrder()->get(); + $branchNodes = $nodes->where('depth', 1)->values()->load('descendants'); + $leaves = $nodes->where('depth', 2)->values()->load('ancestors'); + + $this->assertSame( + array_map(fn (int $branch): array => [$this->key($branch * 2 + 3)], range(0, $branches - 1)), + $branchNodes->map(fn (Category $node): array => $node->descendants->modelKeys())->all(), + ); + $this->assertSame( + array_map(fn (int $branch): array => [$this->key(1), $this->key($branch * 2 + 2)], range(0, $branches - 1)), + $leaves->map(fn (Category $node): array => $node->ancestors->modelKeys())->all(), + ); + } + + public function testDescendantsRelationQuery(): void + { + $nodes = $this->category::has('descendants')->whereIn('id', $this->keys(2, 3))->get(); + + $this->assertEquals(1, $nodes->count()); + $this->assertEquals($this->key(2), $nodes->first()->getKey()); + + $nodes = $this->category::has('descendants', '>', 2)->defaultOrder()->get(); + + $this->assertEquals(2, $nodes->count()); + $this->assertEquals($this->key(1), $nodes[0]->getKey()); + $this->assertEquals($this->key(5), $nodes[1]->getKey()); + } + + public function testParentRelationQuery(): void + { + $nodes = $this->category::has('parent')->whereIn('id', $this->keys(1, 2)); + + $this->assertEquals(1, $nodes->count()); + $this->assertEquals($this->key(2), $nodes->first()->getKey()); + } + + public function testSiblingsRelation(): void + { + $node = $this->findCategory('samsung'); + $result = $node->siblings; + + $this->assertEquals(3, $result->count()); + $this->assertEqualsCanonicalizing($this->keys(6, 9, 10), $result->pluck('id')->all()); + $this->assertTrue($node->relationLoaded('siblings')); + } + + public function testSiblingsAndSelfRelation(): void + { + $node = $this->findCategory('samsung'); + $result = $node->siblingsAndSelf; + + $this->assertEquals(4, $result->count()); + $this->assertEqualsCanonicalizing($this->keys(6, 7, 9, 10), $result->pluck('id')->all()); + $this->assertTrue($node->relationLoaded('siblingsAndSelf')); + } + + public function testSiblingsEagerlyLoaded(): void + { + $nodes = $this->category::whereIn('id', $this->keys(2, 5))->get(); + + $nodes->load('siblings'); + + $this->assertEquals(2, $nodes->count()); + $this->assertTrue($nodes->first()->relationLoaded('siblings')); + $this->assertEquals($this->keys(5), $nodes->find($this->key(2))->siblings->pluck('id')->all()); + $this->assertEquals($this->keys(2), $nodes->find($this->key(5))->siblings->pluck('id')->all()); + } + + public function testSiblingEagerLoadUsesParentIdSetConstraint(): void + { + $nodes = $this->category::whereIn('id', $this->keys(3, 7)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('siblings'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + $this->assertStringContainsString(' in ', $relationQuery); + $this->assertStringNotContainsString(' or ', $relationQuery); + $this->assertEquals($this->keys(4), $nodes->find($this->key(3))->siblings->pluck('id')->all()); + $this->assertEqualsCanonicalizing($this->keys(6, 9, 10), $nodes->find($this->key(7))->siblings->pluck('id')->all()); + } + + public function testRootSiblingEagerLoadUsesNullParentConstraint(): void + { + $nodes = $this->category::whereIn('id', $this->keys(1, 11)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('siblings'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + $this->assertMatchesRegularExpression('/parent_id\W+is null/', $relationQuery); + $this->assertEquals($this->keys(11), $nodes->find($this->key(1))->siblings->pluck('id')->all()); + $this->assertEquals($this->keys(1), $nodes->find($this->key(11))->siblings->pluck('id')->all()); + } + + public function testSiblingsAndSelfEagerlyLoaded(): void + { + $nodes = $this->category::whereIn('id', $this->keys(3, 7))->get(); + + $nodes->load('siblingsAndSelf'); + + $this->assertEquals(2, $nodes->count()); + $this->assertTrue($nodes->first()->relationLoaded('siblingsAndSelf')); + $this->assertEqualsCanonicalizing($this->keys(3, 4), $nodes->find($this->key(3))->siblingsAndSelf->pluck('id')->all()); + $this->assertEqualsCanonicalizing($this->keys(6, 7, 9, 10), $nodes->find($this->key(7))->siblingsAndSelf->pluck('id')->all()); + } + + #[DataProvider('customOrderedSiblingRelations')] + public function testIndexedSiblingEagerMatchingPreservesResultOrder(string $relation, array $expected): void + { + $nodes = $this->category::whereIn('id', $this->keys(6, 7, 9, 10)) + ->defaultOrder() + ->get(); + + $nodes->load([$relation => fn (SiblingsRelation $query): SiblingsRelation => $query->orderBy('name')]); + + $samsung = $nodes->find($this->key(7)); + $sonySiblings = $nodes->find($this->key(9))->{$relation}; + $sonyNames = $sonySiblings->pluck('name')->all(); + + $this->assertEquals($expected, $samsung->{$relation}->pluck('name')->all()); + + // Parents with the same siblings still receive independent collections. + $samsung->{$relation}->pop(); + + $this->assertEquals($sonyNames, $sonySiblings->pluck('name')->all()); + } + + /** + * Get sibling relations with their expected custom-ordered names. + */ + public static function customOrderedSiblingRelations(): array + { + return [ + 'siblings' => ['siblings', ['lenovo', 'nokia', 'sony']], + 'siblings and self' => ['siblingsAndSelf', ['lenovo', 'nokia', 'samsung', 'sony']], + ]; + } + + public function testSiblingsRelationQuery(): void + { + $this->assertEquals( + $this->keys(3), + $this->category::has('siblings')->whereIn('id', $this->keys(3, 8))->pluck('id')->all(), + ); + + $this->assertEquals( + $this->keys(6, 7, 9, 10), + $this->category::has('siblings', '>', 2)->orderBy('id')->pluck('id')->all(), + ); + + $this->assertEquals( + $this->keys(1, 11), + $this->category::has('siblings')->whereIn('id', $this->keys(1, 11))->orderBy('id')->pluck('id')->all(), + ); + } + + public function testSiblingsOfRootNode(): void + { + $node = $this->findCategory('store'); + $result = $node->siblings; + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(11), $result->first()->getKey()); + } + + public function testRebuildTree(): void + { + $root = $this->category::findOrFail($this->key(1)); + + $fixed = $this->category::rebuildTree([ + [ + 'id' => $this->key(1), + 'children' => [ + ['id' => $this->key(10)], + [ + 'id' => $this->key(3), + 'name' => 'apple v2', + 'parent_id' => $this->key(999), + '_lft' => 999, + '_rgt' => 1000, + 'depth' => 99, + 'children' => [['name' => 'new node']], + ], + ['id' => $this->key(2)], + ], + ], + ]); + + $this->assertTrue($fixed > 0); + $this->assertTreeNotBroken(); + + $root->refreshNode(); + + $this->assertSame($this->category::findOrFail($this->key(1))->getRgt(), $root->getRgt()); + + $node = $this->category::find($this->key(3)); + + $this->assertEquals($this->key(1), $node->getParentId()); + $this->assertEquals('apple v2', $node->name); + $this->assertEquals(4, $node->getLft()); + $this->assertSame(1, $node->getDepth()); + + $node = $this->findCategory('new node'); + + $this->assertNotNull($node); + $this->assertEquals($this->key(3), $node->getParentId()); + } + + public function testRebuildTreeHandlesNestedPayloadAttributes(): void + { + $this->category::rebuildTree([ + [ + 'id' => $this->key(1), + 'name' => 'store v2', + 'children' => [ + ['id' => $this->key(2), 'name' => 'notebooks v2'], + ], + ], + ]); + + $this->assertTreeNotBroken(); + $this->assertSame('store v2', $this->category::find($this->key(1))->name); + $this->assertSame('notebooks v2', $this->category::find($this->key(2))->name); + } + + public function testUnchangedRebuildDoesNotWriteOrReloadNodeIdentity(): void + { + $this->resetRebuildQueryFixture(); + DB::flushQueryLog(); + + $this->category::rebuildTree([ + [ + 'id' => $this->key(1), + 'children' => [ + ['id' => $this->key(2)], + ['id' => $this->key(3), 'children' => [['id' => $this->key(4)]]], + ], + ], + ]); + + $queries = array_column(DB::getQueryLog(), 'query'); + + $this->assertCount(1, $queries); + $this->assertMatchesRegularExpression('/^select \* from /i', $queries[0]); + } + + public function testChangedRebuildDoesNotReloadNodeIdentityPerRow(): void + { + $this->resetRebuildQueryFixture(); + DB::flushQueryLog(); + + $this->category::rebuildTree([ + [ + 'id' => $this->key(1), + 'children' => [ + ['id' => $this->key(4)], + ['id' => $this->key(2)], + ['id' => $this->key(3)], + ], + ], + ]); + + $queries = array_column(DB::getQueryLog(), 'query'); + + $this->assertFalse(collect($queries)->contains( + static fn (string $query): bool => preg_match( + '/^select .*_lft.*_rgt.*depth.*parent_id.*limit 1$/i', + $query, + ) === 1, + )); + $this->assertSame($this->key(1), $this->category::findOrFail($this->key(4))->getParentId()); + $this->assertTreeNotBroken(); + } + + public function testRebuildSubtree(): void + { + $root = $this->category::with('children')->findOrFail($this->key(7)); + $fixed = $this->category::rebuildSubtree($root, [ + ['name' => 'new node'], + ['id' => (string) $this->key(8)], + ]); + + $this->assertTrue($fixed > 0); + $this->assertTrue($root->hasMoved()); + $this->assertFalse($root->relationLoaded('children')); + $this->assertTreeNotBroken(); + + $node = $this->findCategory('new node'); + + $this->assertNotNull($node); + $this->assertEquals($node->getLft(), 12); + } + + public function testRebuildSubtreePersistsRowsShiftedByItsGapUpdate(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'first', '_lft' => 2, '_rgt' => 3, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'second', '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->key(1), 'depth' => 1], + ]); + + $saving = []; + + $this->category::saving(function (Category $model) use (&$saving): void { + $saving[] = $model->getKey(); + }); + + $fixed = $this->category::rebuildSubtree($this->category::findOrFail($this->key(1)), [ + ['id' => $this->key(2)], + ['id' => $this->key(3)], + ]); + + // Both children are saved with their data; after the gap update, only the shifted child and the grown root are. + $this->assertSame($this->keys(2, 3, 3, 1), $saving); + $this->assertSame(2, $fixed); + $this->assertSame([1, 6], $this->category::findOrFail($this->key(1))->getBounds()); + $this->assertSame([2, 3], $this->category::findOrFail($this->key(2))->getBounds()); + $this->assertSame([4, 5], $this->category::findOrFail($this->key(3))->getBounds()); + $this->assertTreeNotBroken(); + } + + public function testRebuildSubtreeRejectsParentageOutsideItsStoredBounds(): void + { + DB::table('categories')->delete(); + DB::table('categories')->insert([ + ['id' => $this->key(1), 'name' => 'root', '_lft' => 1, '_rgt' => 4, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(2), 'name' => 'inside', '_lft' => 2, '_rgt' => 3, 'parent_id' => $this->key(1), 'depth' => 1], + ['id' => $this->key(3), 'name' => 'outside', '_lft' => 6, '_rgt' => 7, 'parent_id' => $this->key(1), 'depth' => 1], + ]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set subtree for [{$this->category}] with key [{$this->key(1)}] has parentage that crosses its stored bounds.", + ); + + $this->category::rebuildSubtree($this->category::findOrFail($this->key(1)), []); + } + + public function testRebuildSubtreeRepairsAnExistingOrphanedBranch(): void + { + $this->category::whereKey($this->key(8))->update(['parent_id' => $this->key(99)]); + + $this->category::rebuildSubtree($root = $this->category::find($this->key(7)), []); + + $node = $this->category::find($this->key(8)); + + $this->assertSame($root->getKey(), $node->getParentId()); + $this->assertSame($root->getDepth() + 1, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void + { + $columns = ['id', 'name', '_lft', '_rgt', 'parent_id', 'depth']; + $before = DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(); + $saves = 0; + $vetoedKey = null; + + $this->category::saving(function (Category $model) use (&$saves, &$vetoedKey): ?bool { + if (++$saves !== 3) { + return null; + } + + $vetoedKey = $model->getKey(); + + return false; + }); + + try { + DB::transaction(fn (): int => $this->category::rebuildTree([ + [ + 'id' => $this->key(1), + 'name' => 'updated store', + 'children' => [ + ['id' => $this->key(11), 'name' => 'updated second store'], + ], + ], + ['id' => $this->key(2), 'name' => 'updated notebooks'], + ])); + $this->fail('Expected the rebuild veto to propagate.'); + } catch (LogicException $exception) { + $this->assertSame( + sprintf( + 'Saving nested set node [%s] with key [%s] during repair was vetoed.', + $this->category, + $vetoedKey, + ), + $exception->getMessage(), + ); + } + + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + + public function testRebuildTreeWithDeletion(): void + { + $this->category::rebuildTree([['name' => 'all deleted']], true); + + $this->assertTreeNotBroken(); + + $nodes = $this->category::get(); + + $this->assertEquals(1, $nodes->count()); + $this->assertEquals('all deleted', $nodes->first()->name); + + $nodes = $this->category::withTrashed()->get(); + + $this->assertTrue($nodes->count() > 1); + } + + public function testRebuildTreeSoftDeletesRemovedNodes(): void + { + $this->category::rebuildTree([ + ['id' => $this->key(1), 'name' => 'store'], + ], true); + + $deleted = $this->category::withTrashed()->find($this->key(2)); + + $this->assertNotNull($deleted); + $this->assertNotNull($deleted->{$deleted->getDeletedAtColumn()}); + } + + public function testRebuildFailsWithInvalidPK(): void + { + $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessageIs( + "No query results for model [{$this->category}] {$this->key(24)}", + ); + + $this->category::rebuildTree([['id' => $this->key(24)]]); + } + + public function testFlatTree(): void + { + $node = $this->findCategory('mobile'); + $tree = $node->descendants()->orderBy('name')->get()->toFlatTree(); + + $this->assertCount(5, $tree); + $this->assertEquals('samsung', $tree[2]->name); + $this->assertEquals('galaxy', $tree[3]->name); + } + + public function testWhereIsLeaf(): void + { + $categories = $this->category::leaves(); + + $this->assertEqualsCanonicalizing( + ['apple', 'lenovo', 'nokia', 'galaxy', 'sony', 'lenovo', 'store_2'], + $categories->pluck('name')->all(), + ); + $this->assertTrue($categories->every->isLeaf()); + + $category = $this->category::whereIsRoot()->defaultOrder()->first(); + + $this->assertFalse($category->isLeaf()); + } + + public function testEagerLoadAncestors(): void + { + $queryLogCount = count(DB::getQueryLog()); + $categories = $this->category::with('ancestors')->orderBy('name')->get(); + + $this->assertEquals($queryLogCount + 2, count(DB::getQueryLog())); + + $expectedShape = $this->expectedAncestorShape(); + + $output = []; + + foreach ($categories as $category) { + $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() + ? implode(' > ', $category->ancestors->map(function (Category $ancestor): string { + return "{$ancestor->name} ({$ancestor->id})"; + })->toArray()) + : ''; + } + + $this->assertEquals($expectedShape, $output); + } + + public function testNestedAncestorEagerLoadConstraintsAreDeduplicated(): void + { + $nodes = $this->category::whereIn('id', $this->keys(5, 8)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('ancestors'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + // Only galaxy's bound remains, so no successor search is needed. + $this->assertStringNotContainsString('case when', $relationQuery); + $this->assertEquals(['store'], $nodes->find($this->key(5))->ancestors->pluck('name')->all()); + $this->assertEquals(['store', 'mobile', 'samsung'], $nodes->find($this->key(8))->ancestors->pluck('name')->all()); + } + + public function testDisjointAncestorEagerLoadConstraintsAreRetained(): void + { + $nodes = $this->category::whereIn('id', $this->keys(3, 8)) + ->defaultOrder() + ->get(); + + DB::flushQueryLog(); + + $nodes->load('ancestors'); + + $queries = DB::getQueryLog(); + $relationQuery = strtolower(end($queries)['query']); + + $this->assertEquals(1, substr_count($relationQuery, 'case when')); + $this->assertEquals(['store', 'notebooks'], $nodes->find($this->key(3))->ancestors->pluck('name')->all()); + $this->assertEquals(['store', 'mobile', 'samsung'], $nodes->find($this->key(8))->ancestors->pluck('name')->all()); + } + + public function testLazyLoadAncestors(): void + { + $queryLogCount = count(DB::getQueryLog()); + $categories = $this->category::orderBy('name')->get(); + + $this->assertEquals($queryLogCount + 1, count(DB::getQueryLog())); + + $expectedShape = $this->expectedAncestorShape(); + + $output = []; + + foreach ($categories as $category) { + $output["{$category->name} ({$category->id})}"] = $category->ancestors->count() + ? implode(' > ', $category->ancestors->map(function (Category $ancestor): string { + return "{$ancestor->name} ({$ancestor->id})"; + })->toArray()) + : ''; + } + + // assert that there is number of original query + 1 + number of rows to fulfill the relation + $this->assertEquals($queryLogCount + 12, count(DB::getQueryLog())); + + $this->assertEquals($expectedShape, $output); + } + + public function testWhereHasCountQueryForAncestors(): void + { + $categories = $this->category::has('ancestors', '>', 2)->pluck('name')->all(); + + $this->assertEquals(['galaxy'], $categories); + + $categories = $this->category::whereHas('ancestors', function (EloquentBuilder $query): void { + $query->where('id', $this->key(5)); + })->defaultOrder()->pluck('name')->all(); + + $this->assertEquals(['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], $categories); + } + + public function testExistenceQueriesRetainTheConnectionWithoutReplicatingModels(): void + { + $replicatedModels = []; + $connection = DB::getDefaultConnection(); + + config([ + 'database.connections.nested_set_relation' => config( + "database.connections.{$connection}", + ), + ]); + + $this->category::replicating(function (Category $model) use (&$replicatedModels): void { + $replicatedModels[] = $model; + }); + + $parent = (new $this->category)->setConnection('nested_set_relation'); + $relation = $parent->descendants(); + $query = $relation->getRelationExistenceQuery( + $relation->getQuery(), + $parent->newQuery(), + ); + + $this->assertSame([], $replicatedModels); + $this->assertSame($parent->getConnectionName(), $query->getModel()->getConnectionName()); + } + + public function testNestedWhereHasCorrelatesAgainstTheImmediatelyEnclosingRelation(): void + { + $this->assertSame( + $this->keys(1, 5), + $this->category::whereHas( + 'descendants', + fn (EloquentBuilder $query): EloquentBuilder => $query->whereHas('descendants'), + )->orderBy('id')->pluck('id')->all(), + ); + + $this->assertSame( + $this->keys(1, 5, 7), + $this->category::whereHas( + 'descendants', + fn (EloquentBuilder $query): EloquentBuilder => $query->whereHas( + 'ancestors', + fn (EloquentBuilder $query): EloquentBuilder => $query->where('name', 'samsung'), + ), + )->orderBy('id')->pluck('id')->all(), + ); + } + + public function testGetsAncestorsInHierarchicalOrder(): void + { + $node = $this->category::find($this->key(8)); + + // The relation query must be explicitly ordered by _lft (root first), + // not left to incidental database order. + $sql = strtolower($node->ancestors()->toSql()); + $this->assertStringContainsString('order by', $sql); + $this->assertStringContainsString('_lft', $sql); + + // galaxy's ancestors are store (_lft 1), mobile (_lft 8), samsung (_lft 11) + $ancestors = $node->getAncestors(); + $this->assertEquals([1, 8, 11], $ancestors->pluck('_lft')->all()); + $this->assertEquals(['store', 'mobile', 'samsung'], $ancestors->pluck('name')->all()); + } + + public function testEagerLoadsAncestorsInHierarchicalOrder(): void + { + DB::flushQueryLog(); + + $galaxy = $this->category::with('ancestors')->find($this->key(8)); + + // The eager-load query for ancestors must also be ordered by _lft. + $ancestorQuery = collect(DB::getQueryLog()) + ->first(fn (array $entry): bool => str_contains(strtolower($entry['query']), 'order by')); + $this->assertNotNull($ancestorQuery, 'Eager ancestors query is not ordered.'); + $this->assertStringContainsString('_lft', strtolower($ancestorQuery['query'])); + + $this->assertEquals([1, 8, 11], $galaxy->ancestors->pluck('_lft')->all()); + $this->assertEquals(['store', 'mobile', 'samsung'], $galaxy->ancestors->pluck('name')->all()); + } + + public function testReplication(): void + { + $category = $this->findCategory('nokia'); + $category = $category->replicate(); + $category->save(); + $category->refreshNode(); + + $this->assertNull($category->getParentId()); + + $category = $this->findCategory('nokia'); + $category = $category->replicate(); + $category->parent_id = $this->key(1); + $category->save(); + + $category->refreshNode(); + + $this->assertEquals($this->key(1), $category->getParentId()); + } + + public function testWhereIsRootQualifiesParentId(): void + { + $query = $this->category::query() + ->join('categories as joined_categories', 'joined_categories.id', '=', 'categories.id') + ->select('categories.id'); + + $this->assertSame( + $this->keys(1, 11), + (clone $query)->whereIsRoot()->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(2, 3, 4, 5, 6, 7, 8, 9, 10), + (clone $query)->withoutRoot()->orderBy('categories.id')->pluck('id')->all(), + ); + $this->assertSame( + $this->keys(2, 3, 4, 5, 6, 7, 8, 9, 10), + (clone $query)->hasParent()->orderBy('categories.id')->pluck('id')->all(), + ); + } + + /** + * Get each seeded category's ancestor path keyed by name and key. + */ + protected function expectedAncestorShape(): array + { + $label = fn (string $name, int $number): string => "{$name} ({$this->key($number)})"; + + return [ + $label('apple', 3) . '}' => $label('store', 1) . ' > ' . $label('notebooks', 2), + $label('galaxy', 8) . '}' => $label('store', 1) . ' > ' . $label('mobile', 5) . ' > ' . $label('samsung', 7), + $label('lenovo', 4) . '}' => $label('store', 1) . ' > ' . $label('notebooks', 2), + $label('lenovo', 10) . '}' => $label('store', 1) . ' > ' . $label('mobile', 5), + $label('mobile', 5) . '}' => $label('store', 1), + $label('nokia', 6) . '}' => $label('store', 1) . ' > ' . $label('mobile', 5), + $label('notebooks', 2) . '}' => $label('store', 1), + $label('samsung', 7) . '}' => $label('store', 1) . ' > ' . $label('mobile', 5), + $label('sony', 9) . '}' => $label('store', 1) . ' > ' . $label('mobile', 5), + $label('store', 1) . '}' => '', + $label('store_2', 11) . '}' => '', + ]; + } + + /** + * Count the logged single-row reads of a node's structural columns. + */ + private function countStructuralIdentityReloads(): int + { + return count(array_filter( + array_column(DB::getQueryLog(), 'query'), + static fn (string $query): bool => preg_match( + '/^select .*_lft.*_rgt.*depth.* from .* limit 1$/i', + $query, + ) === 1, + )); + } + + /** + * Make a persisted-looking node for in-memory collection tests. + */ + protected function makeCollectionNode( + int|string $id, + int $lft, + int $rgt, + int|string|null $parentId, + ?Category $node = null, + ): Category { + $node ??= new $this->category; + $node->setRawAttributes([ + 'id' => $id, + '_lft' => $lft, + '_rgt' => $rgt, + 'parent_id' => $parentId, + 'depth' => 0, + ], true); + $node->exists = true; + + return $node; + } +} diff --git a/tests/NestedSet/NodeUuidTest.php b/tests/NestedSet/NodeUuidTest.php new file mode 100644 index 0000000000..1a283bc578 --- /dev/null +++ b/tests/NestedSet/NodeUuidTest.php @@ -0,0 +1,30 @@ + $this->shouldSeed(), - '--database' => $this->getRefreshConnection(), - '--realpath' => true, - '--path' => __DIR__ . '/Fixtures/migrations', - ]; - } + protected string $menuItem = MenuItem::class; /** - * Set up the test environment. + * Seed the fixture trees inside the test coroutine and transaction. */ - public function setUp(): void + protected function afterRefreshingDatabase(): void { - parent::setUp(); - - DB::enableQueryLog(); - - DB::table('menu_items') - ->insert($this->getMockMenuItems()); + parent::afterRefreshingDatabase(); - // Reset Postgres sequence after inserting with explicit IDs + // Reset the Postgres sequence after inserting explicit keys. if (DB::connection()->getDriverName() === 'pgsql') { - DB::statement("SELECT setval('menu_items_id_seq', (SELECT MAX(id) FROM menu_items))"); + $table = DB::connection()->getTablePrefix() . 'menu_items'; + + DB::statement("SELECT setval(pg_get_serial_sequence('{$table}', 'id'), (SELECT MAX(id) FROM {$table}))"); } } /** - * Get the initial menu item records. + * Get the fixture migration path. */ - protected function getMockMenuItems(): array - { - return [ - ['id' => 1, 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1', 'depth' => 0], - ['id' => 2, 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2', 'depth' => 0], - ['id' => 5, 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => 2, 'title' => 'menu item 3', 'depth' => 1], - ['id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1', 'depth' => 0], - ['id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2', 'depth' => 0], - ['id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'title' => 'menu item 3', 'depth' => 1], - ]; - } - - protected function assertTreeNotBroken(int|string $menuId): void - { - $this->assertFalse(MenuItem::scoped(['menu_id' => $menuId])->isBroken()); - } - - public function testNotBroken(): void - { - $this->assertTreeNotBroken(1); - $this->assertTreeNotBroken(2); - } - - public function testDiagnosticsRequireAConcreteScopeSelection(): void - { - foreach (['countErrors', 'getTotalErrors', 'isBroken'] as $method) { - try { - MenuItem::query()->{$method}(); - $this->fail("Expected {$method} to require a concrete scope."); - } catch (LogicException $exception) { - $this->assertStringContainsString('scoped([...])', $exception->getMessage()); - } - } - } - - public function testRepairAndRebuildRequireAConcreteScopeSelection(): void - { - $operations = [ - 'fixTree' => fn () => MenuItem::query()->fixTree(), - 'rebuildTree' => fn () => MenuItem::query()->rebuildTree([]), - ]; - - foreach ($operations as $method => $operation) { - try { - $operation(); - $this->fail("Expected {$method} to require a concrete scope."); - } catch (LogicException $exception) { - $this->assertStringContainsString('scoped([...])', $exception->getMessage()); - } - } - } - - public function testSubtreeOperationHydratesPartialRootFromThePersistedRow(): void - { - $root = MenuItem::query() - ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->findOrFail(2); - - MenuItem::fixSubtree($root); - - $this->assertSame(1, $root->menu_id); - $this->assertTreeNotBroken(1); - $this->assertOtherScopeNotAffected(); - } - - #[DataProvider('subtreeOperations')] - public function testSubtreeOperationRejectsARootFromAnotherSelectedScope( - string $operation, - string $label, - ): void { - $root = MenuItem::findOrFail(4); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set {$label} root [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\MenuItem] does not match the query scoped([...]) selection.", - ); - - $operation === 'fix' - ? MenuItem::scoped(['menu_id' => 1])->fixSubtree($root) - : MenuItem::scoped(['menu_id' => 1])->rebuildSubtree($root, []); - } - - public static function subtreeOperations(): array - { - return [ - 'repair' => ['fix', 'subtree repair'], - 'rebuild' => ['rebuild', 'subtree rebuild'], - ]; - } - - public function testScalarLookupsRequireAConcreteScopeSelection(): void + protected function getMigrationPath(): string { - $operations = [ - 'whereAncestorOf' => fn () => MenuItem::query()->whereAncestorOf(5)->get(), - 'whereDescendantOf' => fn () => MenuItem::query()->whereDescendantOf(2)->get(), - 'descendantsOf' => fn () => MenuItem::query()->descendantsOf(2), - 'whereIsBefore' => fn () => MenuItem::query()->whereIsBefore(5)->get(), - 'whereIsAfter' => fn () => MenuItem::query()->whereIsAfter(5)->get(), - ]; - - foreach ($operations as $method => $operation) { - try { - $operation(); - $this->fail("Expected {$method} to require a concrete scope."); - } catch (LogicException $exception) { - $this->assertStringContainsString('scoped([...])', $exception->getMessage()); - } - } + return __DIR__ . '/Fixtures/migrations'; } - #[DataProvider('nodeObjectPositionalQueries')] - public function testNodeObjectPositionalQueriesRequireSelectedScope(string $method): void - { - $node = MenuItem::query() - ->select(['id', '_lft', '_rgt']) - ->findOrFail(6); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\MenuItem] must have scope attribute [menu_id] selected.', - ); - - MenuItem::query()->{$method}($node)->get(); - } - - public static function nodeObjectPositionalQueries(): array - { - return [ - 'ancestors' => ['whereAncestorOf'], - 'descendants' => ['whereDescendantOf'], - 'before' => ['whereIsBefore'], - 'after' => ['whereIsAfter'], - ]; - } - - #[DataProvider('directScopedPositionQueries')] - public function testDirectNodePositionQueriesRequireSelectedScope(string $method): void - { - $node = MenuItem::query() - ->select(['id', '_lft', '_rgt', 'parent_id']) - ->findOrFail(3); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set node [Hypervel\Tests\NestedSet\Fixtures\Models\MenuItem] must have scope attribute [menu_id] selected.', - ); - - $node->{$method}()->get(); - } - - public static function directScopedPositionQueries(): array - { - return [ - 'next nodes' => ['nextNodes'], - 'previous nodes' => ['prevNodes'], - 'next siblings' => ['nextSiblings'], - 'previous siblings' => ['prevSiblings'], - ]; - } - - #[DataProvider('lowLevelScopedOperations')] - public function testLowLevelTreeOperationsRequireAConcreteScope(string $operation): void - { - $query = MenuItem::query(); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIsOrContains('scoped([...])'); - - match ($operation) { - 'lookup' => $query->getNodeData(1), - 'movement' => $query->moveNode(1, 1), - 'depth' => $query->depthForPosition(1), - 'gap' => $query->makeGap(1, 2), - }; - } - - public static function lowLevelScopedOperations(): array - { - return [ - 'node lookup' => ['lookup'], - 'movement' => ['movement'], - 'depth lookup' => ['depth'], - 'gap mutation' => ['gap'], - ]; - } - - public function testNullIsAConcreteScopeValueWhenTheAttributeIsPresent(): void - { - $model = new MenuItem; - $model->setRawAttributes(['menu_id' => null]); - - $this->assertSame([ - 'invalid_intervals' => 0, - 'duplicate_endpoints' => 0, - 'missing_endpoints' => 0, - 'crossing_intervals' => 0, - 'missing_parent' => 0, - 'wrong_parent' => 0, - 'wrong_depth' => 0, - ], $model->newScopedQuery()->countErrors()); - } - - public function testNewNodeRequiresItsConfiguredScopeAttribute(): void + /** + * Get the key of a seeded fixture menu item. + */ + protected function key(int $number): int { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIsOrContains('attribute [menu_id] was not selected'); - - (new MenuItem(['title' => 'missing scope']))->save(); + return $number; } public function testPresentNullScopeCanBePersisted(): void { - Schema::create('nullable_menu_items', function (Blueprint $table): void { - $table->id(); - $table->unsignedInteger('menu_id')->nullable(); - $table->string('title')->nullable(); - NestedSet::columns($table, ['menu_id']); - }); - $node = new NullableMenuItem(['menu_id' => null, 'title' => 'null scope']); $this->assertTrue($node->save()); @@ -282,483 +52,27 @@ public function testPresentNullScopeCanBePersisted(): void $this->assertSame(0, $node->getDepth()); } - public function testMovingNodeNotAffectingOtherMenu(): void - { - $node = MenuItem::where('menu_id', '=', 1)->first(); - - $node->down(); - - $node = MenuItem::where('menu_id', '=', 2)->first(); - - $this->assertEquals(1, $node->getLft()); - } - - public function testScoped(): void - { - $node = MenuItem::scoped(['menu_id' => 2])->first(); - - $this->assertEquals(3, $node->getKey()); - } - - public function testBeforeAndAfterPredicatesUseTheExactNestedSetScope(): void - { - $node = MenuItem::findOrFail(4); - - $this->assertSame( - [3], - MenuItem::query()->whereIsBefore($node)->orderBy('id')->pluck('id')->all(), - ); - $this->assertSame( - [6], - MenuItem::query()->whereIsAfter($node)->orderBy('id')->pluck('id')->all(), - ); - $this->assertSame( - [3], - MenuItem::scoped(['menu_id' => 2])->whereIsBefore(4)->pluck('id')->all(), - ); - $this->assertSame( - [], - MenuItem::scoped(['menu_id' => 2])->whereIsBefore(2)->pluck('id')->all(), - ); - $this->assertSame( - [1, 3], - MenuItem::whereKey(1) - ->whereIsBefore($node, 'or') - ->orderBy('id') - ->pluck('id') - ->all(), - ); - } - - public function testSiblings(): void - { - $node = MenuItem::find(1); - - $result = $node->getSiblings(); - - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); - - $result = $node->getNextSiblings(); - - $this->assertEquals(2, $result->first()->getKey()); - - $node = MenuItem::find(2); - - $result = $node->getPrevSiblings(); - - $this->assertEquals(1, $result->first()->getKey()); - } - - public function testPredicatesRequireExactNestedSetScope(): void - { - $firstScopeRoot = MenuItem::findOrFail(1); - $secondScopeRoot = MenuItem::findOrFail(3); - $firstScopeParent = MenuItem::findOrFail(2); - $firstScopeChild = MenuItem::findOrFail(5); - $secondScopeParent = MenuItem::findOrFail(4); - - $this->assertTrue($firstScopeChild->isChildOf($firstScopeParent)); - // Change only the scope to isolate isSameScope(), then restore it for the cross-scope interval checks. - $firstScopeChild->menu_id = 2; - $this->assertFalse($firstScopeChild->isChildOf($firstScopeParent)); - $firstScopeChild->menu_id = 1; - $this->assertFalse($firstScopeRoot->isSiblingOf($secondScopeRoot)); - $this->assertFalse($firstScopeChild->isSelfOrDescendantOf($secondScopeParent)); - $this->assertFalse($secondScopeParent->isSelfOrAncestorOf($firstScopeChild)); - } - - public function testSiblingsEagerMatchingUsesExactScopeBuckets(): void - { - $nodes = MenuItem::whereIn('id', [1, 3]) - ->orderBy('id') - ->get(); - - $nodes->load(['siblings', 'siblingsAndSelf']); - - $this->assertEquals([2], $nodes->find(1)->siblings->pluck('id')->all()); - $this->assertEquals([1, 2], $nodes->find(1)->siblingsAndSelf->pluck('id')->sort()->values()->all()); - $this->assertEquals([4], $nodes->find(3)->siblings->pluck('id')->all()); - $this->assertEquals([3, 4], $nodes->find(3)->siblingsAndSelf->pluck('id')->sort()->values()->all()); - } - - #[DataProvider('scopedRelationParents')] - public function testRelationsRequireSelectedScopeAndParentage( - string $relation, - string $requiredColumn, - ): void { - $node = MenuItem::query() - ->select(['id', '_lft', '_rgt', 'depth']) - ->findOrFail(5); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation parent for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\MenuItem] requires the [{$requiredColumn}] column.", - ); - - $node->{$relation}()->get(); - } - - public static function scopedRelationParents(): array - { - return [ - 'ancestors' => ['ancestors', 'menu_id'], - 'descendants' => ['descendants', 'menu_id'], - 'siblings' => ['siblings', 'menu_id'], - ]; - } - - public function testRelationExistenceQueriesCorrelateExactScopes(): void + public function testRebuildAssignsAGuardedScopeOverNewNodeDefaults(): void { - DB::table('menu_items')->insert([ - 'id' => 7, - 'menu_id' => 3, - '_lft' => 1, - '_rgt' => 2, - 'parent_id' => null, - 'title' => 'only item', - 'depth' => 0, + GuardedScopeMenuItem::scoped(['menu_id' => 1])->rebuildTree([ + ['id' => 2, 'children' => [[]]], ]); - $this->assertFalse(MenuItem::whereKey(7)->has('siblings')->exists()); - $this->assertSame( - [5], - MenuItem::whereHas( - 'ancestors', - fn ($query) => $query->whereKey(2), - )->orderBy('id')->pluck('id')->all(), - ); - - $node = MenuItem::with('siblings')->findOrFail(7); - - $this->assertTrue($node->siblings->isEmpty()); - } - - public function testEagerAncestorsHandleAnEmptyQueryResult(): void - { - DB::flushQueryLog(); - - $node = MenuItem::with('ancestors')->findOrFail(1); - - $this->assertCount(2, DB::getQueryLog()); - $this->assertTrue($node->ancestors->isEmpty()); - } - - public function testDescendants(): void - { - $node = MenuItem::find(2); - - $result = $node->getDescendants(); - - $this->assertEquals(1, $result->count()); - $this->assertEquals(5, $result->first()->getKey()); - - $node = MenuItem::scoped(['menu_id' => 1])->with('descendants')->find(2); - - $result = $node->descendants; - - $this->assertEquals(1, $result->count()); - $this->assertEquals(5, $result->first()->getKey()); - } - - public function testAncestors(): void - { - $node = MenuItem::find(5); - - $result = $node->getAncestors(); - - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); - - $node = MenuItem::scoped(['menu_id' => 1])->with('ancestors')->find(5); - - $result = $node->ancestors; - - $this->assertEquals(1, $result->count()); - $this->assertEquals(2, $result->first()->getKey()); - } - - public function testDepth(): void - { - $node = MenuItem::scoped(['menu_id' => 1])->withDepth()->where('id', '=', 5)->first(); - - $this->assertEquals(1, $node->depth); - - $node = MenuItem::find(2); - - $result = $node->children()->withDepth()->get(); - - $this->assertEquals(1, $result->first()->depth); - } + $child = GuardedScopeMenuItem::where('title', 'untitled')->sole(); - public function testStoredDepthWorksAcrossAQueryWithoutAConcreteScope(): void - { - $depths = MenuItem::query() - ->withDepth() - ->orderBy('id') - ->pluck('depth', 'id') - ->all(); - - $this->assertSame([1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 1, 6 => 1], $depths); - } - - public function testFixTreeRepairsOnlyTheSelectedScope(): void - { - DB::table('menu_items')->where('id', 5)->update(['_lft' => 3]); - DB::flushQueryLog(); - - MenuItem::scoped(['menu_id' => 1])->fixTree(); - - $queries = array_column(DB::getQueryLog(), 'query'); - - $this->assertTrue(collect($queries)->contains( - static fn (string $query): bool => preg_match( - '/^select .*menu_id.* from .*menu_items/i', - $query, - ) === 1, - )); - $this->assertFalse(collect($queries)->contains( - static fn (string $query): bool => preg_match( - '/^select .*_lft.*_rgt.*depth.*parent_id.*menu_id.*limit 1$/i', - $query, - ) === 1, - )); - $this->assertTreeNotBroken(1); - $this->assertOtherScopeNotAffected(); - } - - public function testSaveAsRoot(): void - { - $node = MenuItem::find(5); - - $node->saveAsRoot(); - - $this->assertEquals(5, $node->getLft()); - $this->assertEquals(null, $node->parent_id); - - $this->assertOtherScopeNotAffected(); - } - - public function testInsertion(): void - { - $node = MenuItem::create(['menu_id' => 1, 'parent_id' => 5]); - - $this->assertEquals(5, $node->parent_id); - $this->assertEquals(5, $node->getLft()); - - $this->assertOtherScopeNotAffected(); - } - - public function testInsertionResolvesParentAfterLaterScopeAttributes(): void - { - $node = MenuItem::create(['parent_id' => 5, 'menu_id' => 1]); - - $this->assertSame(5, $node->getParentId()); - $this->assertSame(5, $node->getLft()); - $this->assertOtherScopeNotAffected(); - } - - public function testFillResolvesParentAfterLaterScopeAttributes(): void - { - $node = new MenuItem; - $node->fill(['parent_id' => 5, 'menu_id' => 1])->save(); - - $this->assertSame(5, $node->getParentId()); - $this->assertSame(5, $node->getLft()); - $this->assertOtherScopeNotAffected(); - } - - public function testInsertionToParentFromOtherScope(): void - { - $this->expectException(ModelNotFoundException::class); - - MenuItem::create(['menu_id' => 2, 'parent_id' => 5]); - } - - public function testInsertionRejectsLaterScopeAttributesFromAnotherTree(): void - { - $this->expectException(ModelNotFoundException::class); - - MenuItem::create(['parent_id' => 5, 'menu_id' => 2]); - } - - public function testExistingModelCannotChangeItsNestedSetScope(): void - { - $node = MenuItem::findOrFail(5); - $node->menu_id = 2; - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set scope attribute [menu_id] cannot be changed on an existing [Hypervel\Tests\NestedSet\Fixtures\Models\MenuItem] model.', - ); - - $node->save(); - } - - public function testPartialExistingModelCannotHideANestedSetScopeChange(): void - { - $node = MenuItem::query()->select(['id'])->findOrFail(5); - $node->menu_id = null; - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIsOrContains('scope attribute [menu_id] cannot be changed'); - - $node->save(); - } - - public function testSavingPersistedPartialModelRequiresASelectedKey(): void - { - $node = MenuItem::query() - ->select(['_lft', '_rgt', 'depth', 'title']) - ->whereKey(2) - ->firstOrFail(); - $node->title = 'changed'; - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - 'Nested set model [Hypervel\Tests\NestedSet\Fixtures\Models\MenuItem] requires the [id] column to be selected.', - ); - - $node->save(); - } - - public function testNewRawMutationDefersMissingScopeFailureUntilSave(): void - { - $node = new MenuItem(['title' => 'missing scope']); - DB::flushQueryLog(); - - $node->rawNode(1, 2, null, 0); - - $this->assertSame([], DB::getQueryLog()); - $this->expectException(LogicException::class); - $this->expectExceptionMessageIsOrContains('attribute [menu_id] was not selected'); - - $node->save(); - } - - public function testRawMutationLoadsOnlyMissingScopeIdentity(): void - { - $node = MenuItem::query()->select(['id'])->findOrFail(5); - DB::flushQueryLog(); - - $node->rawNode(4, 5, 2, 1)->save(); - - $this->assertSame(1, count(array_filter( - array_column(DB::getQueryLog(), 'query'), - static fn (string $query): bool => str_starts_with($query, 'select '), - ))); - $this->assertSame(1, $node->menu_id); - $this->assertTreeNotBroken(1); - } - - public function testRawMutationWithCompleteScopeDoesNotReadPersistedIdentity(): void - { - $node = MenuItem::findOrFail(5); - $node->title = 'updated'; - DB::flushQueryLog(); - - $node->rawNode( - $node->getLft(), - $node->getRgt(), - $node->getParentId(), - $node->getDepth(), - )->save(); - - $this->assertSame(0, count(array_filter( - array_column(DB::getQueryLog(), 'query'), - static fn (string $query): bool => str_starts_with($query, 'select '), - ))); - $this->assertSame('updated', MenuItem::findOrFail(5)->title); + $this->assertSame(1, $child->menu_id); + $this->assertSame(2, $child->parent_id); $this->assertTreeNotBroken(1); } - public function testSavingTheSameNestedSetScopeValueRemainsValid(): void - { - $node = MenuItem::findOrFail(5); - $node->menu_id = 1; - $node->title = 'updated'; - - $this->assertTrue($node->save()); - $this->assertSame(1, MenuItem::findOrFail(5)->menu_id); - } - - public function testDeferredNewNodeMutationRevalidatesItsScope(): void - { - $node = new MenuItem(['menu_id' => 1]); - $node->appendToNode(MenuItem::findOrFail(2)); - $node->menu_id = 2; - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Nodes must be in the same tree.'); - - $node->save(); - } - - #[DataProvider('partialCrossScopeMutationModels')] - public function testCrossScopeMutationHydratesPartialModels(string $partial): void - { - $source = $partial === 'source' - ? MenuItem::query() - ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->findOrFail(1) - : MenuItem::findOrFail(1); - $target = $partial === 'target' - ? MenuItem::query() - ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) - ->findOrFail(3) - : MenuItem::findOrFail(3); - - DB::flushQueryLog(); - - $source->appendToNode($target); - - $this->assertSame([], DB::getQueryLog()); - - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Nodes must be in the same tree.'); - - $source->save(); - } - - public static function partialCrossScopeMutationModels(): array - { - return [ - 'partial source' => ['source'], - 'partial target' => ['target'], - ]; - } - - public function testDeletion(): void - { - MenuItem::find(2)->delete(); - - $node = MenuItem::find(1); - - $this->assertEquals(2, $node->getRgt()); - - $this->assertOtherScopeNotAffected(); - } - - public function testDeletingPartiallySelectedScopedNodeHydratesItsTreeIdentity(): void - { - $node = MenuItem::query()->select(['id'])->findOrFail(2); - - $node->delete(); - - $this->assertNull(MenuItem::find(2)); - $this->assertNull(MenuItem::find(5)); - $this->assertNotNull(MenuItem::find(4)); - $this->assertNotNull(MenuItem::find(6)); - $this->assertTreeNotBroken(1); - $this->assertTreeNotBroken(2); - } - public function testRestoringPartiallySelectedScopedNodeHydratesItsTreeIdentity(): void { - Schema::table('menu_items', fn (Blueprint $table) => $table->softDeletes()); - SoftDeletingMenuItem::findOrFail(2)->delete(); + // The other menu's child lies within the deleted node's bounds but stays active. + $this->assertNull(SoftDeletingMenuItem::find(5)); + $this->assertNotNull(SoftDeletingMenuItem::find(6)); + $node = SoftDeletingMenuItem::withTrashed() ->select(['id', 'deleted_at']) ->findOrFail(2); @@ -772,137 +86,6 @@ public function testRestoringPartiallySelectedScopedNodeHydratesItsTreeIdentity( $this->assertTreeNotBroken(1); $this->assertTreeNotBroken(2); } - - public function testMoving(): void - { - $node = MenuItem::find(1); - $this->assertTrue($node->down()); - - $this->assertOtherScopeNotAffected(); - } - - protected function assertOtherScopeNotAffected(): void - { - $this->assertSame( - [ - ['id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'depth' => 0], - ['id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'depth' => 0], - ['id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'depth' => 1], - ], - DB::table('menu_items') - ->where('menu_id', 2) - ->orderBy('id') - ->get(['id', 'menu_id', '_lft', '_rgt', 'parent_id', 'depth']) - ->map(fn (object $row): array => (array) $row) - ->all(), - ); - } - - public function testAppendingToAnotherScopeFails(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Nodes must be in the same tree.'); - - $foo = MenuItem::find(1); - $bar = MenuItem::find(3); - - $foo->appendToNode($bar)->save(); - } - - public function testInsertingBeforeAnotherScopeFails(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs('Nodes must be in the same tree.'); - - $foo = MenuItem::find(1); - $bar = MenuItem::find(3); - - $foo->insertBeforeNode($bar); - } - - public function testEagerLoadingAncestorsWithScope(): void - { - $filteredNodes = MenuItem::where('title', 'menu item 3')->with(['ancestors'])->get(); - - $this->assertEquals(2, $filteredNodes->find(5)->ancestors[0]->id); - $this->assertEquals(4, $filteredNodes->find(6)->ancestors[0]->id); - } - - public function testEagerLoadingDescendantsWithScope(): void - { - $filteredNodes = MenuItem::where('title', 'menu item 2')->with(['descendants'])->get(); - - $this->assertEquals(5, $filteredNodes->find(2)->descendants[0]->id); - $this->assertEquals(6, $filteredNodes->find(4)->descendants[0]->id); - } - - #[DataProvider('scopedEagerRelatedProjections')] - public function testScopedEagerMatchingUsesOnlyTruthfulRelatedProjections( - string $relation, - string $parentTitle, - array $columns, - array $expected, - ?string $requiredColumn, - ): void { - $nodes = MenuItem::where('title', $parentTitle)->get(); - - if ($requiredColumn !== null) { - $this->expectException(LogicException::class); - $this->expectExceptionMessageIs( - "Nested set relation eager load for [Hypervel\\Tests\\NestedSet\\Fixtures\\Models\\MenuItem] requires the [{$requiredColumn}] column.", - ); - } - - $nodes->load([ - $relation => fn ($query) => $query->select($columns), - ]); - - foreach ($expected as $parentId => $relatedIds) { - $this->assertSame( - $relatedIds, - $nodes->find($parentId) - ->getRelation($relation) - ->pluck('id') - ->sort() - ->values() - ->all(), - ); - } - } - - public static function scopedEagerRelatedProjections(): array - { - return [ - 'ancestors without left bound' => [ - 'ancestors', - 'menu item 3', - ['id', 'menu_id', '_rgt'], - [], - '_lft', - ], - 'ancestors without right bound' => [ - 'ancestors', - 'menu item 3', - ['id', 'menu_id', '_lft'], - [], - '_rgt', - ], - 'descendants without left bound' => [ - 'descendants', - 'menu item 2', - ['id', 'menu_id', '_rgt'], - [], - '_lft', - ], - 'descendants need no related right bound' => [ - 'descendants', - 'menu item 2', - ['id', 'menu_id', '_lft'], - [2 => [5], 4 => [6]], - null, - ], - ]; - } } class NullableMenuItem extends MenuItem @@ -910,6 +93,15 @@ class NullableMenuItem extends MenuItem protected ?string $table = 'nullable_menu_items'; } +class GuardedScopeMenuItem extends MenuItem +{ + protected ?string $table = 'menu_items'; + + protected array $attributes = ['menu_id' => 2, 'title' => 'untitled']; + + protected array $fillable = ['parent_id', 'title']; +} + class SoftDeletingMenuItem extends MenuItem { use SoftDeletes; diff --git a/tests/NestedSet/ScopedNodeTestBase.php b/tests/NestedSet/ScopedNodeTestBase.php new file mode 100644 index 0000000000..9c59a5d1d5 --- /dev/null +++ b/tests/NestedSet/ScopedNodeTestBase.php @@ -0,0 +1,1066 @@ + + */ + protected string $menuItem; + + /** + * Get the fixture migration path. + */ + abstract protected function getMigrationPath(): string; + + /** + * Get the key of a seeded fixture menu item. + */ + abstract protected function key(int $number): int|string; + + /** + * Get the keys of seeded fixture menu items. + */ + protected function keys(int ...$numbers): array + { + return array_map($this->key(...), $numbers); + } + + /** + * Use a table prefix so raw nested set SQL is checked against prefixed tables. + */ + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->make('config'); + + $config->set( + 'database.connections.' . $config->string('database.default') . '.prefix', + 'prfx_', + ); + } + + /** + * Get the migration options. + */ + protected function migrateFreshUsing(): array + { + return [ + '--seed' => $this->shouldSeed(), + '--database' => $this->getRefreshConnection(), + '--realpath' => true, + '--path' => $this->getMigrationPath(), + ]; + } + + /** + * Seed the fixture trees inside the test coroutine and transaction. + */ + protected function afterRefreshingDatabase(): void + { + DB::enableQueryLog(); + + DB::table('menu_items') + ->insert($this->getMockMenuItems()); + } + + /** + * Stop query logging on the test coroutine's connection. + */ + protected function tearDownInCoroutine(): void + { + DB::flushQueryLog(); + DB::disableQueryLog(); + } + + /** + * Get the initial menu item records. + */ + protected function getMockMenuItems(): array + { + return [ + ['id' => $this->key(1), 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1', 'depth' => 0], + ['id' => $this->key(2), 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2', 'depth' => 0], + ['id' => $this->key(5), 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->key(2), 'title' => 'menu item 3', 'depth' => 1], + ['id' => $this->key(3), 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1', 'depth' => 0], + ['id' => $this->key(4), 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2', 'depth' => 0], + ['id' => $this->key(6), 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->key(4), 'title' => 'menu item 3', 'depth' => 1], + ]; + } + + /** + * Assert that the selected menu's tree has no structural errors. + */ + protected function assertTreeNotBroken(int $menuId): void + { + $this->assertSame([ + 'invalid_intervals' => 0, + 'duplicate_endpoints' => 0, + 'missing_endpoints' => 0, + 'crossing_intervals' => 0, + 'missing_parent' => 0, + 'wrong_parent' => 0, + 'wrong_depth' => 0, + ], $this->menuItem::scoped(['menu_id' => $menuId])->countErrors()); + } + + public function testNotBroken(): void + { + $this->assertTreeNotBroken(1); + $this->assertTreeNotBroken(2); + $this->assertFalse($this->menuItem::scoped(['menu_id' => 1])->isBroken()); + $this->assertFalse($this->menuItem::scoped(['menu_id' => 2])->isBroken()); + } + + public function testDiagnosticsRequireAConcreteScopeSelection(): void + { + foreach (['countErrors', 'getTotalErrors', 'isBroken'] as $method) { + try { + $this->menuItem::query()->{$method}(); + $this->fail("Expected {$method} to require a concrete scope."); + } catch (LogicException $exception) { + $this->assertStringContainsString('scoped([...])', $exception->getMessage()); + } + } + } + + public function testRepairAndRebuildRequireAConcreteScopeSelection(): void + { + $operations = [ + 'fixTree' => fn (): int => $this->menuItem::query()->fixTree(), + 'rebuildTree' => fn (): int => $this->menuItem::query()->rebuildTree([]), + ]; + + foreach ($operations as $method => $operation) { + try { + $operation(); + $this->fail("Expected {$method} to require a concrete scope."); + } catch (LogicException $exception) { + $this->assertStringContainsString('scoped([...])', $exception->getMessage()); + } + } + } + + public function testSubtreeOperationHydratesPartialRootFromThePersistedRow(): void + { + $root = $this->menuItem::query() + ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail($this->key(2)); + + $this->menuItem::fixSubtree($root); + + $this->assertSame(1, $root->menu_id); + $this->assertTreeNotBroken(1); + $this->assertOtherScopeNotAffected(); + } + + #[DataProvider('subtreeOperations')] + public function testSubtreeOperationRejectsARootFromAnotherSelectedScope( + string $operation, + string $label, + ): void { + $root = $this->menuItem::findOrFail($this->key(4)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set {$label} root [{$this->menuItem}] does not match the query scoped([...]) selection.", + ); + + $operation === 'fix' + ? $this->menuItem::scoped(['menu_id' => 1])->fixSubtree($root) + : $this->menuItem::scoped(['menu_id' => 1])->rebuildSubtree($root, []); + } + + /** + * Get the subtree operations with their error label. + */ + public static function subtreeOperations(): array + { + return [ + 'repair' => ['fix', 'subtree repair'], + 'rebuild' => ['rebuild', 'subtree rebuild'], + ]; + } + + public function testScalarLookupsRequireAConcreteScopeSelection(): void + { + $operations = [ + 'whereAncestorOf' => fn (): BaseCollection => $this->menuItem::query()->whereAncestorOf($this->key(5))->get(), + 'whereDescendantOf' => fn (): BaseCollection => $this->menuItem::query()->whereDescendantOf($this->key(2))->get(), + 'descendantsOf' => fn (): BaseCollection => $this->menuItem::query()->descendantsOf($this->key(2)), + 'whereIsBefore' => fn (): BaseCollection => $this->menuItem::query()->whereIsBefore($this->key(5))->get(), + 'whereIsAfter' => fn (): BaseCollection => $this->menuItem::query()->whereIsAfter($this->key(5))->get(), + ]; + + foreach ($operations as $method => $operation) { + try { + $operation(); + $this->fail("Expected {$method} to require a concrete scope."); + } catch (LogicException $exception) { + $this->assertStringContainsString('scoped([...])', $exception->getMessage()); + } + } + } + + #[DataProvider('nodeObjectPositionalQueries')] + public function testNodeObjectPositionalQueriesRequireSelectedScope(string $method): void + { + $node = $this->menuItem::query() + ->select(['id', '_lft', '_rgt']) + ->findOrFail($this->key(6)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->menuItem}] must have scope attribute [menu_id] selected.", + ); + + $this->menuItem::query()->{$method}($node)->get(); + } + + /** + * Get the positional query methods that accept a node. + */ + public static function nodeObjectPositionalQueries(): array + { + return [ + 'ancestors' => ['whereAncestorOf'], + 'descendants' => ['whereDescendantOf'], + 'before' => ['whereIsBefore'], + 'after' => ['whereIsAfter'], + ]; + } + + #[DataProvider('directScopedPositionQueries')] + public function testDirectNodePositionQueriesRequireSelectedScope(string $method): void + { + $node = $this->menuItem::query() + ->select(['id', '_lft', '_rgt', 'parent_id']) + ->findOrFail($this->key(3)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set node [{$this->menuItem}] must have scope attribute [menu_id] selected.", + ); + + $node->{$method}()->get(); + } + + /** + * Get the node's own positional query methods. + */ + public static function directScopedPositionQueries(): array + { + return [ + 'next nodes' => ['nextNodes'], + 'previous nodes' => ['prevNodes'], + 'next siblings' => ['nextSiblings'], + 'previous siblings' => ['prevSiblings'], + ]; + } + + #[DataProvider('lowLevelScopedOperations')] + public function testLowLevelTreeOperationsRequireAConcreteScope(string $operation): void + { + $query = $this->menuItem::query(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('scoped([...])'); + + match ($operation) { + 'lookup' => $query->getNodeData($this->key(1)), + 'movement' => $query->moveNode($this->key(1), 1), + 'depth' => $query->depthForPosition(1), + 'gap' => $query->makeGap(1, 2), + }; + } + + /** + * Get the low-level tree operations that need a concrete scope. + */ + public static function lowLevelScopedOperations(): array + { + return [ + 'node lookup' => ['lookup'], + 'movement' => ['movement'], + 'depth lookup' => ['depth'], + 'gap mutation' => ['gap'], + ]; + } + + public function testNullIsAConcreteScopeValueWhenTheAttributeIsPresent(): void + { + $model = new $this->menuItem; + $model->setRawAttributes(['menu_id' => null]); + + $this->assertSame([ + 'invalid_intervals' => 0, + 'duplicate_endpoints' => 0, + 'missing_endpoints' => 0, + 'crossing_intervals' => 0, + 'missing_parent' => 0, + 'wrong_parent' => 0, + 'wrong_depth' => 0, + ], $model->newScopedQuery()->countErrors()); + } + + public function testNewNodeRequiresItsConfiguredScopeAttribute(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('attribute [menu_id] was not selected'); + + (new $this->menuItem(['title' => 'missing scope']))->save(); + } + + public function testNodeRejectedForAMissingScopeKeepsItsPendingParent(): void + { + $node = new $this->menuItem(['title' => 'retried', 'parent_id' => $this->key(5)]); + + try { + $node->save(); + $this->fail('Expected the missing scope to be rejected.'); + } catch (LogicException $exception) { + $this->assertStringContainsString('attribute [menu_id] was not selected', $exception->getMessage()); + } + + $node->menu_id = 1; + $node->save(); + + $this->assertSame($this->key(5), $node->getParentId()); + $this->assertSame([5, 6], $node->getBounds()); + $this->assertSame(2, $node->getDepth()); + $this->assertTreeNotBroken(1); + $this->assertOtherScopeNotAffected(); + } + + public function testMovingNodeNotAffectingOtherMenu(): void + { + $node = $this->menuItem::where('menu_id', '=', 1)->defaultOrder()->first(); + + $node->down(); + + $node = $this->menuItem::where('menu_id', '=', 2)->defaultOrder()->first(); + + $this->assertEquals(1, $node->getLft()); + } + + public function testScoped(): void + { + $node = $this->menuItem::scoped(['menu_id' => 2])->defaultOrder()->first(); + + $this->assertEquals($this->key(3), $node->getKey()); + } + + public function testBeforeAndAfterPredicatesUseTheExactNestedSetScope(): void + { + $node = $this->menuItem::findOrFail($this->key(4)); + + $this->assertSame( + [$this->key(3)], + $this->menuItem::query()->whereIsBefore($node)->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + [$this->key(6)], + $this->menuItem::query()->whereIsAfter($node)->orderBy('id')->pluck('id')->all(), + ); + $this->assertSame( + [$this->key(3)], + $this->menuItem::scoped(['menu_id' => 2])->whereIsBefore($this->key(4))->pluck('id')->all(), + ); + $this->assertSame( + [], + $this->menuItem::scoped(['menu_id' => 2])->whereIsBefore($this->key(2))->pluck('id')->all(), + ); + $this->assertSame( + [$this->key(1), $this->key(3)], + $this->menuItem::whereKey($this->key(1)) + ->whereIsBefore($node, 'or') + ->orderBy('id') + ->pluck('id') + ->all(), + ); + } + + public function testSiblings(): void + { + $node = $this->menuItem::find($this->key(1)); + + $result = $node->getSiblings(); + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(2), $result->first()->getKey()); + + $result = $node->getNextSiblings(); + + $this->assertEquals($this->key(2), $result->first()->getKey()); + + $node = $this->menuItem::find($this->key(2)); + + $result = $node->getPrevSiblings(); + + $this->assertEquals($this->key(1), $result->first()->getKey()); + } + + public function testPredicatesRequireExactNestedSetScope(): void + { + $firstScopeRoot = $this->menuItem::findOrFail($this->key(1)); + $secondScopeRoot = $this->menuItem::findOrFail($this->key(3)); + $firstScopeParent = $this->menuItem::findOrFail($this->key(2)); + $firstScopeChild = $this->menuItem::findOrFail($this->key(5)); + $secondScopeParent = $this->menuItem::findOrFail($this->key(4)); + + $this->assertTrue($firstScopeChild->isChildOf($firstScopeParent)); + // Change only the scope to isolate isSameScope(), then restore it for the cross-scope interval checks. + $firstScopeChild->menu_id = 2; + $this->assertFalse($firstScopeChild->isChildOf($firstScopeParent)); + $firstScopeChild->menu_id = 1; + $this->assertFalse($firstScopeRoot->isSiblingOf($secondScopeRoot)); + $this->assertFalse($firstScopeChild->isSelfOrDescendantOf($secondScopeParent)); + $this->assertFalse($secondScopeParent->isSelfOrAncestorOf($firstScopeChild)); + } + + public function testSiblingsEagerMatchingUsesExactScopeBuckets(): void + { + $nodes = $this->menuItem::whereIn('id', $this->keys(1, 3)) + ->orderBy('id') + ->get(); + + $nodes->load(['siblings', 'siblingsAndSelf']); + + $this->assertEquals($this->keys(2), $nodes->find($this->key(1))->siblings->pluck('id')->all()); + $this->assertEquals($this->keys(1, 2), $nodes->find($this->key(1))->siblingsAndSelf->pluck('id')->sort()->values()->all()); + $this->assertEquals($this->keys(4), $nodes->find($this->key(3))->siblings->pluck('id')->all()); + $this->assertEquals($this->keys(3, 4), $nodes->find($this->key(3))->siblingsAndSelf->pluck('id')->sort()->values()->all()); + } + + public function testEagerSiblingsHandleMoreThanAThousandScopes(): void + { + DB::table('menu_items')->delete(); + + $menus = 1100; + $rows = []; + + for ($menu = 1; $menu <= $menus; ++$menu) { + $rows[] = ['id' => $this->key($menu * 2), 'menu_id' => $menu, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'first', 'depth' => 0]; + $rows[] = ['id' => $this->key($menu * 2 + 1), 'menu_id' => $menu, '_lft' => 3, '_rgt' => 4, 'parent_id' => null, 'title' => 'second', 'depth' => 0]; + } + + foreach (array_chunk($rows, 500) as $chunk) { + DB::table('menu_items')->insert($chunk); + } + + $nodes = $this->menuItem::where('_lft', 1)->orderBy('menu_id')->get()->load('siblings'); + + $this->assertSame( + array_map(fn (int $menu): array => [$this->key($menu * 2 + 1)], range(1, $menus)), + $nodes->map(fn (MenuItem $node): array => $node->siblings->modelKeys())->all(), + ); + } + + #[DataProvider('scopedRelationParents')] + public function testRelationsRequireSelectedScopeAndParentage( + string $relation, + string $requiredColumn, + ): void { + $node = $this->menuItem::query() + ->select(['id', '_lft', '_rgt', 'depth']) + ->findOrFail($this->key(5)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation parent for [{$this->menuItem}] requires the [{$requiredColumn}] column.", + ); + + $node->{$relation}()->get(); + } + + /** + * Get each relation with the scope column its parent requires. + */ + public static function scopedRelationParents(): array + { + return [ + 'ancestors' => ['ancestors', 'menu_id'], + 'descendants' => ['descendants', 'menu_id'], + 'siblings' => ['siblings', 'menu_id'], + ]; + } + + public function testRelationExistenceQueriesCorrelateExactScopes(): void + { + DB::table('menu_items')->insert([ + 'id' => $this->key(7), + 'menu_id' => 3, + '_lft' => 1, + '_rgt' => 2, + 'parent_id' => null, + 'title' => 'only item', + 'depth' => 0, + ]); + + $this->assertFalse($this->menuItem::whereKey($this->key(7))->has('siblings')->exists()); + $this->assertSame( + $this->keys(5), + $this->menuItem::whereHas( + 'ancestors', + fn (EloquentBuilder $query): EloquentBuilder => $query->whereKey($this->key(2)), + )->orderBy('id')->pluck('id')->all(), + ); + + $node = $this->menuItem::with('siblings')->findOrFail($this->key(7)); + + $this->assertTrue($node->siblings->isEmpty()); + } + + public function testEagerAncestorsHandleAnEmptyQueryResult(): void + { + DB::flushQueryLog(); + + $node = $this->menuItem::with('ancestors')->findOrFail($this->key(1)); + + $this->assertCount(2, DB::getQueryLog()); + $this->assertTrue($node->ancestors->isEmpty()); + } + + public function testDescendants(): void + { + $node = $this->menuItem::find($this->key(2)); + + $result = $node->getDescendants(); + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(5), $result->first()->getKey()); + + $node = $this->menuItem::scoped(['menu_id' => 1])->with('descendants')->find($this->key(2)); + + $result = $node->descendants; + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(5), $result->first()->getKey()); + } + + public function testAncestors(): void + { + $node = $this->menuItem::find($this->key(5)); + + $result = $node->getAncestors(); + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(2), $result->first()->getKey()); + + $node = $this->menuItem::scoped(['menu_id' => 1])->with('ancestors')->find($this->key(5)); + + $result = $node->ancestors; + + $this->assertEquals(1, $result->count()); + $this->assertEquals($this->key(2), $result->first()->getKey()); + } + + public function testDepth(): void + { + $node = $this->menuItem::scoped(['menu_id' => 1])->withDepth()->where('id', '=', $this->key(5))->first(); + + $this->assertEquals(1, $node->depth); + + $node = $this->menuItem::find($this->key(2)); + + $result = $node->children()->withDepth()->get(); + + $this->assertEquals(1, $result->first()->depth); + } + + public function testStoredDepthWorksAcrossAQueryWithoutAConcreteScope(): void + { + $depths = $this->menuItem::query() + ->withDepth() + ->orderBy('id') + ->pluck('depth', 'id') + ->all(); + + $this->assertSame( + array_combine($this->keys(1, 2, 3, 4, 5, 6), [0, 0, 0, 0, 1, 1]), + $depths, + ); + } + + #[DataProvider('scopedRepairs')] + public function testFixTreeRepairsOnlyTheSelectedScope( + array $changes, + array $errors, + ?int $parent, + int $depth, + ): void { + if (isset($changes['parent_id'])) { + $changes['parent_id'] = $this->key($changes['parent_id']); + } + + DB::table('menu_items')->where('id', $this->key(5))->update($changes); + + $this->assertSame($errors, array_filter($this->menuItem::scoped(['menu_id' => 1])->countErrors())); + + DB::flushQueryLog(); + + $this->menuItem::scoped(['menu_id' => 1])->fixTree(); + + $queries = array_column(DB::getQueryLog(), 'query'); + + $this->assertTrue(collect($queries)->contains( + static fn (string $query): bool => preg_match( + '/^select .*menu_id.* from .*menu_items/i', + $query, + ) === 1, + )); + $this->assertFalse(collect($queries)->contains( + static fn (string $query): bool => preg_match( + '/^select .*_lft.*_rgt.*depth.*parent_id.*menu_id.*limit 1$/i', + $query, + ) === 1, + )); + + $node = $this->menuItem::findOrFail($this->key(5)); + + $this->assertSame($parent === null ? null : $this->key($parent), $node->getParentId()); + $this->assertSame($depth, $node->getDepth()); + $this->assertTreeNotBroken(1); + $this->assertOtherScopeNotAffected(); + } + + /** + * Get the corruptions of the first menu's child with its errors and repaired parentage. + */ + public static function scopedRepairs(): array + { + return [ + 'moved left bound' => [ + ['_lft' => 3], + ['invalid_intervals' => 1, 'duplicate_endpoints' => 1, 'wrong_parent' => 1], + 2, + 1, + ], + 'parent in another scope' => [ + ['parent_id' => 4], + ['missing_parent' => 1], + null, + 0, + ], + ]; + } + + public function testSaveAsRoot(): void + { + $node = $this->menuItem::find($this->key(5)); + + $node->saveAsRoot(); + + $this->assertEquals(5, $node->getLft()); + $this->assertEquals(null, $node->parent_id); + + $this->assertOtherScopeNotAffected(); + } + + public function testInsertion(): void + { + $node = $this->menuItem::create(['menu_id' => 1, 'parent_id' => $this->key(5)]); + + $this->assertEquals($this->key(5), $node->parent_id); + $this->assertEquals(5, $node->getLft()); + + $this->assertOtherScopeNotAffected(); + } + + public function testInsertionToParentFromOtherScope(): void + { + $this->expectException(ModelNotFoundException::class); + + $this->menuItem::create(['menu_id' => 2, 'parent_id' => $this->key(5)]); + } + + public function testInsertionWithParentIdBeforeScope(): void + { + $node = $this->menuItem::create(['parent_id' => $this->key(5), 'menu_id' => 1]); + + $this->assertSame($this->key(5), $node->getParentId()); + $this->assertSame(5, $node->getLft()); + $this->assertOtherScopeNotAffected(); + } + + public function testFillWithParentIdBeforeScope(): void + { + $node = new $this->menuItem; + $node->fill(['parent_id' => $this->key(5), 'menu_id' => 1])->save(); + + $this->assertSame($this->key(5), $node->getParentId()); + $this->assertSame(5, $node->getLft()); + $this->assertOtherScopeNotAffected(); + } + + public function testInsertionToParentFromOtherScopeWithParentIdBeforeScope(): void + { + $this->expectException(ModelNotFoundException::class); + + $this->menuItem::create(['parent_id' => $this->key(5), 'menu_id' => 2]); + } + + public function testRootInANewScopeStartsItsOwnTree(): void + { + $node = $this->menuItem::create(['menu_id' => 3]); + + $this->assertSame([1, 2], $node->getBounds()); + $this->assertSame(0, $node->getDepth()); + $this->assertTreeNotBroken(3); + $this->assertOtherScopeNotAffected(); + } + + public function testExistingModelCannotChangeItsNestedSetScope(): void + { + $node = $this->menuItem::findOrFail($this->key(5)); + $node->menu_id = 2; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set scope attribute [menu_id] cannot be changed on an existing [{$this->menuItem}] model.", + ); + + $node->save(); + } + + public function testPartialExistingModelCannotHideANestedSetScopeChange(): void + { + $node = $this->menuItem::query()->select(['id'])->findOrFail($this->key(5)); + $node->menu_id = null; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('scope attribute [menu_id] cannot be changed'); + + $node->save(); + } + + public function testSavingPersistedPartialModelRequiresASelectedKey(): void + { + $node = $this->menuItem::query() + ->select(['_lft', '_rgt', 'depth', 'title']) + ->whereKey($this->key(2)) + ->firstOrFail(); + $node->title = 'changed'; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set model [{$this->menuItem}] requires the [id] column to be selected.", + ); + + $node->save(); + } + + public function testNewRawMutationDefersMissingScopeFailureUntilSave(): void + { + $node = new $this->menuItem(['title' => 'missing scope']); + DB::flushQueryLog(); + + $node->rawNode(1, 2, null, 0); + + $this->assertSame([], DB::getQueryLog()); + $this->expectException(LogicException::class); + $this->expectExceptionMessageIsOrContains('attribute [menu_id] was not selected'); + + $node->save(); + } + + public function testRawMutationLoadsOnlyMissingScopeIdentity(): void + { + $node = $this->menuItem::query()->select(['id'])->findOrFail($this->key(5)); + DB::flushQueryLog(); + + $node->rawNode(4, 5, $this->key(2), 1)->save(); + + $this->assertSame(1, count(array_filter( + array_column(DB::getQueryLog(), 'query'), + static fn (string $query): bool => str_starts_with($query, 'select '), + ))); + $this->assertSame(1, $node->menu_id); + $this->assertTreeNotBroken(1); + } + + public function testRawMutationWithCompleteScopeDoesNotReadPersistedIdentity(): void + { + $node = $this->menuItem::findOrFail($this->key(5)); + $node->title = 'updated'; + DB::flushQueryLog(); + + $node->rawNode( + $node->getLft(), + $node->getRgt(), + $node->getParentId(), + $node->getDepth(), + )->save(); + + $this->assertSame(0, count(array_filter( + array_column(DB::getQueryLog(), 'query'), + static fn (string $query): bool => str_starts_with($query, 'select '), + ))); + $this->assertSame('updated', $this->menuItem::findOrFail($this->key(5))->title); + $this->assertTreeNotBroken(1); + } + + public function testSavingTheSameNestedSetScopeValueRemainsValid(): void + { + $node = $this->menuItem::findOrFail($this->key(5)); + $node->menu_id = 1; + $node->title = 'updated'; + + $this->assertTrue($node->save()); + $this->assertSame(1, $this->menuItem::findOrFail($this->key(5))->menu_id); + } + + public function testDeferredNewNodeMutationRevalidatesItsScope(): void + { + $node = new $this->menuItem(['menu_id' => 1]); + $node->appendToNode($this->menuItem::findOrFail($this->key(2))); + $node->menu_id = 2; + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Nodes must be in the same tree.'); + + $node->save(); + } + + #[DataProvider('partialCrossScopeMutationModels')] + public function testCrossScopeMutationHydratesPartialModels(string $partial): void + { + $source = $partial === 'source' + ? $this->menuItem::query() + ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail($this->key(1)) + : $this->menuItem::findOrFail($this->key(1)); + $target = $partial === 'target' + ? $this->menuItem::query() + ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail($this->key(3)) + : $this->menuItem::findOrFail($this->key(3)); + + DB::flushQueryLog(); + + $source->appendToNode($target); + + $this->assertSame([], DB::getQueryLog()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Nodes must be in the same tree.'); + + $source->save(); + } + + /** + * Get which participant of a cross-scope mutation is partially selected. + */ + public static function partialCrossScopeMutationModels(): array + { + return [ + 'partial source' => ['source'], + 'partial target' => ['target'], + ]; + } + + public function testDeletion(): void + { + $this->menuItem::find($this->key(2))->delete(); + + $node = $this->menuItem::find($this->key(1)); + + $this->assertEquals(2, $node->getRgt()); + + $this->assertOtherScopeNotAffected(); + } + + public function testDeletingPartiallySelectedScopedNodeHydratesItsTreeIdentity(): void + { + $node = $this->menuItem::query()->select(['id'])->findOrFail($this->key(2)); + + $node->delete(); + + $this->assertNull($this->menuItem::find($this->key(2))); + $this->assertNull($this->menuItem::find($this->key(5))); + $this->assertNotNull($this->menuItem::find($this->key(4))); + $this->assertNotNull($this->menuItem::find($this->key(6))); + $this->assertTreeNotBroken(1); + $this->assertTreeNotBroken(2); + } + + public function testMoving(): void + { + $node = $this->menuItem::find($this->key(1)); + $this->assertTrue($node->down()); + + $this->assertOtherScopeNotAffected(); + } + + /** + * Assert that the second menu's tree is unchanged. + */ + protected function assertOtherScopeNotAffected(): void + { + $this->assertSame( + [ + ['id' => $this->key(3), 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(4), 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'depth' => 0], + ['id' => $this->key(6), 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => $this->key(4), 'depth' => 1], + ], + DB::table('menu_items') + ->where('menu_id', 2) + ->orderBy('id') + ->get(['id', 'menu_id', '_lft', '_rgt', 'parent_id', 'depth']) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + + public function testRebuildsTree(): void + { + // A payload scope value is ignored, so the new child stays in the selected menu. + $fixed = $this->menuItem::scoped(['menu_id' => 1])->rebuildTree([ + ['id' => $this->key(2), 'children' => [['menu_id' => 2]]], + ], delete: true); + + $root = $this->menuItem::findOrFail($this->key(2)); + $child = $root->children()->sole(); + + $this->assertSame(2, $fixed); + $this->assertSame([1, 4], $root->getBounds()); + $this->assertSame(1, $child->menu_id); + $this->assertSame([2, 3], $child->getBounds()); + $this->assertSame(1, $child->getDepth()); + $this->assertNull($this->menuItem::find($this->key(1))); + $this->assertNull($this->menuItem::find($this->key(5))); + $this->assertTreeNotBroken(1); + $this->assertOtherScopeNotAffected(); + } + + public function testAppendingToAnotherScopeFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Nodes must be in the same tree.'); + + $foo = $this->menuItem::find($this->key(1)); + $bar = $this->menuItem::find($this->key(3)); + + $foo->appendToNode($bar)->save(); + } + + public function testInsertingBeforeAnotherScopeFails(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs('Nodes must be in the same tree.'); + + $foo = $this->menuItem::find($this->key(1)); + $bar = $this->menuItem::find($this->key(3)); + + $foo->insertBeforeNode($bar); + } + + public function testEagerLoadingAncestorsWithScope(): void + { + $filteredNodes = $this->menuItem::where('title', 'menu item 3')->with(['ancestors'])->get(); + + $this->assertEquals($this->key(2), $filteredNodes->find($this->key(5))->ancestors[0]->id); + $this->assertEquals($this->key(4), $filteredNodes->find($this->key(6))->ancestors[0]->id); + } + + public function testEagerLoadingDescendantsWithScope(): void + { + $filteredNodes = $this->menuItem::where('title', 'menu item 2')->with(['descendants'])->get(); + + $this->assertEquals($this->key(5), $filteredNodes->find($this->key(2))->descendants[0]->id); + $this->assertEquals($this->key(6), $filteredNodes->find($this->key(4))->descendants[0]->id); + } + + #[DataProvider('scopedEagerRelatedProjections')] + public function testScopedEagerMatchingUsesOnlyTruthfulRelatedProjections( + string $relation, + string $parentTitle, + array $columns, + array $expected, + ?string $requiredColumn, + ): void { + $nodes = $this->menuItem::where('title', $parentTitle)->get(); + + if ($requiredColumn !== null) { + $this->expectException(LogicException::class); + $this->expectExceptionMessageIs( + "Nested set relation eager load for [{$this->menuItem}] requires the [{$requiredColumn}] column.", + ); + } + + $nodes->load([ + $relation => fn (BaseRelation $query): BaseRelation => $query->select($columns), + ]); + + foreach ($expected as $parentId => $relatedIds) { + $this->assertSame( + $this->keys(...$relatedIds), + $nodes->find($this->key($parentId)) + ->getRelation($relation) + ->pluck('id') + ->sort() + ->values() + ->all(), + ); + } + } + + /** + * Get scoped eager related projections with their expected matches or missing column. + */ + public static function scopedEagerRelatedProjections(): array + { + return [ + 'ancestors without left bound' => [ + 'ancestors', + 'menu item 3', + ['id', 'menu_id', '_rgt'], + [], + '_lft', + ], + 'ancestors without right bound' => [ + 'ancestors', + 'menu item 3', + ['id', 'menu_id', '_lft'], + [], + '_rgt', + ], + 'descendants without left bound' => [ + 'descendants', + 'menu item 2', + ['id', 'menu_id', '_rgt'], + [], + '_lft', + ], + 'descendants need no related right bound' => [ + 'descendants', + 'menu item 2', + ['id', 'menu_id', '_lft'], + [2 => [5], 4 => [6]], + null, + ], + ]; + } +} diff --git a/tests/NestedSet/ScopedNodeUuidTest.php b/tests/NestedSet/ScopedNodeUuidTest.php new file mode 100644 index 0000000000..e30770eb08 --- /dev/null +++ b/tests/NestedSet/ScopedNodeUuidTest.php @@ -0,0 +1,30 @@ +', Post::query()->withTrashed()); + assertType('Hypervel\Database\Eloquent\Builder', Post::query()->withoutTrashed()); + assertType('Hypervel\Database\Eloquent\Builder', Post::query()->onlyTrashed()); + assertType('int', Post::query()->restore()); + assertType('Hypervel\Types\SoftDeletes\Post', Post::query()->restoreOrCreate(['title' => 'Draft'])); + assertType('Hypervel\Types\SoftDeletes\Post', Post::query()->createOrRestore(['title' => 'Draft'])); + + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', Article::withTrashed()); + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', Article::query()->onlyTrashed()->published()); + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', $article->withoutTrashed()); + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', FeaturedArticle::withTrashed()); + assertType('Hypervel\Types\SoftDeletes\FeaturedArticle', FeaturedArticle::query()->restoreOrCreate()); + + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $post->comments()->withTrashed()); + assertType('int', $post->comments()->restore()); + assertType('Hypervel\Types\SoftDeletes\Comment', $post->comments()->createOrRestore()); + + // The macro runs before a same-named scope, as in Builder::__call(). + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', Archive::onlyTrashed()); + assertType('Hypervel\Types\SoftDeletes\ArticleBuilder', Archive::query()->onlyTrashed()); + + // Macro names are case-sensitive, and only soft-deleting models have them. + Post::query()->WithTrashed(); // @phpstan-ignore method.notFound + Plain::query()->withTrashed(); // @phpstan-ignore method.notFound +} + +class Post extends Model +{ + use SoftDeletes; + + /** + * Get the post's comments. + * + * @return HasMany + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class); + } +} + +class Comment extends Model +{ + use SoftDeletes; +} + +trait Archivable +{ + use SoftDeletes; +} + +class Article extends Model +{ + use Archivable; + + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = ArticleBuilder::class; +} + +class FeaturedArticle extends Article +{ +} + +/** + * @template TModel of Model + * + * @extends Builder + */ +class ArticleBuilder extends Builder +{ + /** + * Limit the query to published articles. + */ + public function published(): static + { + return $this->whereNotNull('published_at'); + } +} + +class Archive extends Model +{ + use SoftDeletes; + + /** @use HasBuilder> */ + use HasBuilder; + + protected static string $builder = ArticleBuilder::class; + + /** + * Limit the query to archives trashed for the given reason. + * + * @param Builder $query + */ + protected function scopeOnlyTrashed(Builder $query, string $reason): void + { + } +} + +class Plain extends Model +{ +} diff --git a/types/NestedSet/NestedSet.php b/types/NestedSet/NestedSet.php new file mode 100644 index 0000000000..02f2171ee6 --- /dev/null +++ b/types/NestedSet/NestedSet.php @@ -0,0 +1,82 @@ + + */ +class NestedSetTypeMenuBuilder extends QueryBuilder +{ +} + +class NestedSetTypeMenuItem extends Model +{ + use HasNode; + + /** @use HasBuilder> */ + use HasBuilder { + HasNode::newEloquentBuilder insteadof HasBuilder; + } + + protected static string $builder = NestedSetTypeMenuBuilder::class; +} + +$category = new NestedSetTypeCategory; + +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', NestedSetTypeCategory::query()); +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', NestedSetTypeCategory::scoped([])); +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', NestedSetTypeCategory::whereIsRoot()); +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', NestedSetTypeSubcategory::query()); +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', $category->newNestedSetQuery()); +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', $category->nextSiblings()); +assertType('NestedSetTypeCategory|null', NestedSetTypeCategory::query()->root()); +assertType('Hypervel\NestedSet\Eloquent\Collection', NestedSetTypeCategory::query()->descendantsOf(1)); + +assertType('Hypervel\NestedSet\Eloquent\AncestorsRelation', $category->ancestors()); +assertType('Hypervel\NestedSet\Eloquent\DescendantsRelation', $category->descendants()); +assertType('Hypervel\NestedSet\Eloquent\SiblingsRelation', $category->siblingsAndSelf()); +assertType('Hypervel\NestedSet\Eloquent\Collection', $category->descendants()->getResults()); +assertType('Hypervel\Database\Eloquent\Relations\BelongsTo', $category->parent()); + +assertType('Hypervel\NestedSet\Eloquent\Collection', $category->getAncestors()); +assertType('Hypervel\NestedSet\Eloquent\Collection', $category->getAncestors()->toTree()); + +$keyedCategories = new Collection(['root' => $category]); + +assertType('Hypervel\NestedSet\Eloquent\Collection', $keyedCategories); +assertType('Hypervel\NestedSet\Eloquent\Collection', $keyedCategories->toFlatTree()); +assertType('NestedSetTypeCategory|null', $category->getNextSibling()); + +assertType('NestedSetTypeMenuBuilder', NestedSetTypeMenuItem::query()); +assertType('NestedSetTypeMenuBuilder', NestedSetTypeMenuItem::whereIsLeaf()); + +$softCategory = new NestedSetTypeSoftCategory; + +assertType('Hypervel\NestedSet\Eloquent\QueryBuilder', NestedSetTypeSoftCategory::withTrashed()); +assertType('Hypervel\NestedSet\Eloquent\AncestorsRelation', $softCategory->ancestors()->withTrashed()); diff --git a/types/Scout/Searchable.php b/types/Scout/Searchable.php new file mode 100644 index 0000000000..4fb1daec30 --- /dev/null +++ b/types/Scout/Searchable.php @@ -0,0 +1,56 @@ +where('price', '>', 100)->searchable()); + /* @phpstan-ignore method.void */ + assertType('null', Order::query()->unsearchable(500)); + /* @phpstan-ignore method.void */ + assertType('null', $user->orders()->searchable()); + /* @phpstan-ignore method.void */ + assertType('null', $user->storeOrders()->unsearchable()); +} + +class Order extends Model +{ + use Searchable; +} + +class Store extends Model +{ +} + +class User extends Model +{ + /** + * Get the user's orders. + * + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + /** + * Get the orders placed through the user's stores. + * + * @return HasManyThrough + */ + public function storeOrders(): HasManyThrough + { + return $this->hasManyThrough(Order::class, Store::class); + } +}