From bcc1f434037d83b4804cd738d02151d15834fa7a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:12:51 +0000 Subject: [PATCH 01/11] docs: plan Nested Set invariants and performance Record the approved Nested Set architecture before the implementation history. The plan defines structural ownership, scoped tree identity, stored depth, typed keys, relation behavior, repair boundaries, and worker-lifetime state. Capture the cross-driver index and eager-matching benchmarks, including their write, storage, and memory tradeoffs. Document the bounded design choices and the mechanisms deliberately rejected to keep the package scalable without speculative machinery. Specify the unit and MySQL, MariaDB, PostgreSQL, and SQLite validation matrix, plus the post-compaction and anti-overengineering rules used throughout the work. --- ...t-invariants-performance-and-modern-api.md | 932 ++++++++++++++++++ 1 file changed, 932 insertions(+) create mode 100644 docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md diff --git a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md new file mode 100644 index 000000000..058b7fe76 --- /dev/null +++ b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md @@ -0,0 +1,932 @@ +# Complete Nested Set Invariants, Performance, and Modern APIs + +## Status + +Investigation, benchmarks, owner gates, implementation, validation, +self-review, peer code review, and final audit-record review are complete. + +## Scope + +Modernize `hypervel/nested-set` using +`aimeos/laravel-nestedset@90ea384febeaaa97967f43b1af9661d4edf2354a` +as the sole ongoing reference. Aimeos is a source of current behavior and tests, +not a parity contract. Hypervel keeps its coroutine-local lifecycle, immutable +dates, strict types, supported database policy, and scalability requirements. + +The work must: + +- support bigint, integer, native UUID where available, and ULID model keys; +- make unscoped and scoped trees correct on MySQL, MariaDB, PostgreSQL, + and SQLite; +- maintain bounds, parentage, and stored depth as one database invariant; +- make relations, eager matching, repair, and diagnostics scope-correct; +- preserve caller ordering and useful existing APIs; +- remove stale state, dead overrides, duplicate predicates, and misleading + docs; and +- add no package lock, retry policy, compatibility branch, schema + introspection cache, worker-retained tree index, or hidden network call. + +## Post-compaction recovery and anti-overengineering rules + +After compaction, read `AGENTS.md` and this plan in full before resuming. Do not +reread the framework-wide audit plan; this section carries its applicable +rules. + +- Require a supported path and meaningful harm before treating a concern as a + defect. Merely conceivable states do not justify machinery. +- Trace the owner, callers, callees, commit boundary, cleanup, siblings, tests, + and upstream behavior before changing code. Upstream difference is not proof + of a bug, and upstream parity is not proof of correctness. +- Fix verified failures completely at the lowest inconsistent owner. Do not + compensate in callers, retain partial fixes to reduce churn, or leave + same-family defects behind. +- Prefer existing Eloquent, Schema, PHP, and database facilities. Do not + duplicate framework metadata, casting, lifecycle, or cleanup locally. +- Add no abstraction, registry, retry, backoff, timeout, lock, config switch, + context slot, cache, or extension point unless a verified requirement below + needs it or it deletes greater complexity. +- Do not enforce invariants across deliberate escape hatches such as raw SQL, + raw builders, or disabled model events. Document their responsibility when + the public contract requires it. +- For coroutine or worker state, identify the shared state, realistic + interleaving, and harm before adding isolation. Mutable operation state is + never process-global. +- Backward compatibility does not preserve flawed Hypervel-only internals, but + retain useful Laravel/Eloquent-shaped APIs and conventions unless an + approved improvement requires a precise correction. +- Any newly discovered public divergence or source-proven hot-path regression + returns to owner review before implementation. +- Account for queries, rows locked, index writes, allocations, sorting, + hashing, serialization, container lookups, retained worker memory, and cache + invalidation. Do not implement improvements whose practical effect is noise. +- Keep tests deterministic and behavior-focused. Do not add production seams, + timing assertions, or exhaustive tests of private mechanics. +- Do not make source awkward or slower for PHPStan. Correct real types first, + then use local narrowing or a precise ignore for analysis limitations. +- Remove every superseded property, helper, import, ignore, comment, test + fixture, and documentation statement in the same change. +- If implementation exposes an unexpected defect or contradiction, stop that + edit, investigate the complete related slice, obtain focused second-opinion + consensus, replace the affected plan text, and then resume. + +## Architecture and research + +### Ownership + +| Surface | Owner and lifetime | +|---|---| +| Pending model action | Model instance | +| Structural freshness | Coroutine-local, keyed by logical connection and table | +| Bounds, parent, depth | Database row and concrete nested-set scope | +| Visibility | Ordinary Eloquent global scopes; never a tree partition | +| Eager matching index | One relation operation; released after matching | +| Trait membership cache | Worker-static, per model class | +| Blueprint macros | Provider boot state; existing `Blueprint::flushState()` cleanup | +| Concurrent mutation serialization | Application transaction and application lock | + +The same physical tree can be addressed through multiple model classes. +Freshness and structural identity therefore belong to resolved connection plus +table, not model class. Nested-set scope attributes partition trees within that +table. Visibility global scopes do not. + +### Measured design choices + +Benchmarks used PHP 8.4. Eager matching used synthetic 20,000- and +100,000-node PHP fixtures; database measurements used a 50,000-node, +ten-scope UUID-keyed fixture on all four engines. They are research evidence, +not CI timing thresholds. + +| Read shape | Current | Aimeos | Final adaptive design | +|---|---:|---:|---:| +| 200 disjoint descendant parents / 77,860 results | 1,434 ms | 113 ms | 21 ms | +| 100 scoped descendant parents / 99,900 results | 558 ms | 3,890 ms | 27 ms | +| Same descendants with custom result order | 1,121 ms | 3,824 ms | 34 ms | +| One root / 99,999 descendants | 9.6 ms | 338 ms | 9.2 ms | +| 100 scoped ancestor chains / 99,900 results | 414 ms | 3,927 ms | 23 ms | +| One-scope ancestors / 500 parents / 189 results | 5.6 ms | 4.9 ms | 5.3 ms | + +Aimeos retained about 37–45 MiB on the large cases; the final design retained +about 6 MiB and avoids worker-retained indexes. + +The final schema indexes are: + +```text +(scope..., _rgt) +(scope..., _lft) +(scope..., parent_id, _lft) +``` + +Unscoped trees omit the scope prefix. Compared with the current index, the +layout reduced representative scoped ancestor/children reads from +15.6/27.4 ms to 0.35/0.28 ms on MySQL, 9.2/9.1 ms to 0.21/0.24 ms on MariaDB, +1.1/2.7 ms to 0.21/0.14 ms on PostgreSQL, and 5.6/4.3 ms to 0.56/0.02 ms on +SQLite. Removing the dedicated `_rgt` index caused material MySQL/MariaDB +regressions. + +The extra indexes increased representative index storage and made gap updates +about 1.6x slower on MySQL and 1.3x slower on PostgreSQL; MariaDB and SQLite +were neutral or faster. They also prevented unrelated scopes with +reused coordinates from blocking each other in the ten-scope MySQL/MariaDB +probe. They are not a serialization guarantee: MariaDB chose a table scan and +still blocked in a separate two-large-scope probe. This bounded write/storage +cost is approved for the large read and scope-isolation gain. + +Stored depth changed a 1,000-node projection from 5,410 ms to 6.2 ms on MySQL, +779 ms to 1.5 ms on MariaDB, 55.6 ms to 0.74 ms on PostgreSQL, and 54.5 ms to +0.30 ms on SQLite. A default depth index is rejected: although it improved +depth filters by about 2–14x, it slowed structural writes by roughly 20–50%. +Applications that frequently filter by depth may add +`(scope..., depth, _lft)`. + +The endpoint-window integrity check ran in about 5–29 ms. Aimeos's pairwise +crossing check took about 1.3–9 seconds and exceeded 90 seconds under one +layout. Do not port its pairwise join or whole-tree PHP/Fenwick scanner. + +## Public result + +Existing useful model and builder operations remain. The intentional public +corrections/additions are: + +- `Collection` root/key APIs accept `Model|int|string|null|false` where + applicable, with `false` alone meaning infer; +- `toFlatTree()` no longer incorrectly accepts only `bool`; +- `getParentId()`, its mutator, and parent-facing metadata support + `int|string|null`; +- `getDepthName()`, `getDepth()`, and `setDepth()` expose stored depth; +- recursive `create()` truthfully returns `static`, never null; +- a real Eloquent `siblings()` relation supports lazy/eager/existence/count; +- `siblingsAndSelf()` uses the same relation with self inclusion; +- `QueryBuilder::moveNode()` may omit target depth, resolved by the new + `depthForPosition()` method; +- `fixTree()` accepts explicit observer-required columns; +- rebuild root parameters truthfully require models; +- `countErrors()` returns named invariant categories and scoped models require + an explicit `scoped([...])` selection; +- static and Blueprint schema helpers support bigint, integer, UUID, ULID, and + scoped indexes; and +- bulk descendant deletion remains the scalable default, with protected + Laravel-shaped hooks for evented deletion. + +These gates, mandatory stored depth, the integrity result change, and the +bounded `isNode()` cache are owner-approved. + +## 1. Provenance, package metadata, and provider + +Change the README and Boost guide to name Aimeos as the sole ongoing source. +Lazychaser remains historical Git ancestry and does not need a second active +reference. + +Add `NestedSetServiceProvider` and register the macros in `register()`: + +```php +Blueprint::macro('nestedSet', function (array $scopes = []): void { + NestedSet::columns($this, $scopes); +}); + +Blueprint::macro('integerNestedSet', function (array $scopes = []): void { + NestedSet::integerColumns($this, $scopes); +}); + +Blueprint::macro('uuidNestedSet', function (array $scopes = []): void { + NestedSet::uuidColumns($this, $scopes); +}); + +Blueprint::macro('ulidNestedSet', function (array $scopes = []): void { + NestedSet::ulidColumns($this, $scopes); +}); + +Blueprint::macro('dropNestedSet', function (array $scopes = []): void { + NestedSet::dropColumns($this, $scopes); +}); +``` + +Macroable rebinds these closures to the active Blueprint, so `$this` is the +table blueprint. Add provider discovery to the split and root Composer +metadata. Do not add package-specific macro reset machinery: database test +cleanup already calls `Blueprint::flushState()`, and each rebuilt application +re-registers the provider. + +After restore refactoring, re-scan package imports. Remove `nesbot/carbon` only +if no source file directly uses it. Keep the real Collections, Context, +Database, and Support dependencies. + +## 2. Canonical schema and stored depth + +Add `NestedSet::DEPTH = 'depth'`. Every schema helper creates `_lft`, `_rgt`, +`depth`, a nullable typed `parent_id`, and the three indexes above: + +```php +public static function columns(Blueprint $table, array $scopes = []): void; +public static function integerColumns(Blueprint $table, array $scopes = []): void; +public static function uuidColumns(Blueprint $table, array $scopes = []): void; +public static function ulidColumns(Blueprint $table, array $scopes = []): void; +public static function dropColumns(Blueprint $table, array $scopes = []): void; +``` + +Every helper uses unsigned-integer `_lft` and `_rgt` columns with a default of +`0`. PostgreSQL's signed integer range gives the portable limit: at most +1,073,741,823 nodes in one scope because the maximum endpoint is twice the row +count. Widening both indexed, frequently updated columns for a larger +theoretical tree would impose real storage and write cost. + +`columns()` uses unsigned-big-integer parent IDs to match `$table->id()`. +`integerColumns()` matches `$table->increments()`. UUID uses each database +grammar's UUID type: native on PostgreSQL and supported MariaDB versions, +compatible storage elsewhere. ULID uses the framework's 26-character type. + +Depth is: + +```php +$table->unsignedSmallInteger(NestedSet::DEPTH)->default(0); +``` + +The portable non-negative ceiling is PostgreSQL's 32,767; MySQL/MariaDB allow +65,535 and SQLite uses 64-bit integer affinity. A deeper hierarchy is not a +realistic reason to widen every row and optional index. + +Scope names are ordered existing application columns and only prefix indexes. +The helper does not create scope columns. Create them first with their desired +native type: + +```php +$table->foreignId('menu_id'); +$table->nestedSet(['menu_id']); +``` + +`dropColumns()` takes the same ordered scope list and deterministically drops +the exact indexes and columns. Public `getDefaultColumns()` remains the +canonical structural column list used by that drop path and includes `_lft`, +`_rgt`, `parent_id`, and `depth`. Document the required symmetry. Add no index +introspection, dynamic schema-method string, arbitrary ID-column parameter, +self-referential foreign key, package migration, or default depth index. + +Make the default test fixture use `$table->id()`. Add explicit narrow integer, +UUID, and ULID fixtures. + +## 3. Depth mutation and reads + +Roots use depth `0`; children use parent depth plus one; sibling insertion uses +the target depth. Every insert, move, raw-node update, repair, rebuild, +recursive create, and replication path must either maintain or deliberately +exclude depth. `replicate()` excludes depth together with parent and bounds. + +Update `HasNode`'s trait metadata to: + +```php +/** + * @template TModel of Model + * + * @property int|string|null $parent_id + * @property ?int $depth + * @property ?static $parent + */ +``` + +Expose the canonical model surface: + +```php +public function getDepthName(): string; +public function getDepth(): ?int; +public function setDepth(?int $value): static; +public static function create(array $attributes = [], ?self $parent = null): static; +public function rawNode( + int $lft, + int $rgt, + int|string|null $parentId, + ?int $depth +): static; +``` + +Update depth in the same structural SQL statement as bounds. MySQL/MariaDB +evaluate assignments left-to-right, so movement patch arrays place depth +before `_lft` and `_rgt` whenever the depth expression reads old bounds. Add no +query solely to persist a known depth. + +Replace correlated depth reads and filters with the stored column. +`withDepth($as)` selects/aliases the qualified stored column directly and +works for scoped and trashed rows. `getNodeData()` returns named left, right, +and depth values; `getPlainNodeData()` still extracts only `[left, right]` for +range consumers. + +Internal model actions pass an explicit target depth when the source model has +loaded it. A partial-select before/after action and direct low-level callers +may omit it; the public builder derives the persisted target depth: + +```php +public function moveNode( + int|string $key, + int $position, + ?int $targetDepth = null, + array $nodeData = [] +): int; + +public function depthForPosition(int $position): int +{ + // 0 when no containing node exists; otherwise containing depth + 1. +} + +$depthDelta = ($targetDepth ?? $this->depthForPosition($position)) - $currentDepth; +``` + +The lookup uses the structural, nested-set-scoped builder, includes structural +soft-deleted rows, orders containing nodes by `_lft` descending, and is served +by `(scope..., _lft)`. There is no `-1` sentinel, caller-side `+1`, or shadowed +`$depth` variable. + +## 4. Structural state and builder ownership + +Delete deleted-at state from `NodeContext`. During `restored`, read the exact +stored previous value from: + +```php +$model->getPrevious()[$model->getDeletedAtColumn()] +``` + +Eloquent publishes that raw, database-precision value before the event. Pass it +to descendant restore without writing it back onto the restored model. Do not +round to the start of a second or add a stack/registry. This fixes nested +same-class restores and prevents a later ordinary save from re-deleting the +restored row. + +Delete `HasNode::$hasSoftDelete` and use `Model::isSoftDeletable()`. Eloquent +already owns the correct per-class worker cache and cleanup. + +Key freshness by a length-delimited logical identity derived from the resolved +connection's `getName()`, falling back through the resolver's default and then +to a stable default marker when a custom resolver provides neither, plus the +model table. Explicit and default aliases for one logical connection must +converge. Do not use model class, connection object identity, or scope. + +Pending-action APIs stage parentage and action intent only; the action owns +bounds and depth. In each `insertAt()` branch, publish freshness immediately +before that branch's first interval mutation. Also publish immediately before +force deletion closes its physical gap and before raw repair/rebuild +mutations. Do not defer publication until `callPendingActions()` returns: +append/prepend refreshes the parent after `insertAt()`, and that in-action +refresh must observe the new `_rgt`. +Table-wide invalidation may cause an extra refresh across scopes but cannot +miss a structural write. + +The internal structural builder is: + +```php +return $this->applyNestedSetScope( + $this->newQuery()->withoutGlobalScopes(), + $table, +); +``` + +Starting with `newQuery()` is load-bearing: global scopes first install their +builder extensions, then `withoutGlobalScopes()` removes their filtering. +This retains SoftDeletes macros while allowing structural operations to see +every row. `newScopedQuery()` stays user-facing and retains ordinary global +scopes. Parent resolution uses the structural builder plus `withoutTrashed()`. + +Apply this boundary to movement, gap changes, deletion/restoration, depth, +integrity, repair, and rebuild. A blank model must never contribute null scope +values to aliased structural queries. + +Qualify model-owned structural columns in user-composable read predicates and +ordering so they remain valid after joins. This covers `whereIsRoot()`, +`whereIsLeaf()`, `hasChildren()`, before/after predicates, `defaultOrder()`, +and the next/previous node and sibling queries. `wrappedColumns()` returns +qualified bounds, and `whereAncestorOf()` consumes them without adding a +second table prefix. Keep internal `UPDATE` assignment targets unqualified: +SQLite rejects qualified columns on the left side of `SET`. +`defaultOrder()` uses the base query's `reorder()` API so prior ordinary and +union order clauses and their bindings are removed together. It qualifies the +structural column for ordinary queries and uses the projected unqualified +column for compound queries, whose result-level `ORDER BY` cannot reference a +source table. + +Respect Eloquent's cached `#[UseEloquentBuilder]` metadata. A configured +builder that subclasses Nested Set `QueryBuilder` is instantiated; an +incompatible configured builder throws a descriptive `LogicException`; the +default uses Nested Set `QueryBuilder`. Delete dead `newModelBuilder()` and do +not duplicate Model's builder cache. + +Truthful builder types: + +```php +public function newNestedSetQuery(?string $table = null): QueryBuilder; +public function newScopedQuery(?string $table = null): QueryBuilder; +public static function scoped(array $attributes): QueryBuilder; + +/** + * @template TQuery of BaseQueryBuilder|EloquentBuilder + * @param TQuery $query + * @return TQuery + */ +public function applyNestedSetScope( + BaseQueryBuilder|EloquentBuilder $query, + ?string $table = null +): BaseQueryBuilder|EloquentBuilder; +``` + +## 5. Scope values, keys, and predicates + +Add one public model-owned `getNestedSetScope()` returning the configured +attribute names and normalized values in declared order. Both SQL predicates +and eager bucket identity consume this map. + +Normalize each value as: + +```php +$value = enum_value($value); + +return match (true) { + $value === null, is_int($value), is_string($value) => $value, + is_bool($value) => (int) $value, + $value instanceof DateTimeInterface => $value->format('Y-m-d H:i:s'), + $value instanceof Stringable => (string) $value, + default => throw new LogicException(/* model and attribute */), +}; +``` + +Place `DateTimeInterface` before `Stringable`: a future date implementation's +`__toString()` must not silently change database identity. Reject floats, +arrays, resources, and other objects. Float text is precision/INI-dependent +and unsuitable as a tree partition key; fail descriptively rather than permit +silent bucket collisions or add a float encoder. + +Encode a scope tuple once per model/result using values only, because declared +attribute order is fixed: + +```php +$key = ''; + +foreach ($model->getNestedSetScope() as $value) { + $key .= $value === null + ? '-1:' + : strlen((string) $value).':'.$value; +} +``` + +This distinguishes null, empty string, false, and delimiter-like values while +converging database integer `1`, request string `'1'`, true, and matching +backed enums. No JSON, PHP serialization, escaping scheme, recursive encoder, +connection lookup, or attribute-name repetition is needed. + +Scalar model/parent dictionaries use a separate roots list and plain non-null +PHP array keys. Do not add type prefixes, null markers, or `(int)` casts: +those add measured CPU/memory and a wrong model key type would collapse UUID +or ULID trees. For strict parent/predicate comparisons, after non-null guards, +compare `(string) $left === (string) $right`. + +Use `Model::is()` as the row-identity fast path. When its raw default/explicit +connection names differ, compare non-null keys plus the same resolved logical +connection/table identity used by freshness. Exact normalized scope maps then +establish tree identity. Self-inclusive ancestor/descendant predicates require +persisted row and tree identity. Sibling predicates require persisted, +distinct rows in the same concrete scope with equal normalized parent IDs; +two roots in one scope are siblings because null equals null here. Child +predicates require a persisted parent, the same concrete scope, and the +child's non-null normalized parent ID to equal the parent's persisted key. +Only `null` means no parent; `0` and `''` remain valid. + +Rename the protected target guard to `assertNodeInTree()`: + +```php +if (($node->getLft() ?? 0) < 1 || ($node->getRgt() ?? 0) < 1) { + throw new LogicException('Node must be part of a tree.'); +} +``` + +Do not grow this into a full interval-integrity check. Positive hand-set bounds +are a deliberate low-level bypass. + +Defer parent-ID lookup until all filled scope attributes are present at the +saving action boundary. Preserve the `void` mutator and accept +`int|string|null`. Parent lookup is structural and excludes trashed parents. + +```php +public function setParentIdAttribute(int|string|null $value): void; +public function getParentId(): int|string|null; +``` + +Scalar coordinate lookups in `whereAncestorOf()` and before/after predicates +use `newNestedSetQuery($alias)`. This removes ordinary global-scope filters +from the aliased subquery while applying a concrete nested-set scope to that +alias. A scalar key outside the selected tree therefore resolves to no +coordinate. A scoped model without a concrete scope rejects scalar +ancestor/descendant/before/after lookups and names `scoped()` instead of +silently returning inconsistent empty or missing-model results. Node-based +before/after predicates group the node's exact scope and coordinate comparison +under the requested boolean so `boolean: 'or'` semantics remain correct. Pass +node coordinate bindings with their raw predicates and remove comments that +claim scalar lookups are unscoped. + +## 6. Relations and adaptive eager matching + +Add a real `SiblingsRelation` used by both `siblings()` and +`siblingsAndSelf()`, supporting lazy loading, eager loading, `has`/`whereHas`, +and counts for roots and non-roots. It applies exact scope in constraints and +matching, excludes self where required, and respects a custom parent column. +Its existence query uses portable null-safe parent correlation so roots match +roots. Every Nested Set relation's `getForeignKeyName(): string` returns the +parent model's configured parent column. The sibling relation's qualified +helper delegates to that shared implementation and qualifies the result +through the related model. + +`BaseRelation` redeclares the inherited query property with the same native +Eloquent builder type and a truthful `@var QueryBuilder`, matching the subtype +enforced by its constructor. Keep forwarded `select()` calls separate from +`newQuery()` where PHPStan would otherwise infer the underlying base query +builder through Eloquent's mixin. Initialize eager index buckets before taking +references to them. + +Order ancestors explicitly root-to-parent. Do not impose a default order on +descendants: a caller's `descendants()->orderBy(...)` remains authoritative. + +For ancestor/descendant existence queries, correlate every inner alias scope +column with the corresponding outer row column. Correlated scope equality is +portable and null-safe: equal values or both null. Never bind scope from the +blank relation replica. Derive the outer qualifier from the supplied parent +query so nested `whereHas()` levels correlate against the immediately enclosing +alias rather than the outermost table. + +`whereAncestorOf($node)` and `whereDescendantOf($node)` already own their scope +predicate. Remove the duplicate trailing scope predicate from both relation +`addConstraints()` methods. The new sibling relation applies scope exactly +once. + +Eager constraints deduplicate and reduce parent intervals within each exact +scope, and constrain to no rows when the parent set is empty. Sibling matching +uses an operation-local scope-and-parent bucket, with a separate root bucket; +self exclusion is applied only for `siblings()`. Ancestor/descendant matching +uses four bounded paths and no others: + +1. one parent: direct current scan; +2. results bucketed by exact scope during the existing O(R) construction pass; +3. monotonic descendant bucket: binary lower bound at `_lft + 1`, stop at + `_rgt`; +4. non-monotonic/custom order or ancestors: scan only the matching scope + bucket in query order. + +For ancestors, retain the direct scan when all parents share one scope. +`matches()` remains the correctness authority. Do not port Aimeos's global +sort, position map, result re-sort, weak ancestor binary index, sweep-line +matcher, or further heuristics. + +The base scope index stores only result models. Descendants add left-bound +values and monotonic-order tracking for their binary-search path; ancestors do +not allocate fields they never read. + +Qualify sibling parent/key predicates through the related model so joins remain +unambiguous. Keep the configured plain and qualified foreign-key accessors on +the shared relation base. + +## 7. Collection linking and traversal + +`Collection::getRootNodeId()` returns `int|string|null`. `toTree()` and +`toFlatTree()` accept `Model|int|string|null|false`, where `false` alone asks +the collection to infer the root and explicit `null` selects root-level nodes. +Reject meaningless `true`. Never use `$root ?: null`. + +Rebuild linking from array-backed parent buckets plus a separate roots list. +Clear stale parent/children relations before relinking. Each parent gets one +relation-free clone shared by its children; this exposes legitimate loaded +parent data without creating parent/children serialization cycles. Apply the +same rule during recursive model creation, cloning only after every child write +has settled the parent's final bounds and then assigning that clone in one +second pass. Every node, including a leaf, gets a loaded `children` relation; +child collections preserve the input collection order. + +Remove the blanket `getArrayableRelations()` suppression. Use iterative +flattening to avoid recursive stack growth, but retain straightforward +recursion for already-nested rebuild input where no realistic stack failure +was demonstrated. + +## 8. Structural writes and deletion + +`makeGap($cut, 0)` returns `0` before constructing SQL. Do not also patch the +lower-level column expression for an unreachable zero update. + +Use the indexable gap predicate `_rgt >= $cut`. Preserve Hypervel's narrower +movement range rather than Aimeos's broader overlap update, which includes +containing ancestors in no-op updates and widens locks. + +Use known source/target bounds and depth in movement SQL. Refresh the source +only when the coroutine freshness marker proves another structural operation +could have made it stale. Publish freshness after that check and immediately +before the movement update; publish new-node insertion immediately before its +gap update. Refresh `insertAfterNode()`'s target after success, matching +`insertBeforeNode()`, and invalidate structural relations on refreshed or +moved models. + +Publication remains before the builder call when a requested movement has zero +distance. That may cause one conservative later refresh, but moving it after +the builder return would publish too late for callers that refresh related +models immediately after the structural operation. + +Keep set-based descendant deletion as the default. It scales and preserves +existing Hypervel behavior, but descendant observers do not run. Expose only +these protected switches: + +```php +protected function shouldFireDescendantEvents(): bool +{ + return false; +} + +protected function getDescendantDeleteChunkSize(): int +{ + return 1000; +} +``` + +The opt-in evented path uses descending left-bound keyset chunks and a +per-model `deletingAsDescendant` flag to prevent the package's own recursive +cascade/gap handling while leaving application model events enabled. Set and +clear that flag with `try`/`finally`. Hard/force deletion is children-first and +includes trashed descendants. Soft deletion remains after the parent's own +soft delete so descendant timestamps are not earlier than the restore cutoff. +A child veto (`delete() === false`) throws; the documented application +transaction rolls back earlier work. + +Every structural mutation, including one append/move/delete call, spans +multiple statements. Public docs require a database transaction and +application-owned serialization for concurrent writers to the same +connection/table/scope. Do not add a package mutex, distributed lock, retry, +timeout, config key, or implicit network operation. + +## 9. Integrity, repair, and rebuild + +`countErrors()` returns: + +```php +[ + 'invalid_intervals' => int, + 'duplicate_endpoints' => int, + 'missing_endpoints' => int, // portable 0/1 range invariant + 'crossing_intervals' => int, + 'missing_parent' => int, + 'wrong_parent' => int, + 'wrong_depth' => int, +] +``` + +`invalid_intervals` covers non-positive, reversed, and even-width intervals. +`duplicate_endpoints` owns endpoint uniqueness. `missing_endpoints` checks the +per-scope `min = 1` and `max = 2 * rows` invariant for non-empty trees; an +empty tree is healthy. It does not duplicate the distinctness category. +`missing_parent` counts non-null parent keys with no same-scope row. +`wrong_parent` covers an existing parent that is not the child's immediate +containing node. Compute it with a two-table child/parent join: a correct +existing parent contains the child and has +`child.depth = parent.depth + 1`. Do not retain the current three-table cross +join. `wrong_depth` covers non-zero roots and parent/child depth deltas other +than one. + +Build endpoint events in SQL: + +```text +_lft -> delta +1, expected active-before = depth +_rgt -> delta -1, expected active-before = depth + 1 +``` + +Compare expected depth with the preceding window sum ordered by endpoint and +coalesce the first empty frame to zero. Evaluate `crossing_intervals` only +when endpoints are unique; a database-side duplicate guard makes it return +`0` otherwise. This zero is conditional, not proof of no crossing. Allow +crossing and wrong-depth, and wrong-parent and wrong-depth, to co-fire. + +For scoped models, `countErrors()`, `getTotalErrors()`, and `isBroken()` require +a concrete `scoped([...])` selection. Presence is the criterion; null may be a +concrete scope value. Check presence with `array_key_exists()` against raw +attributes: `scoped(['menu_id' => null])` is concrete, while a blank model is +not. Otherwise throw a `LogicException` naming `scoped()`. + +`getTotalErrors()` remains the sum for compatibility, but documentation states +that non-zero means broken and its magnitude is not a unique-node count. +`isBroken()` performs cheap indexed/aggregate existence checks first and the +window check last. `countErrors()` assembles readable helper subqueries into +one portable select and therefore remains one round trip, including the +database-side duplicate guard. Keep SQL work bounded; do not materialize the +tree in PHP or use a quadratic crossing join. + +`fixTree(?Model $root = null, array $extraColumns = [])` and +`fixSubtree(Model $root, array $extraColumns = [])` select only structural +columns plus explicit observer-required fields and use the structural builder. +Before subtree repair/rebuild writes, one `exists` query with a `whereIn` +subquery rejects a parentage edge that leaves the supplied root's stored +interval. This proves the range-selected repair set is complete; otherwise the +operation fails descriptively rather than reporting success over rows it could +not see. Rebuild performs this check before creating any temporary zero-bound +nodes. +Repair maintains depth with iterative traversal over a separate ordered roots +list and plain non-null parent buckets. Whole-tree unresolved components become +database roots; subtree unresolved components become direct children of the +supplied root. Components are promoted in dictionary insertion order inherited +from the `defaultOrder()` result. This handles missing parents and cycles +without recursion, a second scan, or a null marker that collides with a valid +empty-string model key. When a subtree gap update can shift selected rows, +reconcile each selected model's original snapshot with the exact gap +transformation, then persist every renumbered row without rereading it. Keep +the pre-gap dirty-node tally separate for the public result. Repair/rebuild use +the structural builder for every read and gap write. Every repair/rebuild save +is checked through one shared helper. A model-event veto throws +`LogicException` naming the model class and key so the caller's transaction +rolls back every earlier structural write. + +Use truthful rebuild contracts: + +```php +public function rebuildTree(array $data, bool $delete = false, ?Model $root = null): int; +public function rebuildSubtree(Model $root, array $data, bool $delete = false): int; +``` + +Copy each payload and unset `children` plus the model key before `fill()`. +Primary keys identify matches and are never mass-assigned. Maintain depth and +scope throughout repair/rebuild. + +## 10. Bounded immutable metadata cache + +Cache `NestedSet::isNode()` results per concrete class using +`class_uses_recursive()`. The measured saving is about 2.5 microseconds per +call and matters across large model/relation construction workloads. The map +is immutable and bounded by model classes loaded in one worker. + +Add `NestedSet::flushState()` and register it once in +`AfterEachTestSubscriber::flushNestedSetState()`. Do not add a second +soft-delete cache or automatically make explicit `getAncestors()`, +`getDescendants()`, or `getSiblings()` return loaded relations: those methods +retain fresh-query semantics, while relation properties/eager loading already +provide caching. + +## 11. Documentation and cleanup + +Update `src/boost/docs/nested-set.md` in nearby Laravel-style language for: + +- bigint/integer/UUID/ULID helpers and scoped index prefixes; +- mandatory stored depth and the optional application depth index; +- sibling relations and custom builders; +- literal nested-set scopes versus visibility global scopes; +- error categories and required scoped diagnostics; +- repair extra columns and rebuild contracts; +- bulk versus protected evented descendant deletion; +- per-mutation transactions and application serialization; +- aborting the transaction when an ordinary boolean-returning model mutation + is vetoed; and +- measured index/read/write tradeoffs without internal algorithm narration. + +Update canonical examples from `increments()` to `$table->id()`. Remove stale +parent metadata, dead commented fixture resets, unused `dumpTree()` and +duplicate integrity assertions/helpers, unused `NestedSet::BEFORE` and +`NestedSet::AFTER`, obsolete PHPStan ignores, and any comments that describe +replaced behavior. Review all 112 current package-local PHPStan suppressions; +remove those made obsolete by corrected types and convert every survivor to +the exact diagnostic identifier it owns. Trait-provided Nested Set methods +reached through a base `Model` type require local `method.notFound` +suppressions because PHPStan rejects traits as types. Do not add an +analyzer-only package interface. Add truthful native types to modified fixture +scope methods. + +Keep `dirtyBounds()` bounds-only. Ordinary Eloquent dirtiness already detects +a real depth change, so nulling the original depth would add noise rather than +correctness. + +Use truthful native types on modified relation, builder, model-key, and +structural setter methods. Add concise Laravel-style method docblocks +throughout every modified source file, with short rationale comments only for +the endpoint-window invariant, isolated relation aliases, and other logic that +is not self-evident from the code. + +## 12. Audit records + +Add one companion-ledger work unit for the implemented Nested Set findings, +rejected concerns, API result, cross-package revalidation, gates, and review. +Keep `database-10` revalidated and record use of Eloquent's existing +soft-delete/builder metadata rather than a new package cache. + +This remediation does not by itself mark the package checklist complete: the +owner will decide afterward whether the prior audit is sufficient or a fresh +package-wide audit is required. Do not replace the independently active +Reverb routing entry. + +## Test plan + +Audit every current Aimeos test and merge relevant coverage into the existing +Hypervel tests while preserving Hypervel-specific regressions. + +### Unit and SQLite/Testbench regressions + +- collection inference and explicit roots: null, `0`, empty string, integer, + numeric string, UUID, ULID, and model; +- parent fill-order, numeric request strings, UUID/ULID parents, missing and + trashed parents, key `0`, and cross-scope rejection; +- per-class soft-delete metadata and re-entrant exact-timestamp restore; +- shared-table model aliases, logical connection aliases, tables, + connections, coroutine isolation, and repair/rebuild freshness publication; +- compatible/incompatible/default custom builders and Model cache cleanup; +- persisted/unsaved identity, scope-aware ancestor/descendant/sibling/child + predicates, scoped scalar and node before/after predicates, boolean + grouping, structural coordinate lookups across trashed visibility modes, + default/explicit logical connection aliases, and persisted 0/0 target + rejection, plus concrete-scope enforcement for every scalar scoped lookup; +- lazy/eager/existence/count sibling relations, custom parent columns, + configured plain foreign keys across sibling/ancestor/descendant relations, + qualified relation predicates after joins, null-root correlation, null + scopes, root-to-parent ancestors, nested `whereHas()` alias correlation, and + exactly one scope predicate per relation; +- joined root/leaf/has-children/before/after/default-order, + `withoutRoot()` / `hasParent()`, and all next/previous node and sibling + queries with qualified structural columns; +- blank scoped model family across relation existence, `withDepth()`, + `fixTree()`, and diagnostics, including Aimeos's global-scope fixture proving + structural paths ignore visibility scopes; +- scope tuple int/string/enum/date/stringable/null/empty/bool/delimiter cases, + plus descriptive float/non-stringable rejection; +- one-parent, one-scope, multi-scope, monotonic descendant, custom-order + descendant, and ancestor eager matching; +- parent serialization, relation-free clones, partial relink, recursive create, + multiple roots, wide/deep iterative flattening, and no cycles; +- every insertion/move direction, root/sibling/child depth, omitted public + target depth, root-position derivation, source/target truthfulness, relation + invalidation, append/prepend returning with the caller's parent `_rgt` + refreshed in memory, no redundant first-operation source refresh, + replication depth exclusion, and zero-height gap with no query; +- `defaultOrder()` replacing raw and union ordering without leaving stale + bindings; +- bulk and evented deletion query/chunk behavior, order, veto, soft/force + paths, independently deleted descendants, and exact restoration; +- every integrity category, scoped refusal, conditional crossing under + duplicates, short-circuit behavior, and healthy empty/deep/scoped trees; + replace the existing `oddness`/`duplicates` assertions with the final named + category contract; +- repair/rebuild depth, scopes, extra observer columns, whole-tree orphans, + subtree missing/outside/null parents, cycles, healthy named diagnostics, + complete subtree selection refusal, post-gap persistence of unchanged rows, + `rebuildSubtree()` through the shared repair path, model-only roots, key + exclusion, delete-missing, iterative traversal, and transaction-backed + rollback when a repair or rebuild save is vetoed; +- `isNode()` class separation, trait-of-trait detection, non-object/null + handling, and framework cleanup registration; and +- Blueprint macro use in separate test methods, proving flush/re-registration. + +### Database integration + +Add driver-routed coverage under: + +```text +tests/Integration/NestedSet/Database/MySql +tests/Integration/NestedSet/Database/MariaDb +tests/Integration/NestedSet/Database/Postgres +tests/Integration/NestedSet/Database/Sqlite +``` + +Use the existing `RequiresDatabase`/database test infrastructure. Share a base +only where setup and assertions are genuinely common. + +Across the matrix, prove: + +- exact bigint/integer/UUID/ULID parent types, mandatory depth, scoped and + unscoped index column order, and symmetric drops; +- native UUID trees where the driver supports them and compatible UUID storage + elsewhere; +- integer and UUID structural mutation/depth, scope isolation, soft deletes, + repair, relations, and diagnostics; +- MySQL/MariaDB depth-before-bound assignment correctness; and +- persisted moved-subtree depth across every database driver; and +- portable endpoint-window SQL and schema introspection. + +Do not assert elapsed time, a chosen optimizer plan, or cross-transaction lock +timing in normal tests. Benchmark evidence remains in this plan; functional +tests assert schema and results deterministically. + +### Validation + +Run changed files immediately, then: + +```sh +./vendor/bin/phpunit --no-progress tests/NestedSet +./vendor/bin/phpunit --no-progress tests/Integration/NestedSet +composer fix +``` + +Run driver integration groups with the repository `.env` credentials for +MySQL, MariaDB, PostgreSQL, and SQLite. Finish with a fresh diff review of +every caller/callee, scope and lifecycle boundary, SQL shape, retained memory, +public API/doc statement, dead path, and added mechanism before requesting +code review. + +## Explicit rejections + +- no mechanical Aimeos wholesale port or strict future merge parity; +- no optional-depth fallback, `hasColumn()` branch, or schema cache; +- no default depth index, widened `_lft` index, driver hint, or FK; +- no package-owned lock, retry, timeout, or config; +- no model-class/object-identity freshness key or scope-specific invalidation; +- no restore timestamp stack/registry or rounded timestamp; +- no whole-tree PHP/Fenwick scan or quadratic crossing join; +- no global eager sort, position map, result re-sort, ancestor binary index, + sweep-line matcher, or additional adaptive branch; +- no default descendant ordering that overrides caller ordering; +- no encoded scalar ID dictionaries or `(int)` UUID/ULID normalization; +- no automatic explicit-relation getter cache; +- no unconditional movement refresh or locally reimplemented target algebra; +- no broad overlap movement update without new evidence; +- no default event-per-descendant deletion; +- no iterative rewrite of already-nested rebuild input; +- no arbitrary dynamic schema type or introspective drop helper; and +- no source/test compatibility layer for the old schema or result categories. From 1a6da612c35be420be938f286f3c07000ff3f23b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:12:59 +0000 Subject: [PATCH 02/11] feat(nested-set): add typed schema and lifecycle support Register the Nested Set provider through package discovery and expose Blueprint helpers for bigint, integer, UUID, and ULID node keys. Each helper creates stored depth and the scoped structural indexes needed by ancestor, descendant, child, and sibling reads, with symmetric removal support. Complete the split package dependencies and root discovery metadata so the package works both inside the monorepo and after subtree publication. Cache immutable HasNode trait membership by concrete class and give Testing one authoritative flush hook. The cache is bounded by loaded model classes, removes repeated recursive trait discovery, and retains no request data. --- composer.json | 1 + src/nested-set/composer.json | 6 +- src/nested-set/src/NestedSet.php | 99 +++++++++++++++---- .../src/NestedSetServiceProvider.php | 37 +++++++ .../src/PHPUnit/AfterEachTestSubscriber.php | 2 +- 5 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 src/nested-set/src/NestedSetServiceProvider.php diff --git a/composer.json b/composer.json index b7ebda6ed..e3c08e4da 100644 --- a/composer.json +++ b/composer.json @@ -336,6 +336,7 @@ "Hypervel\\Log\\Context\\ContextServiceProvider", "Hypervel\\Log\\LogServiceProvider", "Hypervel\\Mail\\MailServiceProvider", + "Hypervel\\NestedSet\\NestedSetServiceProvider", "Hypervel\\Notifications\\NotificationServiceProvider", "Hypervel\\ObjectPool\\ObjectPoolServiceProvider", "Hypervel\\Passkeys\\PasskeysServiceProvider", diff --git a/src/nested-set/composer.json b/src/nested-set/composer.json index 67d042f99..2376112ed 100644 --- a/src/nested-set/composer.json +++ b/src/nested-set/composer.json @@ -30,7 +30,6 @@ }, "require": { "php": "^8.4", - "nesbot/carbon": "^3.13.1", "hypervel/collections": "^0.4", "hypervel/context": "^0.4", "hypervel/database": "^0.4", @@ -42,6 +41,11 @@ "extra": { "branch-alias": { "dev-main": "0.4-dev" + }, + "hypervel": { + "providers": [ + "Hypervel\\NestedSet\\NestedSetServiceProvider" + ] } } } diff --git a/src/nested-set/src/NestedSet.php b/src/nested-set/src/NestedSet.php index 920c45efb..60300dcad 100644 --- a/src/nested-set/src/NestedSet.php +++ b/src/nested-set/src/NestedSet.php @@ -24,36 +24,66 @@ class NestedSet public const PARENT_ID = 'parent_id'; /** - * Insert direction. + * The name of default depth column. */ - public const BEFORE = 1; + public const DEPTH = 'depth'; /** - * Insert direction. + * Cached node-trait membership by concrete class. + * + * @var array */ - public const AFTER = 2; + protected static array $nodeClasses = []; /** - * Add default nested set columns to the table. Also create an index. + * Add bigint nested set columns and indexes to the table. */ - public static function columns(Blueprint $table): void + public static function columns(Blueprint $table, array $scopes = []): void { - $table->unsignedInteger(self::LFT)->default(0); - $table->unsignedInteger(self::RGT)->default(0); + static::addBoundsAndDepth($table); + $table->unsignedBigInteger(self::PARENT_ID)->nullable(); + static::addIndexes($table, $scopes); + } + + /** + * Add integer nested set columns and indexes to the table. + */ + public static function integerColumns(Blueprint $table, array $scopes = []): void + { + static::addBoundsAndDepth($table); $table->unsignedInteger(self::PARENT_ID)->nullable(); + static::addIndexes($table, $scopes); + } - $table->index(static::getDefaultColumns()); + /** + * Add UUID nested set columns and indexes to the table. + */ + public static function uuidColumns(Blueprint $table, array $scopes = []): void + { + static::addBoundsAndDepth($table); + $table->uuid(self::PARENT_ID)->nullable(); + static::addIndexes($table, $scopes); } /** - * Drop NestedSet columns. + * Add ULID nested set columns and indexes to the table. */ - public static function dropColumns(Blueprint $table): void + public static function ulidColumns(Blueprint $table, array $scopes = []): void { - $columns = static::getDefaultColumns(); + static::addBoundsAndDepth($table); + $table->ulid(self::PARENT_ID)->nullable(); + static::addIndexes($table, $scopes); + } - $table->dropIndex($columns); - $table->dropColumn($columns); + /** + * Drop nested set columns and indexes. + */ + public static function dropColumns(Blueprint $table, array $scopes = []): void + { + $table->dropIndex([...$scopes, self::RGT]); + $table->dropIndex([...$scopes, self::LFT]); + $table->dropIndex([...$scopes, self::PARENT_ID, self::LFT]); + $table->dropColumn(static::getDefaultColumns()); } /** @@ -61,14 +91,49 @@ public static function dropColumns(Blueprint $table): void */ public static function getDefaultColumns(): array { - return [static::LFT, static::RGT, static::PARENT_ID]; + return [self::LFT, self::RGT, self::PARENT_ID, self::DEPTH]; } /** - * Replaces instanceof calls for this trait. + * Determine whether the value uses the node trait. */ public static function isNode(mixed $node): bool { - return is_object($node) && in_array(HasNode::class, class_uses_recursive($node), true); + if (! is_object($node)) { + return false; + } + + $class = $node::class; + + return static::$nodeClasses[$class] + ??= in_array(HasNode::class, class_uses_recursive($class), true); + } + + /** + * Add the common structural columns to the table. + */ + protected static function addBoundsAndDepth(Blueprint $table): void + { + $table->unsignedInteger(self::LFT)->default(0); + $table->unsignedInteger(self::RGT)->default(0); + $table->unsignedSmallInteger(self::DEPTH)->default(0); + } + + /** + * Add nested set indexes to the table. + */ + protected static function addIndexes(Blueprint $table, array $scopes): void + { + $table->index([...$scopes, self::RGT]); + $table->index([...$scopes, self::LFT]); + $table->index([...$scopes, self::PARENT_ID, self::LFT]); + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + static::$nodeClasses = []; } } diff --git a/src/nested-set/src/NestedSetServiceProvider.php b/src/nested-set/src/NestedSetServiceProvider.php new file mode 100644 index 000000000..49d57674f --- /dev/null +++ b/src/nested-set/src/NestedSetServiceProvider.php @@ -0,0 +1,37 @@ +callIfExists(\Hypervel\NestedSet\Eloquent\BaseRelation::class, 'flushState'); + $this->callIfExists(\Hypervel\NestedSet\NestedSet::class, 'flushState'); } /** From 14042fec9b537aec1906a4edbd1fadcdfc159379 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:15 +0000 Subject: [PATCH 03/11] test(nested-set): verify schema helpers and cleanup Exercise every Blueprint helper against the real schema builder, including parent column compatibility, mandatory depth, exact scoped index order, symmetric drops, and macro re-registration after framework state is flushed. Move the default fixtures to the canonical bigint schema and include depth in scoped fixtures so subsequent tree tests exercise the final storage contract. Prove the global test-state subscriber clears Nested Set's bounded class metadata alongside the framework's other static state. --- tests/NestedSet/NestedSetSchemaTest.php | 122 ++++++++++++++++++ ...5_07_02_000000_create_categories_table.php | 2 +- ...5_07_03_000000_create_menu_items_table.php | 4 +- .../PHPUnit/AfterEachTestSubscriberTest.php | 22 ++++ 4 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 tests/NestedSet/NestedSetSchemaTest.php diff --git a/tests/NestedSet/NestedSetSchemaTest.php b/tests/NestedSet/NestedSetSchemaTest.php new file mode 100644 index 000000000..68d1f9a4e --- /dev/null +++ b/tests/NestedSet/NestedSetSchemaTest.php @@ -0,0 +1,122 @@ +id(); + $table->unsignedBigInteger('tenant_id'); + $table->nestedSet(['tenant_id']); + }); + + $this->assertNestedSetColumns(['tenant_id', NestedSet::LFT, NestedSet::RGT, NestedSet::PARENT_ID, NestedSet::DEPTH]); + $this->assertNestedSetIndexes(['tenant_id']); + } + + public function testIntegerNestedSetMacroCreatesExpectedColumnsAndIndexes(): void + { + Schema::create('nested_set_schema', function (Blueprint $table): void { + $table->increments('id'); + $table->integerNestedSet(); + }); + + $this->assertNestedSetColumns([NestedSet::LFT, NestedSet::RGT, NestedSet::PARENT_ID, NestedSet::DEPTH]); + $this->assertNestedSetIndexes(); + } + + public function testUuidNestedSetMacroCreatesCompatibleSqliteParentColumnAndIndexes(): 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(); + } + + public function testUlidNestedSetMacroCreatesCompatibleSqliteParentColumnAndIndexes(): 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(); + } + + public function testDropNestedSetMacroDropsTheColumnsAndIndexesItCreates(): void + { + Schema::create('nested_set_schema', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->nestedSet(['tenant_id']); + }); + + Schema::table('nested_set_schema', function (Blueprint $table): void { + $table->dropNestedSet(['tenant_id']); + }); + + $this->assertSame(['id', 'tenant_id'], Schema::getColumnListing('nested_set_schema')); + $this->assertSame([], array_values(array_filter( + Schema::getIndexes('nested_set_schema'), + fn (array $index): bool => ! $index['primary'], + ))); + } + + /** + * 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. + */ + protected function assertNestedSetIndexes(array $scopes = []): void + { + $indexColumns = array_map( + fn (array $index): array => $index['columns'], + Schema::getIndexes('nested_set_schema'), + ); + + $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/migrations/2025_07_02_000000_create_categories_table.php b/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php index aa581b1e1..2416b765c 100644 --- a/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php +++ b/tests/NestedSet/migrations/2025_07_02_000000_create_categories_table.php @@ -14,7 +14,7 @@ public function up(): void { Schema::create('categories', function (Blueprint $table) { - $table->increments('id'); + $table->id(); $table->string('name'); $table->softDeletes(); NestedSet::columns($table); diff --git a/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php b/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php index add636a5b..7ee915545 100644 --- a/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php +++ b/tests/NestedSet/migrations/2025_07_03_000000_create_menu_items_table.php @@ -14,10 +14,10 @@ public function up(): void { Schema::create('menu_items', function (Blueprint $table) { - $table->increments('id'); + $table->id(); $table->unsignedInteger('menu_id'); $table->string('title')->nullable(); - NestedSet::columns($table); + NestedSet::columns($table, ['menu_id']); }); } diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index b2b05f6ee..068bff534 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -19,6 +19,7 @@ use Hypervel\Http\Resources\JsonApi\JsonApiResource; use Hypervel\Http\Response as HttpResponse; use Hypervel\Http\UploadedFile; +use Hypervel\NestedSet\NestedSet; use Hypervel\Process\InvokedProcess; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; @@ -219,6 +220,27 @@ public function flushFrameworkStateForTest(): void } } + public function testFrameworkCleanupFlushesNestedSetMetadata(): void + { + $classes = new ReflectionProperty(NestedSet::class, 'nodeClasses'); + $classes->setValue(null, [self::class => false]); + + $subscriber = new class extends AfterEachTestSubscriber { + public function flushFrameworkStateForTest(): void + { + $this->flushFrameworkState(); + } + }; + + try { + $subscriber->flushFrameworkStateForTest(); + + $this->assertSame([], $classes->getValue()); + } finally { + NestedSet::flushState(); + } + } + public function testFlushStateAfterTestRunsCustomCallbacksBeforeFrameworkCleanup(): void { $subscriber = new class extends AfterEachTestSubscriber { From 2493236443dde1ed8d5daad32b47a76f654a1547 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:27 +0000 Subject: [PATCH 04/11] fix(nested-set): preserve scoped tree invariants Make connection-and-table structural freshness coroutine-local, normalize ordered scope identities, and separate structural queries from ordinary Eloquent visibility scopes. UUID, ULID, zero, empty-string, enum, date, and stringable keys now retain their exact tree identity. Maintain bounds, parentage, and stored depth together across insertion, movement, deletion, restoration, repair, and rebuild. Reject incomplete subtree repair sets, preserve post-gap snapshots, honor model vetoes, and expose truthful repair and diagnostic results. Add a real scope-aware siblings relation and correct nested existence aliases, joined column qualification, custom builders, collection linking, and recursive parent clones. Ancestor and descendant eager matching now uses bounded operation-local scope buckets and binary search where ordering permits, preserving caller order without retaining worker indexes. Keep set-based descendant deletion as the scalable default while providing protected evented deletion hooks with bounded keyset chunks. Remove stale soft-delete state, duplicate predicates, dead overrides, and obsolete structural assumptions. --- .../src/Eloquent/AncestorsRelation.php | 88 +- src/nested-set/src/Eloquent/BaseRelation.php | 200 ++- src/nested-set/src/Eloquent/Collection.php | 140 +- .../src/Eloquent/DescendantsRelation.php | 166 ++- src/nested-set/src/Eloquent/QueryBuilder.php | 1223 +++++++++++++---- .../src/Eloquent/SiblingsRelation.php | 232 ++++ src/nested-set/src/HasNode.php | 684 +++++++-- src/nested-set/src/NodeContext.php | 51 +- 8 files changed, 2260 insertions(+), 524 deletions(-) create mode 100644 src/nested-set/src/Eloquent/SiblingsRelation.php diff --git a/src/nested-set/src/Eloquent/AncestorsRelation.php b/src/nested-set/src/Eloquent/AncestorsRelation.php index f2b95508f..9442c4b50 100644 --- a/src/nested-set/src/Eloquent/AncestorsRelation.php +++ b/src/nested-set/src/Eloquent/AncestorsRelation.php @@ -17,22 +17,104 @@ public function addConstraints(): void return; } - /* @phpstan-ignore-next-line */ $this->query->whereAncestorOf($this->parent) - ->applyNestedSetScope(); + ->defaultOrder(); } + /** + * Set the constraints for an eager load of the relation. + */ + public function addEagerConstraints(array $models): void + { + parent::addEagerConstraints($models); + + $this->query->defaultOrder(); + } + + /** + * Determine whether a node is an ancestor of the parent. + */ protected function matches(Model $model, Model $related): bool { - /* @phpstan-ignore-next-line */ + /* @phpstan-ignore method.notFound */ return $related->isAncestorOf($model); } + /** + * Add an eager ancestor constraint. + */ protected function addEagerConstraint(QueryBuilder $query, Model $model): void { $query->orWhereAncestorOf($model); } + /** + * Remove parent intervals whose ancestor queries are already covered. + */ + protected function prepareEagerModels(array $models): array + { + $groups = []; + + foreach (parent::prepareEagerModels($models) as $model) { + $groups[$this->scopeKey($model)][] = $model; + } + + $result = []; + + foreach ($groups as $group) { + usort($group, function (Model $left, Model $right): int { + $comparison = ($right->getLft() ?? PHP_INT_MIN) /* @phpstan-ignore method.notFound */ + <=> ($left->getLft() ?? PHP_INT_MIN); /* @phpstan-ignore method.notFound */ + + return $comparison !== 0 + ? $comparison + : ($left->getRgt() ?? PHP_INT_MAX) /* @phpstan-ignore method.notFound */ + <=> ($right->getRgt() ?? PHP_INT_MAX); /* @phpstan-ignore method.notFound */ + }); + + $minimumRgt = null; + + foreach ($group as $model) { + $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + + if ($rgt !== null && $minimumRgt !== null && $rgt >= $minimumRgt) { + continue; + } + + $result[] = $model; + + if ($rgt !== null) { + $minimumRgt = $minimumRgt === null ? $rgt : min($minimumRgt, $rgt); + } + } + } + + return $result; + } + + /** + * Index only when multiple concrete scopes would otherwise be scanned. + */ + protected function shouldIndexResults(array $models): bool + { + if (count($models) < 2) { + return false; + } + + $scope = $this->scopeKey($models[0]); + + foreach (array_slice($models, 1) as $model) { + if ($this->scopeKey($model) !== $scope) { + return true; + } + } + + return false; + } + + /** + * Get the ancestor relation existence condition. + */ protected function relationExistenceCondition(string $hash, string $table, string $lft, string $rgt): string { $key = $this->getBaseQuery()->getGrammar()->wrap($this->parent->getKeyName()); diff --git a/src/nested-set/src/Eloquent/BaseRelation.php b/src/nested-set/src/Eloquent/BaseRelation.php index 3d6cb9aaa..0dd53b1f1 100644 --- a/src/nested-set/src/Eloquent/BaseRelation.php +++ b/src/nested-set/src/Eloquent/BaseRelation.php @@ -15,12 +15,14 @@ abstract class BaseRelation extends Relation { /** - * The count of self joins. + * The nested-set query builder instance. + * + * @var QueryBuilder */ - protected static int $selfJoinCount = 0; + protected EloquentBuilder $query; /** - * AncestorsRelation constructor. + * Create a new nested set relation. */ public function __construct(QueryBuilder $builder, Model $model) { @@ -31,16 +33,30 @@ public function __construct(QueryBuilder $builder, Model $model) parent::__construct($builder, $model); } + /** + * Determine whether a related node matches a parent. + */ abstract protected function matches(Model $model, Model $related): bool; + /** + * Add an eager constraint for a parent. + */ abstract protected function addEagerConstraint(QueryBuilder $query, Model $model): void; + /** + * Get the relation existence condition. + */ abstract protected function relationExistenceCondition(string $hash, string $table, string $lft, string $rgt): string; + /** + * Get the relation existence query. + */ public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parentQuery, mixed $columns = ['*']): EloquentBuilder { - /* @phpstan-ignore-next-line */ - $query = $this->getParent()->replicate()->newScopedQuery()->select($columns); + // The relation owns an isolated aliased model; Eloquent applies the + // caller's constraints to the returned builder afterward. + $query = $this->getParent()->replicate()->newQuery(); + $query->select($columns); $table = $query->getModel()->getTable(); @@ -52,20 +68,33 @@ public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilde $condition = $this->relationExistenceCondition( $grammar->wrapTable($hash), - $grammar->wrapTable($table), - $grammar->wrap($this->parent->getLftName()), /* @phpstan-ignore-line */ - $grammar->wrap($this->parent->getRgtName()) /* @phpstan-ignore-line */ + $grammar->wrapTable($parentQuery->getModel()->getTable()), + $grammar->wrap($this->parent->getLftName()), /* @phpstan-ignore method.notFound */ + $grammar->wrap($this->parent->getRgtName()) /* @phpstan-ignore method.notFound */ ); - return $query->whereRaw($condition); + $query->whereRaw($condition); + + foreach (array_keys($this->scopeValues($this->parent)) as $attribute) { + $relatedColumn = $hash . '.' . $attribute; + $parentColumn = $parentQuery->getModel()->getTable() . '.' . $attribute; + + $query->where(function (EloquentBuilder $query) use ($relatedColumn, $parentColumn) { + $query->whereColumn($relatedColumn, '=', $parentColumn) + ->orWhere(function (EloquentBuilder $query) use ($relatedColumn, $parentColumn) { + $query->whereNull($relatedColumn) + ->whereNull($parentColumn); + }); + }); + } + + return $query; } /** * Initialize the relation on a set of models. - * - * @param string $relation */ - public function initRelation(array $models, $relation): array + public function initRelation(array $models, string $relation): array { return $models; } @@ -81,7 +110,7 @@ public function getRelationCountHash(bool $incrementJoinCount = true): string /** * Get the results of the relationship. */ - public function getResults(): mixed + public function getResults(): Collection { return $this->query->get(); } @@ -91,27 +120,37 @@ public function getResults(): mixed */ public function addEagerConstraints(array $models): void { + $models = $this->prepareEagerModels($models); + + if ($models === []) { + $this->query->whereRaw('0 = 1'); + + 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); - foreach ($models as $model) { - $this->addEagerConstraint($outer, $model); - } + $this->constrainEagerModels($outer, $models); }); } /** * Match the eagerly loaded results to their parents. - * - * @param string $relation */ - public function match(array $models, Collection $results, $relation): array + public function match(array $models, Collection $results, string $relation): array { + $indexed = $this->shouldIndexResults($models) + ? $this->indexResults($results) + : null; + foreach ($models as $model) { - $related = $this->matchForModel($model, $results); + $related = $indexed === null + ? $this->matchForModel($model, $results) + : $this->matchFromIndex($model, $indexed); $model->setRelation($relation, $related); } @@ -119,6 +158,9 @@ public function match(array $models, Collection $results, $relation): array return $models; } + /** + * Match query results for one parent. + */ protected function matchForModel(Model $model, Collection $results): Collection { $result = $this->related->newCollection(); @@ -132,23 +174,123 @@ protected function matchForModel(Model $model, Collection $results): Collection return $result; } + /** + * Apply eager constraints for the prepared parent models. + */ + protected function constrainEagerModels(QueryBuilder $query, array $models): void + { + foreach ($models as $model) { + $this->addEagerConstraint($query, $model); + } + } + + /** + * Deduplicate the parent models used to constrain an eager query. + */ + protected function prepareEagerModels(array $models): array + { + $result = []; + $seenKeys = []; + $seenObjects = []; + + foreach ($models as $model) { + $scope = $this->scopeKey($model); + $key = $model->getKey(); + + if ($key === null) { + $objectId = spl_object_id($model); + + if (isset($seenObjects[$scope][$objectId])) { + continue; + } + + $seenObjects[$scope][$objectId] = true; + } else { + if (isset($seenKeys[$scope][$key])) { + continue; + } + + $seenKeys[$scope][$key] = true; + } + + $result[] = $model; + } + + return $result; + } + + /** + * 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. + */ + protected function indexResults(Collection $results): array + { + /** @var array}> $indexed */ + $indexed = []; + + foreach ($results as $related) { + $scope = $this->scopeKey($related); + + if (! isset($indexed[$scope])) { + $indexed[$scope] = [ + 'models' => [], + ]; + } + + $indexed[$scope]['models'][] = $related; + } + + return $indexed; + } + + /** + * Match a parent from its exact scope bucket. + */ + protected function matchFromIndex(Model $model, array $indexed): Collection + { + $bucket = $indexed[$this->scopeKey($model)]['models'] ?? []; + + return $this->matchForModel($model, $this->related->newCollection($bucket)); + } + + /** + * Get the normalized nested-set scope values for a node. + * + * @return array + */ + protected function scopeValues(Model $model): array + { + return $model->getNestedSetScope(); /* @phpstan-ignore method.notFound */ + } + + /** + * Get the stable identity key for a node's nested-set scope. + */ + protected function scopeKey(Model $model): string + { + return $model->getNestedSetScopeKey(); /* @phpstan-ignore method.notFound */ + } + /** * Get the plain foreign key. */ - public function getForeignKeyName(): mixed + public function getForeignKeyName(): string { - // Return a stub value for relation - // resolvers which need this function. - return NestedSet::PARENT_ID; + return $this->parent->getParentIdName(); /* @phpstan-ignore method.notFound */ } /** - * Flush all static state. + * Get the qualified foreign key name. */ - public static function flushState(): void + public function getQualifiedForeignKeyName(): string { - // Relation::flushState() uses late static binding, so this resets the - // nested-set alias counter that shadows the parent relation counter. - parent::flushState(); + return $this->related->qualifyColumn($this->getForeignKeyName()); } } diff --git a/src/nested-set/src/Eloquent/Collection.php b/src/nested-set/src/Eloquent/Collection.php index 1b49291a0..36cb555ae 100644 --- a/src/nested-set/src/Eloquent/Collection.php +++ b/src/nested-set/src/Eloquent/Collection.php @@ -5,6 +5,7 @@ namespace Hypervel\NestedSet\Eloquent; use Hypervel\Database\Eloquent\Collection as BaseCollection; +use Hypervel\Database\Eloquent\Model; use Hypervel\NestedSet\NestedSet; class Collection extends BaseCollection @@ -19,21 +20,40 @@ public function linkNodes(): static return $this; } - /* @phpstan-ignore-next-line */ - $groupedNodes = $this->groupBy($this->first()->getParentIdName()); + [$groupedNodes, $roots] = $this->groupNodesByParent(); + return $this->linkNodesFromGroups($groupedNodes, $roots); + } + + /** + * Fill relations from nodes grouped by their parent IDs. + */ + protected function linkNodesFromGroups(array $groupedNodes, array $roots): static + { foreach ($this->items as $node) { - /* @phpstan-ignore-next-line */ - if (! $node->getParentId()) { + $node->unsetRelation('parent'); + $node->unsetRelation('children'); + } + + foreach ($this->items as $node) { + $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound */ + + if ($parentId === null) { $node->setRelation('parent', null); } - $children = $groupedNodes->get($node->getKey(), []); - foreach ($children as $child) { // @phpstan-ignore foreach.emptyArray - $child->setRelation('parent', $node); + $children = $this->nodesForParent($groupedNodes, $roots, $node->getKey()); + + if ($children !== []) { + $parent = clone $node; + $parent->setRelations([]); + + foreach ($children as $child) { + $child->setRelation('parent', $parent); + } } - $node->setRelation('children', BaseCollection::make($children)); + $node->setRelation('children', $node->newCollection($children)); } return $this; @@ -44,28 +64,27 @@ public function linkNodes(): static * 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. */ - public function toTree(mixed $root = false): static + public function toTree(Model|int|string|false|null $root = false): static { if ($this->isEmpty()) { return new static; } - $this->linkNodes(); - - $items = []; - $root = $this->getRootNodeId($root); + [$groupedNodes, $roots] = $this->groupNodesByParent(); - foreach ($this->items as $node) { - /* @phpstan-ignore-next-line */ - if ($node->getParentId() == $root) { - $items[] = $node; - } - } + $this->linkNodesFromGroups($groupedNodes, $roots); - return new static($items); + return new static($this->nodesForParent( + $groupedNodes, + $roots, + $this->getRootNodeId($root), + )); } - protected function getRootNodeId(mixed $root = false): int + /** + * Resolve the parent key used as the collection root. + */ + protected function getRootNodeId(Model|int|string|false|null $root = false): int|string|null { if (NestedSet::isNode($root)) { return $root->getKey(); @@ -77,24 +96,26 @@ protected function getRootNodeId(mixed $root = false): int // If root node is not specified we take parent id of node with // least lft value as root node id. - $leastValue = null; + $leastValue = PHP_INT_MAX; + $rootNodeId = null; foreach ($this->items as $node) { - /* @phpstan-ignore-next-line */ - if ($leastValue === null || $node->getLft() < $leastValue) { - $leastValue = $node->getLft(); /* @phpstan-ignore-line */ - $root = $node->getParentId(); /* @phpstan-ignore-line */ + $lft = $node->getLft(); /* @phpstan-ignore method.notFound */ + + if ($lft !== null && $lft < $leastValue) { + $leastValue = $lft; + $rootNodeId = $node->getParentId(); /* @phpstan-ignore method.notFound */ } } - return $root; + return $rootNodeId; } /** * Build a list of nodes that retain the order that they were pulled from * the database. */ - public function toFlatTree(bool $root = false): static + public function toFlatTree(Model|int|string|false|null $root = false): static { $result = new static; @@ -102,23 +123,72 @@ public function toFlatTree(bool $root = false): static return $result; } - /* @phpstan-ignore-next-line */ - $groupedNodes = $this->groupBy($this->first()->getParentIdName()); + [$groupedNodes, $roots] = $this->groupNodesByParent(); + + return $result->flattenTree( + $groupedNodes, + $roots, + $this->getRootNodeId($root), + ); + } + + /** + * Group nodes by parent while keeping roots separate from scalar keys. + */ + protected function groupNodesByParent(): array + { + $groupedNodes = []; + $roots = []; + + foreach ($this->items as $node) { + $parentId = $node->getParentId(); /* @phpstan-ignore method.notFound */ + + if ($parentId === null) { + $roots[] = $node; + } else { + $groupedNodes[$parentId][] = $node; + } + } - return $result->flattenTree($groupedNodes, $this->getRootNodeId($root)); + return [$groupedNodes, $roots]; } /** - * Flatten a tree into a non recursive array. + * Flatten a tree without recursive stack growth. */ - protected function flattenTree(Collection $groupedNodes, mixed $parentId): static + protected function flattenTree(array $groupedNodes, array $roots, int|string|null $parentId): static { - foreach ($groupedNodes->get($parentId, []) as $node) { // @phpstan-ignore foreach.emptyArray + $stack = []; + $children = $this->nodesForParent($groupedNodes, $roots, $parentId); + + for ($index = count($children) - 1; $index >= 0; --$index) { + $stack[] = $children[$index]; + } + + while ($stack !== []) { + $node = array_pop($stack); $this->push($node); - $this->flattenTree($groupedNodes, $node->getKey()); + $children = $this->nodesForParent($groupedNodes, $roots, $node->getKey()); + + for ($index = count($children) - 1; $index >= 0; --$index) { + $stack[] = $children[$index]; + } } return $this; } + + /** + * Get nodes for a parent ID. + */ + protected function nodesForParent( + array $groupedNodes, + array $roots, + int|string|null $parentId, + ): array { + return $parentId === null + ? $roots + : ($groupedNodes[$parentId] ?? []); + } } diff --git a/src/nested-set/src/Eloquent/DescendantsRelation.php b/src/nested-set/src/Eloquent/DescendantsRelation.php index 8780e225f..2f038b826 100644 --- a/src/nested-set/src/Eloquent/DescendantsRelation.php +++ b/src/nested-set/src/Eloquent/DescendantsRelation.php @@ -4,6 +4,7 @@ namespace Hypervel\NestedSet\Eloquent; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; class DescendantsRelation extends BaseRelation @@ -17,22 +18,179 @@ public function addConstraints(): void return; } - /* @phpstan-ignore-next-line */ - $this->query->whereDescendantOf($this->parent) - ->applyNestedSetScope(); + $this->query->whereDescendantOf($this->parent); } + /** + * Add an eager descendant constraint. + */ protected function addEagerConstraint(QueryBuilder $query, Model $model): void { $query->orWhereDescendantOf($model); } + /** + * Remove parent intervals whose descendant queries are already covered. + */ + protected function prepareEagerModels(array $models): array + { + $groups = []; + + foreach (parent::prepareEagerModels($models) as $model) { + $groups[$this->scopeKey($model)][] = $model; + } + + $result = []; + + foreach ($groups as $group) { + usort($group, function (Model $left, Model $right): int { + $comparison = ($left->getLft() ?? PHP_INT_MAX) /* @phpstan-ignore method.notFound */ + <=> ($right->getLft() ?? PHP_INT_MAX); /* @phpstan-ignore method.notFound */ + + return $comparison !== 0 + ? $comparison + : ($right->getRgt() ?? PHP_INT_MIN) /* @phpstan-ignore method.notFound */ + <=> ($left->getRgt() ?? PHP_INT_MIN); /* @phpstan-ignore method.notFound */ + }); + + $maximumRgt = null; + + foreach ($group as $model) { + $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + + if ($rgt !== null && $maximumRgt !== null && $rgt <= $maximumRgt) { + continue; + } + + $result[] = $model; + + if ($rgt !== null) { + $maximumRgt = $maximumRgt === null ? $rgt : max($maximumRgt, $rgt); + } + } + } + + return $result; + } + + /** + * Determine whether a node is a descendant of the parent. + */ protected function matches(Model $model, Model $related): bool { - /* @phpstan-ignore-next-line */ + /* @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); + $lft = $related->getLft(); /* @phpstan-ignore method.notFound */ + + if (! isset($indexed[$scope])) { + $indexed[$scope] = [ + 'models' => [], + 'lfts' => [], + 'monotonic' => true, + 'last_lft' => null, + ]; + } + + $bucket = &$indexed[$scope]; + + if ($lft === null || ($bucket['last_lft'] !== null && $lft < $bucket['last_lft'])) { + $bucket['monotonic'] = false; + } + + $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 $indexed; + } + + /** + * Match descendants from an exact scope bucket. + */ + protected function matchFromIndex(Model $model, array $indexed): Collection + { + $bucket = $indexed[$this->scopeKey($model)] ?? null; + + if ($bucket === null) { + return $this->related->newCollection(); + } + + $lft = $model->getLft(); /* @phpstan-ignore method.notFound */ + $rgt = $model->getRgt(); /* @phpstan-ignore method.notFound */ + + if ($lft === null || $rgt === null) { + return $this->related->newCollection(); + } + + 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; + } + + $related = $bucket['models'][$index]; + + if ($this->matches($model, $related)) { + $result->push($related); + } + } + + return $result; + } + + /** + * Find the first sorted value greater than or equal to the given value. + */ + protected static function lowerBound(array $values, int $needle): int + { + $low = 0; + $high = count($values); + + while ($low < $high) { + $middle = intdiv($low + $high, 2); + + if ($values[$middle] < $needle) { + $low = $middle + 1; + } else { + $high = $middle; + } + } + + return $low; + } + + /** + * Get the descendant relation existence condition. + */ protected function relationExistenceCondition(string $hash, string $table, string $lft, string $rgt): string { return "{$hash}.{$lft} between {$table}.{$lft} + 1 and {$table}.{$rgt}"; diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index 88dd8174f..250ec5013 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -9,38 +9,49 @@ use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\Query\Builder as BaseQueryBuilder; use Hypervel\Database\Query\Expression; +use Hypervel\Database\Query\JoinClause; use Hypervel\NestedSet\NestedSet; -use Hypervel\Support\Arr; +use Hypervel\NestedSet\NodeContext; use Hypervel\Support\Collection as BaseCollection; use LogicException; class QueryBuilder extends EloquentBuilder { /** - * Get node's `lft` and `rgt` values. + * Get a node's structural values. */ - public function getNodeData(mixed $id, bool $required = false): array + public function getNodeData(int|string $id, bool $required = false): array { + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $data = $this->toBase() ->where($this->model->getKeyName(), '=', $id) ->first([ - $this->model->getLftName(), /* @phpstan-ignore-line */ - $this->model->getRgtName(), /* @phpstan-ignore-line */ + $lftName, + $rgtName, + $depthName, ]); if (! $data && $required) { - throw new ModelNotFoundException; + throw (new ModelNotFoundException)->setModel($this->model::class, [$id]); } - return (array) $data; + return $data ? (array) $data : []; } /** * Get plain node data. */ - public function getPlainNodeData(mixed $id, bool $required = false): array + public function getPlainNodeData(int|string $id, bool $required = false): array { - return array_values($this->getNodeData($id, $required)); + $data = $this->getNodeData($id, $required); + + return [ + $data[$this->model->getLftName()] ?? 0, /* @phpstan-ignore method.notFound */ + $data[$this->model->getRgtName()] ?? 0, /* @phpstan-ignore method.notFound */ + ]; } /** @@ -48,8 +59,7 @@ public function getPlainNodeData(mixed $id, bool $required = false): array */ public function whereIsRoot(): static { - /* @phpstan-ignore-next-line */ - $this->query->whereNull($this->model->getParentIdName()); + $this->query->whereNull($this->model->qualifyColumn($this->model->getParentIdName())); /* @phpstan-ignore method.notFound */ return $this; } @@ -59,42 +69,40 @@ public function whereIsRoot(): static */ public function whereAncestorOf(mixed $id, bool $andSelf = false, string $boolean = 'and'): static { - $keyName = $this->model->getTable() . '.' . $this->model->getKeyName(); + $keyName = $this->model->qualifyColumn($this->model->getKeyName()); $model = null; if (NestedSet::isNode($id)) { $model = $id; $value = '?'; - - $this->query->addBinding($id->getRgt()); + $bindings = [$id->getRgt()]; $id = $id->getKey(); } else { + $this->assertConcreteNestedSetScope('scalar lookup'); + $valueQuery = $this->model - ->newQuery() + ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound */ ->toBase() - ->select('_.' . $this->model->getRgtName()) /* @phpstan-ignore-line */ - ->from($this->model->getTable() . ' as _') - ->where($this->model->getKeyName(), '=', $id) - ->limit(1); + ->select('_n.' . $this->model->getRgtName()) /* @phpstan-ignore method.notFound */ + ->from($this->model->getTable() . ' as _n') + ->where('_n.' . $this->model->getKeyName(), '=', $id); $this->query->mergeBindings($valueQuery); $value = '(' . $valueQuery->toSql() . ')'; + $bindings = []; } - $this->query->whereNested(function ($inner) use ($model, $value, $andSelf, $id, $keyName) { + $this->query->whereNested(function ($inner) use ($model, $value, $bindings, $andSelf, $id, $keyName) { [$lft, $rgt] = $this->wrappedColumns(); - $wrappedTable = $this->query->getGrammar()->wrapTable($this->model->getTable()); - $inner->whereRaw("{$value} between {$wrappedTable}.{$lft} and {$wrappedTable}.{$rgt}"); + $inner->whereRaw("{$value} between {$lft} and {$rgt}", $bindings); if (! $andSelf) { $inner->where($keyName, '<>', $id); } if ($model !== null) { - // we apply scope only when Node was passed as $id. - // In other cases, according to docs, query should be scoped() before calling this method $model->applyNestedSetScope($inner); } }, $boolean); @@ -102,11 +110,17 @@ public function whereAncestorOf(mixed $id, bool $andSelf = false, string $boolea return $this; } + /** + * Add an `or` constraint for ancestors of a node. + */ public function orWhereAncestorOf(mixed $id, bool $andSelf = false): static { return $this->whereAncestorOf($id, $andSelf, 'or'); } + /** + * Limit results to ancestors and the node itself. + */ public function whereAncestorOrSelf(mixed $id): static { return $this->whereAncestorOf($id, true); @@ -117,13 +131,14 @@ public function whereAncestorOrSelf(mixed $id): static */ public function ancestorsOf(mixed $id, array $columns = ['*']): BaseCollection { - /* @phpstan-ignore-next-line */ return $this->whereAncestorOf($id)->get($columns); } + /** + * Get ancestors and the node itself. + */ public function ancestorsAndSelf(mixed $id, array $columns = ['*']): BaseCollection { - /* @phpstan-ignore-next-line */ return $this->whereAncestorOf($id, true)->get($columns); } @@ -132,8 +147,12 @@ public function ancestorsAndSelf(mixed $id, array $columns = ['*']): BaseCollect */ public function whereNodeBetween(array $values, string $boolean = 'and', bool $not = false, ?BaseQueryBuilder $query = null): static { - /* @phpstan-ignore-next-line */ - ($query ?? $this->query)->whereBetween($this->model->getTable() . '.' . $this->model->getLftName(), $values, $boolean, $not); + ($query ?? $this->query)->whereBetween( + $this->model->qualifyColumn($this->model->getLftName()), /* @phpstan-ignore method.notFound */ + $values, + $boolean, + $not, + ); return $this; } @@ -156,9 +175,9 @@ public function whereDescendantOf(mixed $id, string $boolean = 'and', bool $not $id->applyNestedSetScope($inner); $data = $id->getBounds(); } else { - // we apply scope only when Node was passed as $id. - // In other cases, according to docs, query should be scoped() before calling this method - /* @phpstan-ignore-next-line */ + $this->assertConcreteNestedSetScope('scalar lookup'); + + /* @phpstan-ignore method.notFound */ $data = $this->model->newNestedSetQuery() ->getPlainNodeData($id, true); } @@ -174,21 +193,33 @@ public function whereDescendantOf(mixed $id, string $boolean = 'and', bool $not return $this; } - public function whereNotDescendantOf(mixed $id): QueryBuilder + /** + * Exclude descendants of a node. + */ + public function whereNotDescendantOf(mixed $id): static { return $this->whereDescendantOf($id, 'and', true); } - public function orWhereDescendantOf(mixed $id): QueryBuilder + /** + * Add an `or` constraint for descendants of a node. + */ + public function orWhereDescendantOf(mixed $id): static { return $this->whereDescendantOf($id, 'or'); } - public function orWhereNotDescendantOf(mixed $id): QueryBuilder + /** + * Add an `or` exclusion for descendants of a node. + */ + public function orWhereNotDescendantOf(mixed $id): static { return $this->whereDescendantOf($id, 'or', true); } + /** + * Limit results to descendants and the node itself. + */ public function whereDescendantOrSelf(mixed $id, string $boolean = 'and', bool $not = false): static { return $this->whereDescendantOf($id, $boolean, $not, true); @@ -206,33 +237,56 @@ public function descendantsOf(mixed $id, array $columns = ['*'], bool $andSelf = } } + /** + * Get descendants and the node itself. + */ public function descendantsAndSelf(mixed $id, array $columns = ['*']): BaseCollection { return $this->descendantsOf($id, $columns, true); } + /** + * Add a positional constraint relative to a node. + */ protected function whereIsBeforeOrAfter(mixed $id, string $operator, string $boolean): static { + $model = null; + if (NestedSet::isNode($id)) { + $model = $id; $value = '?'; - - $this->query->addBinding($id->getLft()); + $bindings = [$id->getLft()]; } else { + $this->assertConcreteNestedSetScope('scalar lookup'); + $valueQuery = $this->model - ->newQuery() + ->newNestedSetQuery('_n') /* @phpstan-ignore method.notFound */ ->toBase() - ->select('_n.' . $this->model->getLftName()) /* @phpstan-ignore-line */ + ->select('_n.' . $this->model->getLftName()) /* @phpstan-ignore method.notFound */ ->from($this->model->getTable() . ' as _n') ->where('_n.' . $this->model->getKeyName(), '=', $id); $this->query->mergeBindings($valueQuery); $value = '(' . $valueQuery->toSql() . ')'; + $bindings = []; } [$lft] = $this->wrappedColumns(); - $this->query->whereRaw("{$lft} {$operator} {$value}", [], $boolean); + $this->query->whereNested(function (BaseQueryBuilder $inner) use ( + $model, + $lft, + $operator, + $value, + $bindings, + ) { + if ($model !== null) { + $model->applyNestedSetScope($inner); + } + + $inner->whereRaw("{$lft} {$operator} {$value}", $bindings); + }, $boolean); return $this; } @@ -253,13 +307,21 @@ public function whereIsBefore(mixed $id, string $boolean = 'and'): static return $this->whereIsBeforeOrAfter($id, '<', $boolean); } - public function whereIsLeaf(): BaseQueryBuilder|QueryBuilder + /** + * Limit results to leaf nodes. + */ + public function whereIsLeaf(): static { [$lft, $rgt] = $this->wrappedColumns(); - return $this->whereRaw("{$lft} = {$rgt} - 1"); + $this->query->whereRaw("{$lft} = {$rgt} - 1"); + + return $this; } + /** + * Get the leaf nodes. + */ public function leaves(array $columns = ['*']): BaseCollection { return $this->whereIsLeaf()->get($columns); @@ -274,21 +336,11 @@ public function withDepth(string $as = 'depth'): static $this->query->columns = ['*']; } - $table = $this->wrappedTable(); - - [$lft, $rgt] = $this->wrappedColumns(); - - $alias = '_d'; - $wrappedAlias = $this->query->getGrammar()->wrapTable($alias); - - $query = $this->model - ->newScopedQuery('_d') - ->toBase() - ->selectRaw('count(1) - 1') - ->from($this->model->getTable() . ' as ' . $alias) - ->whereRaw("{$table}.{$lft} between {$wrappedAlias}.{$lft} and {$wrappedAlias}.{$rgt}"); + $grammar = $this->query->getGrammar(); + $column = $grammar->wrap($this->model->qualifyColumn($this->model->getDepthName())); /* @phpstan-ignore method.notFound */ + $alias = $grammar->wrap($as); - $this->query->selectSub($query, $as); + $this->query->selectRaw("{$column} as {$alias}"); return $this; } @@ -301,34 +353,19 @@ protected function wrappedColumns(): array $grammar = $this->query->getGrammar(); return [ - $grammar->wrap($this->model->getLftName()), /* @phpstan-ignore-line */ - $grammar->wrap($this->model->getRgtName()), /* @phpstan-ignore-line */ + $grammar->wrap($this->model->qualifyColumn($this->model->getLftName())), /* @phpstan-ignore method.notFound */ + $grammar->wrap($this->model->qualifyColumn($this->model->getRgtName())), /* @phpstan-ignore method.notFound */ ]; } - /** - * Get a wrapped table name. - */ - protected function wrappedTable(): string - { - return $this->query->getGrammar()->wrapTable($this->getQuery()->from); - } - - /** - * Wrap model's key name. - */ - protected function wrappedKey(): string - { - return $this->query->getGrammar()->wrap($this->model->getKeyName()); - } - /** * Exclude root node from the result. */ public function withoutRoot(): static { - /* @phpstan-ignore-next-line */ - $this->query->whereNotNull($this->model->getParentIdName()); + $this->query->whereNotNull( + $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound */ + ); return $this; } @@ -338,8 +375,9 @@ public function withoutRoot(): static */ public function hasParent(): static { - /* @phpstan-ignore-next-line */ - $this->query->whereNotNull($this->model->getParentIdName()); + $this->query->whereNotNull( + $this->model->qualifyColumn($this->model->getParentIdName()), /* @phpstan-ignore method.notFound */ + ); return $this; } @@ -361,10 +399,19 @@ public function hasChildren(): static */ public function defaultOrder(string $dir = 'asc'): static { - /* @phpstan-ignore-next-line */ - $this->query->orders = null; + $this->query->reorder(); - $this->query->orderBy($this->model->getLftName(), $dir); /* @phpstan-ignore-line */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ + + // A compound query can order only by columns projected in its result. + $column = $this->query->unions + ? $lftName + : $this->model->qualifyColumn($lftName); + + $this->query->orderBy( + $column, + $dir, + ); return $this; } @@ -379,12 +426,35 @@ public function reversed(): static /** * Move a node to the new position. + * + * @param array $nodeData complete values keyed by the model's left, right, and depth column names */ - public function moveNode(mixed $key, int $position): int - { - /* @phpstan-ignore-next-line */ - [$lft, $rgt] = $this->model->newNestedSetQuery() - ->getPlainNodeData($key, true); + public function moveNode( + int|string $key, + int $position, + ?int $targetDepth = null, + array $nodeData = [], + ): int { + $data = $nodeData ?: $this->model->newNestedSetQuery()->getNodeData($key, true); /* @phpstan-ignore method.notFound */ + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + + foreach ([$lftName, $rgtName, $depthName] as $column) { + if (! array_key_exists($column, $data)) { + throw new LogicException(sprintf( + 'Node data for [%s] must contain [%s], [%s], and [%s].', + $this->model::class, + $lftName, + $rgtName, + $depthName, + )); + } + } + + $lft = (int) $data[$lftName]; + $rgt = (int) $data[$rgtName]; + $currentDepth = (int) $data[$depthName]; if ($lft < $position && $position <= $rgt) { throw new LogicException('Cannot move node into itself.'); @@ -411,29 +481,46 @@ public function moveNode(mixed $key, int $position): int $distance *= -1; } - $params = compact('lft', 'rgt', 'from', 'to', 'height', 'distance'); + $depth = ($targetDepth ?? $this->depthForPosition($position)) - $currentDepth; + $params = compact('lft', 'rgt', 'from', 'to', 'height', 'distance', 'depth'); $boundary = [$from, $to]; - $query = $this->toBase()->where(function (BaseQueryBuilder $inner) use ($boundary) { - $inner->whereBetween($this->model->getLftName(), $boundary); /* @phpstan-ignore-line */ - $inner->orWhereBetween($this->model->getRgtName(), $boundary); /* @phpstan-ignore-line */ - }); + $query = $this->toBase() + ->where($this->model->getRgtName(), '>=', $boundary[0]) /* @phpstan-ignore method.notFound */ + ->where($this->model->getLftName(), '<=', $boundary[1]); /* @phpstan-ignore method.notFound */ return $query->update($this->patch($params)); } + /** + * Get the depth of a node inserted at the given position. + */ + public function depthForPosition(int $position): int + { + $depth = $this->model + ->newNestedSetQuery() + ->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 */ + ->value($this->model->getDepthName()); /* @phpstan-ignore method.notFound */ + + return $depth === null ? 0 : ((int) $depth + 1); + } + /** * Make or remove gap in the tree. Negative height will remove gap. */ public function makeGap(int $cut, int $height): int { + if ($height === 0) { + return 0; + } + $params = compact('cut', 'height'); - $query = $this->toBase()->whereNested(function (BaseQueryBuilder $inner) use ($cut) { - $inner->where($this->model->getLftName(), '>=', $cut); /* @phpstan-ignore-line */ - $inner->orWhere($this->model->getRgtName(), '>=', $cut); /* @phpstan-ignore-line */ - }); + $query = $this->toBase() + ->where($this->model->getRgtName(), '>=', $cut); /* @phpstan-ignore method.notFound */ return $query->update($this->patch($params)); } @@ -447,35 +534,61 @@ protected function patch(array $params): array $columns = []; - /* @phpstan-ignore-next-line */ - foreach ([$this->model->getLftName(), $this->model->getRgtName()] as $col) { + if (($params['depth'] ?? 0) !== 0) { + $column = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $columns[$column] = $this->depthPatch($grammar->wrap($column), $params); + } + + foreach ([ + $this->model->getLftName(), /* @phpstan-ignore method.notFound */ + $this->model->getRgtName(), /* @phpstan-ignore method.notFound */ + ] as $col) { $columns[$col] = $this->columnPatch($grammar->wrap($col), $params); } return $columns; } + /** + * Get the depth-column patch for a moved subtree. + */ + protected function depthPatch(string $column, array $params): Expression + { + $depth = (int) $params['depth']; + $lft = (int) $params['lft']; + $rgt = (int) $params['rgt']; + $operator = $depth > 0 ? '+' : '-'; + $distance = abs($depth); + + return new Expression( + "case when {$this->query->getGrammar()->wrap($this->model->getLftName())} " /* @phpstan-ignore method.notFound */ + . "between {$lft} and {$rgt} then {$column} {$operator} {$distance} else {$column} end" + ); + } + /** * Get patch for single column. */ protected function columnPatch(string $col, array $params): Expression { - extract($params); + $height = (int) $params['height']; - /** @var int $height */ if ($height > 0) { $height = " + {$height}"; } - if (isset($cut)) { + if (isset($params['cut'])) { + $cut = (int) $params['cut']; + return new Expression("case when {$col} >= {$cut} then {$col}{$height} else {$col} end"); } - /** @var int $distance */ - /** @var int $lft */ - /** @var int $rgt */ - /** @var int $from */ - /** @var int $to */ + $distance = (int) $params['distance']; + $lft = (int) $params['lft']; + $rgt = (int) $params['rgt']; + $from = (int) $params['from']; + $to = (int) $params['to']; + if ($distance > 0) { $distance = " + {$distance}"; } @@ -493,143 +606,380 @@ protected function columnPatch(string $col, array $params): Expression */ public function countErrors(): array { - $checks = []; - - // Check if lft and rgt values are ok - $checks['oddness'] = $this->getOdnessQuery(); - - // Check if lft and rgt values are unique - $checks['duplicates'] = $this->getDuplicatesQuery(); - - // Check if parent_id is set correctly - $checks['wrong_parent'] = $this->getWrongParentQuery(); - - // Check for nodes that have missing parent - $checks['missing_parent'] = $this->getMissingParentQuery(); + $this->assertConcreteNestedSetScope(); + + $checks = [ + 'invalid_intervals' => $this->getInvalidIntervalsQuery(), + 'duplicate_endpoints' => $this->getDuplicateEndpointsQuery(), + 'missing_endpoints' => $this->getMissingEndpointsQuery(), + 'crossing_intervals' => $this->getCrossingIntervalsQuery(), + 'missing_parent' => $this->getMissingParentQuery(), + 'wrong_parent' => $this->getWrongParentQuery(), + 'wrong_depth' => $this->getWrongDepthQuery(), + ]; $query = $this->query->newQuery(); foreach ($checks as $key => $inner) { - $inner->selectRaw('count(1)'); - $query->selectSub($inner, $key); } - return (array) $query->first(); + return array_map( + static fn (mixed $value): int => (int) $value, + (array) $query->first(), + ); } - protected function getOdnessQuery(): BaseQueryBuilder + /** + * Get the invalid interval query. + */ + protected function getInvalidIntervalsQuery(bool $count = true): BaseQueryBuilder { - return $this->model + $query = $this->model ->newNestedSetQuery() ->toBase() ->whereNested(function (BaseQueryBuilder $inner) { [$lft, $rgt] = $this->wrappedColumns(); - $inner->whereRaw("{$lft} >= {$rgt}") + $inner->whereRaw("{$lft} <= 0") + ->orWhereRaw("{$rgt} <= 0") + ->orWhereRaw("{$lft} >= {$rgt}") ->orWhereRaw("({$rgt} - {$lft}) % 2 = 0"); }); + + return $count ? $query->selectRaw('count(*)') : $query; } - protected function getDuplicatesQuery(): BaseQueryBuilder + /** + * Get the ordered endpoint events query. + */ + protected function getEndpointEventsQuery(): BaseQueryBuilder { - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); - - $firstAlias = 'c1'; - $secondAlias = 'c2'; + [$lft, $rgt] = $this->wrappedColumns(); + $depth = $this->query->getGrammar()->wrap($this->model->getDepthName()); /* @phpstan-ignore method.notFound */ - $waFirst = $this->query->getGrammar()->wrapTable($firstAlias); - $waSecond = $this->query->getGrammar()->wrapTable($secondAlias); + // 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() + ->toBase() + ->selectRaw("{$lft} as endpoint, {$depth} as expected, 1 as delta"); - $query = $this->model - ->newNestedSetQuery($firstAlias) + $rgtQuery = $this->model + ->newNestedSetQuery() ->toBase() - ->from($this->query->raw("{$table} as {$waFirst}, {$table} {$waSecond}")) - ->whereRaw("{$waFirst}.{$keyName} < {$waSecond}.{$keyName}") - ->whereNested(function (BaseQueryBuilder $inner) use ($waFirst, $waSecond) { - [$lft, $rgt] = $this->wrappedColumns(); + ->selectRaw("{$rgt} as endpoint, {$depth} + 1 as expected, -1 as delta"); - $inner->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$lft}") - ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$rgt}") - ->orWhereRaw("{$waFirst}.{$lft}={$waSecond}.{$rgt}") - ->orWhereRaw("{$waFirst}.{$rgt}={$waSecond}.{$lft}"); - }); + return $lftQuery->unionAll($rgtQuery); + } - /* @phpstan-ignore-next-line */ - return $this->model->applyNestedSetScope($query, $secondAlias); + /** + * Get duplicate endpoint groups. + */ + protected function getDuplicateEndpointGroupsQuery(): BaseQueryBuilder + { + return $this->query + ->newQuery() + ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') + ->select('endpoint') + ->groupBy('endpoint') + ->havingRaw('count(*) > 1'); } - protected function getWrongParentQuery(): BaseQueryBuilder + /** + * Get the duplicate endpoint count query. + */ + protected function getDuplicateEndpointsQuery(): BaseQueryBuilder { - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); + return $this->query + ->newQuery() + ->fromSub($this->getDuplicateEndpointGroupsQuery(), 'duplicate_endpoints') + ->selectRaw('count(*)'); + } - $grammar = $this->query->getGrammar(); + /** + * Get the missing endpoint range query. + */ + protected function getMissingEndpointsQuery(): BaseQueryBuilder + { + $statistics = $this->query + ->newQuery() + ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') + ->selectRaw( + 'count(*) as endpoint_count, ' + . 'min(endpoint) as minimum_endpoint, ' + . 'max(endpoint) as maximum_endpoint' + ); - /* @phpstan-ignore-next-line */ - $parentIdName = $grammar->wrap($this->model->getParentIdName()); + return $this->query + ->newQuery() + ->fromSub($statistics, 'endpoint_statistics') + ->selectRaw( + 'case ' + . 'when endpoint_count = 0 then 0 ' + . 'when minimum_endpoint = 1 and maximum_endpoint = endpoint_count then 0 ' + . 'else 1 end as missing_endpoints' + ); + } + + /** + * Get expected and active endpoint depths. + */ + 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() + ->fromSub($this->getEndpointEventsQuery(), 'endpoint_events') + ->select(['expected']) + ->selectRaw( + 'coalesce(sum(delta) over (order by endpoint ' + . 'rows between unbounded preceding and 1 preceding), 0) as active_before' + ); + } - $parentAlias = 'p'; - $childAlias = 'c'; - $intermAlias = 'i'; + /** + * Get the crossing interval count query. + */ + protected function getCrossingIntervalsQuery(): BaseQueryBuilder + { + $duplicates = $this->getDuplicateEndpointGroupsQuery() + ->selectRaw('1') + ->limit(1); + + return $this->query + ->newQuery() + ->fromSub($this->getEndpointStateQuery(), 'endpoint_state') + ->selectRaw( + 'case when exists (' . $duplicates->toSql() + . ') then 0 else count(*) end as crossing_intervals', + $duplicates->getBindings(), + ) + ->whereColumn('active_before', '<>', 'expected'); + } - $waParent = $grammar->wrapTable($parentAlias); - $waChild = $grammar->wrapTable($childAlias); - $waInterm = $grammar->wrapTable($intermAlias); + /** + * Get the missing parent query. + */ + protected function getMissingParentQuery(bool $count = true): BaseQueryBuilder + { + $childAlias = 'nested_set_child'; + $parentAlias = 'nested_set_parent'; + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $keyName = $this->model->getKeyName(); $query = $this->model - ->newNestedSetQuery('c') + ->newNestedSetQuery($childAlias) ->toBase() - ->from($this->query->raw("{$table} as {$waChild}, {$table} as {$waParent}, {$table} as {$waInterm}")) - ->whereRaw("{$waChild}.{$parentIdName}={$waParent}.{$keyName}") - ->whereRaw("{$waInterm}.{$keyName} <> {$waParent}.{$keyName}") - ->whereRaw("{$waInterm}.{$keyName} <> {$waChild}.{$keyName}") - ->whereNested(function (BaseQueryBuilder $inner) use ($waInterm, $waChild, $waParent) { - [$lft, $rgt] = $this->wrappedColumns(); + ->from($this->model->getTable() . ' as ' . $childAlias) + ->leftJoin( + $this->model->getTable() . ' as ' . $parentAlias, + function (JoinClause $join) use ($childAlias, $parentAlias, $parentIdName, $keyName) { + $join->on( + $childAlias . '.' . $parentIdName, + '=', + $parentAlias . '.' . $keyName, + ); + + $this->addScopeColumnComparisons($join, $childAlias, $parentAlias); + } + ) + ->whereNotNull($childAlias . '.' . $parentIdName) + ->whereNull($parentAlias . '.' . $keyName); - $inner->whereRaw("{$waChild}.{$lft} not between {$waParent}.{$lft} and {$waParent}.{$rgt}") - ->orWhereRaw("{$waChild}.{$lft} between {$waInterm}.{$lft} and {$waInterm}.{$rgt}") - ->whereRaw("{$waInterm}.{$lft} between {$waParent}.{$lft} and {$waParent}.{$rgt}"); - }); + return $count ? $query->selectRaw('count(*)') : $query; + } + + /** + * Get the wrong parent query. + */ + protected function getWrongParentQuery(bool $count = true): BaseQueryBuilder + { + $childAlias = 'nested_set_child'; + $parentAlias = 'nested_set_parent'; + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $keyName = $this->model->getKeyName(); + $lftName = $this->model->getLftName(); /* @phpstan-ignore method.notFound */ + $rgtName = $this->model->getRgtName(); /* @phpstan-ignore method.notFound */ + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $grammar = $this->query->getGrammar(); - /* @phpstan-ignore-next-line */ - $this->model->applyNestedSetScope($query, $parentAlias); - /* @phpstan-ignore-next-line */ - $this->model->applyNestedSetScope($query, $intermAlias); + $query = $this->model + ->newNestedSetQuery($childAlias) + ->toBase() + ->from($this->model->getTable() . ' as ' . $childAlias) + ->join( + $this->model->getTable() . ' as ' . $parentAlias, + function (JoinClause $join) use ($childAlias, $parentAlias, $parentIdName, $keyName) { + $join->on( + $childAlias . '.' . $parentIdName, + '=', + $parentAlias . '.' . $keyName, + ); + + $this->addScopeColumnComparisons($join, $childAlias, $parentAlias); + } + ) + ->where(function (BaseQueryBuilder $query) use ( + $childAlias, + $parentAlias, + $lftName, + $rgtName, + $depthName, + $grammar, + ) { + $query->whereColumn( + $childAlias . '.' . $lftName, + '<=', + $parentAlias . '.' . $lftName, + )->orWhereColumn( + $childAlias . '.' . $rgtName, + '>=', + $parentAlias . '.' . $rgtName, + )->orWhereRaw( + $grammar->wrap($childAlias . '.' . $depthName) + . ' <> ' + . $grammar->wrap($parentAlias . '.' . $depthName) + . ' + 1' + ); + }); - return $query; + return $count ? $query->selectRaw('count(*)') : $query; } - protected function getMissingParentQuery(): BaseQueryBuilder + /** + * Get the wrong depth query. + */ + protected function getWrongDepthQuery(bool $count = true): BaseQueryBuilder { - return $this->model - ->newNestedSetQuery() + $childAlias = 'nested_set_child'; + $parentAlias = 'nested_set_parent'; + $parentIdName = $this->model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $keyName = $this->model->getKeyName(); + $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ + $grammar = $this->query->getGrammar(); + + $query = $this->model + ->newNestedSetQuery($childAlias) ->toBase() - ->whereNested(function (BaseQueryBuilder $inner) { - $grammar = $this->query->getGrammar(); - - $table = $this->wrappedTable(); - $keyName = $this->wrappedKey(); - $parentIdName = $grammar->wrap($this->model->getParentIdName()); /* @phpstan-ignore-line */ - $alias = 'p'; - $wrappedAlias = $grammar->wrapTable($alias); - - /* @phpstan-ignore-next-line */ - $existsCheck = $this->model - ->newNestedSetQuery() - ->toBase() - ->selectRaw('1') - ->from($this->query->raw("{$table} as {$wrappedAlias}")) - ->whereRaw("{$table}.{$parentIdName} = {$wrappedAlias}.{$keyName}") - ->limit(1); - - /* @phpstan-ignore-next-line */ - $this->model->applyNestedSetScope($existsCheck, $alias); - - $inner->whereRaw("{$parentIdName} is not null") - ->addWhereExistsQuery($existsCheck, 'and', true); + ->from($this->model->getTable() . ' as ' . $childAlias) + ->leftJoin( + $this->model->getTable() . ' as ' . $parentAlias, + function (JoinClause $join) use ($childAlias, $parentAlias, $parentIdName, $keyName) { + $join->on( + $childAlias . '.' . $parentIdName, + '=', + $parentAlias . '.' . $keyName, + ); + + $this->addScopeColumnComparisons($join, $childAlias, $parentAlias); + } + ) + ->where(function (BaseQueryBuilder $query) use ( + $childAlias, + $parentAlias, + $parentIdName, + $keyName, + $depthName, + $grammar, + ) { + $query->where(function (BaseQueryBuilder $query) use ($childAlias, $parentIdName, $depthName) { + $query->whereNull($childAlias . '.' . $parentIdName) + ->where($childAlias . '.' . $depthName, '<>', 0); + })->orWhere(function (BaseQueryBuilder $query) use ( + $childAlias, + $parentAlias, + $parentIdName, + $keyName, + $depthName, + $grammar, + ) { + $query->whereNotNull($childAlias . '.' . $parentIdName) + ->whereNotNull($parentAlias . '.' . $keyName) + ->whereRaw( + $grammar->wrap($childAlias . '.' . $depthName) + . ' <> ' + . $grammar->wrap($parentAlias . '.' . $depthName) + . ' + 1' + ); + }); + }); + + return $count ? $query->selectRaw('count(*)') : $query; + } + + /** + * Add null-safe scope comparisons between aliases. + */ + protected function addScopeColumnComparisons( + BaseQueryBuilder $query, + string $firstAlias, + string $secondAlias, + ): void { + foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + $first = $firstAlias . '.' . $attribute; + $second = $secondAlias . '.' . $attribute; + + $query->where(function (BaseQueryBuilder $query) use ($first, $second) { + $query->whereColumn($first, '=', $second) + ->orWhere(function (BaseQueryBuilder $query) use ($first, $second) { + $query->whereNull($first) + ->whereNull($second); + }); }); + } + } + + /** + * Assert that every nested set scope attribute is selected. + */ + protected function assertConcreteNestedSetScope(string $operation = 'diagnostics'): void + { + $attributes = $this->model->getAttributes(); + + 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.', + $operation, + $this->model::class, + )); + } + } + } + + /** + * Assert that stored bounds contain every parent-linked subtree node. + */ + protected function assertSubtreeSelectionComplete(Model $root): void + { + $keyName = $root->getKeyName(); + $parentIdName = $root->getParentIdName(); /* @phpstan-ignore method.notFound */ + $lftName = $root->getLftName(); /* @phpstan-ignore method.notFound */ + $bounds = [ + $root->getLft(), /* @phpstan-ignore method.notFound */ + $root->getRgt(), /* @phpstan-ignore method.notFound */ + ]; + + $selectedKeys = $root + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->select($keyName) + ->whereBetween($lftName, $bounds); + + $crossesBoundary = $root + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->whereIn($parentIdName, $selectedKeys) + ->whereNotBetween($lftName, $bounds) + ->exists(); + + if ($crossesBoundary) { + throw new LogicException(sprintf( + 'Nested set subtree for [%s] with key [%s] cannot be repaired because parentage crosses its stored bounds.', + $root::class, + $root->getKey() ?? 'null', + )); + } } /** @@ -645,100 +995,318 @@ public function getTotalErrors(): int */ public function isBroken(): bool { - return $this->getTotalErrors() > 0; + $this->assertConcreteNestedSetScope(); + + if ($this->getInvalidIntervalsQuery(false)->exists() + || $this->getDuplicateEndpointGroupsQuery()->exists() + || (int) $this->getMissingEndpointsQuery()->value('missing_endpoints') > 0 + || $this->getMissingParentQuery(false)->exists() + || $this->getWrongParentQuery(false)->exists() + || $this->getWrongDepthQuery(false)->exists() + ) { + return true; + } + + return (int) $this->getCrossingIntervalsQuery()->value('crossing_intervals') > 0; } /** - * Fixes the tree based on parentage info. - * Nodes with invalid parent are saved as roots. + * Fix the tree based on parentage information. + * + * Nodes with invalid parents become roots of the repaired selection. */ - public function fixTree(?Model $root = null): int + public function fixTree(?Model $root = null, array $extraColumns = []): int { - $columns = [ - $this->model->getKeyName(), /* @phpstan-ignore-line */ - $this->model->getParentIdName(), /* @phpstan-ignore-line */ - $this->model->getLftName(), /* @phpstan-ignore-line */ - $this->model->getRgtName(), /* @phpstan-ignore-line */ - ]; + if ($root === null) { + $this->assertConcreteNestedSetScope('repair'); + } else { + $this->assertSubtreeSelectionComplete($root); + } - $dictionary = $this->model - ->newNestedSetQuery() + $model = $root ?? $this->model; + $columns = array_values(array_unique([ + $model->getKeyName(), + $model->getParentIdName(), /* @phpstan-ignore method.notFound */ + $model->getLftName(), /* @phpstan-ignore method.notFound */ + $model->getRgtName(), /* @phpstan-ignore method.notFound */ + $model->getDepthName(), /* @phpstan-ignore method.notFound */ + ...$extraColumns, + ])); + + $nodes = $model + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); }) ->defaultOrder() - ->get($columns) - ->groupBy($this->model->getParentIdName()) /* @phpstan-ignore-line */ - ->all(); + ->get($columns); - return $this->fixNodes($dictionary, $root); - } + $roots = []; + $childrenByParent = []; + $parentOrder = []; - public function fixSubtree(Model $root): int - { - return $this->fixTree($root); + foreach ($nodes as $node) { + static::addRepairNode($roots, $childrenByParent, $parentOrder, $node); + } + + return $this->fixNodes($roots, $childrenByParent, $parentOrder, $root); } - protected function fixNodes(array &$dictionary, ?Model $parent = null): int + /** + * Fix a subtree based on parentage information. + */ + public function fixSubtree(Model $root, array $extraColumns = []): int { - $parentId = $parent ? $parent->getKey() : null; - $cut = $parent ? $parent->getLft() + 1 : 1; /* @phpstan-ignore-line */ + return $this->fixTree($root, $extraColumns); + } + /** + * Repair ordered nodes from their parent dictionaries. + */ + protected function fixNodes( + array $roots, + array &$childrenByParent, + array $parentOrder, + ?Model $parent = null, + ): int { + $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 = []; $moved = 0; - $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); + if ($parent === null) { + $nodes = $roots; + $roots = []; + } else { + $nodes = $childrenByParent[$parentId] ?? []; + unset($childrenByParent[$parentId]); + } + + $cut = static::reorderNodes( + $childrenByParent, + $updated, + $ordered, + $nodes, + $parentId, + $cut, + $depth, + ); - // Save nodes that have invalid parent as roots - while (! empty($dictionary)) { - $dictionary[null] = reset($dictionary); + foreach ($parentOrder as $unresolvedParentId) { + if ($unresolvedParentId === null) { + if ($roots === []) { + continue; + } - unset($dictionary[key($dictionary)]); + $nodes = $roots; + $roots = []; + } else { + if (! array_key_exists($unresolvedParentId, $childrenByParent)) { + continue; + } + + $nodes = $childrenByParent[$unresolvedParentId]; + unset($childrenByParent[$unresolvedParentId]); + } - $cut = self::reorderNodes($dictionary, $updated, $parentId, $cut); + $cut = static::reorderNodes( + $childrenByParent, + $updated, + $ordered, + $nodes, + $parentId, + $cut, + $depth, + ); } - /* @phpstan-ignore-next-line */ - if ($parent && ($grown = $cut - $parent->getRgt()) != 0) { - /* @phpstan-ignore-next-line */ - $moved = $this->model->newScopedQuery()->makeGap($parent->getRgt() + 1, $grown); + $grown = $parent ? $cut - $parent->getRgt() : 0; /* @phpstan-ignore method.notFound */ - /* @phpstan-ignore-next-line */ - $updated[] = $parent->rawNode($parent->getLft(), $cut, $parent->getParentId()); + if ($updated !== [] || $grown !== 0) { + NodeContext::setHasPerformed($parent ?? $this->model); } - foreach ($updated as $model) { - $model->save(); + if ($parent !== null && $grown !== 0) { + $gapCut = $parent->getRgt() + 1; /* @phpstan-ignore method.notFound */ + $moved = $parent + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->makeGap($gapCut, $grown); + + foreach ($ordered as $model) { + static::syncRepairNodeOriginalAfterGap($model, $gapCut, $grown); + } + + $parent = $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; + } + + $nodesToSave = $parent !== null && $grown !== 0 + ? $ordered + : $updated; + + foreach ($nodesToSave as $model) { + static::saveRepairNode($model); } return count($updated) + $moved; } + /** + * Sync a repair model's original bounds with the preceding gap update. + */ + protected static function syncRepairNodeOriginalAfterGap(Model $model, int $cut, int $height): void + { + $attributes = $model->getAttributes(); + $databaseAttributes = $model->getRawOriginal(); + $lftName = $model->getLftName(); /* @phpstan-ignore method.notFound */ + $rgtName = $model->getRgtName(); /* @phpstan-ignore method.notFound */ + $rgt = (int) $databaseAttributes[$rgtName]; + + if ($rgt < $cut) { + return; + } + + // 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; + + $model->setRawAttributes($databaseAttributes, true); + $model->setRawAttributes($attributes); + } + + /** + * Assign contiguous bounds and depth to a set of nodes. + */ protected static function reorderNodes( - array &$dictionary, + array &$childrenByParent, array &$updated, - mixed $parentId = null, - int $cut = 1 + array &$ordered, + array $nodes, + int|string|null $parentId, + int $cut, + int $depth, ): int { - if (! isset($dictionary[$parentId])) { - return $cut; + $stack = [[ + 'nodes' => array_values($nodes), + 'index' => 0, + 'parent_id' => $parentId, + 'depth' => $depth, + ]]; + + while ($stack !== []) { + $frameIndex = array_key_last($stack); + + if (isset($stack[$frameIndex]['model'])) { + $frame = array_pop($stack); + $model = $frame['model']; + + $model->rawNode( + $frame['lft'], + $cut, + $frame['parent_id'], + $frame['depth'], + ); + + $ordered[] = $model; + + if ($model->isDirty()) { + $updated[] = $model; + } + + ++$cut; + + continue; + } + + if ($stack[$frameIndex]['index'] >= count($stack[$frameIndex]['nodes'])) { + array_pop($stack); + + continue; + } + + $model = $stack[$frameIndex]['nodes'][$stack[$frameIndex]['index']]; + ++$stack[$frameIndex]['index']; + + $stack[] = [ + 'model' => $model, + 'lft' => $cut++, + 'parent_id' => $stack[$frameIndex]['parent_id'], + 'depth' => $stack[$frameIndex]['depth'], + ]; + + $key = $model->getKey(); + + if ($key === null || ! array_key_exists($key, $childrenByParent)) { + continue; + } + + $children = $childrenByParent[$key]; + unset($childrenByParent[$key]); + + $stack[] = [ + 'nodes' => array_values($children), + 'index' => 0, + 'parent_id' => $key, + 'depth' => $stack[$frameIndex]['depth'] + 1, + ]; } - foreach ($dictionary[$parentId] as $model) { - $lft = $cut; - /* @phpstan-ignore-next-line */ - $cut = static::reorderNodes($dictionary, $updated, $model->getKey(), $cut + 1); - /* @phpstan-ignore-next-line */ - if ($model->rawNode($lft, $cut, $parentId)->isDirty()) { - $updated[] = $model; + return $cut; + } + + /** + * Add a repair node to its parent bucket. + */ + protected static function addRepairNode( + array &$roots, + array &$childrenByParent, + array &$parentOrder, + Model $model, + ): void { + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + + if ($parentId === null) { + if ($roots === []) { + $parentOrder[] = null; } - ++$cut; + $roots[] = $model; + + return; } - unset($dictionary[$parentId]); + if (! array_key_exists($parentId, $childrenByParent)) { + $parentOrder[] = $parentId; + } - return $cut; + $childrenByParent[$parentId][] = $model; + } + + /** + * Save a node changed during repair. + */ + protected static function saveRepairNode(Model $model): void + { + if ($model->save()) { + return; + } + + throw new LogicException(sprintf( + 'Saving nested set node [%s] with key [%s] during repair was vetoed.', + $model::class, + $model->getKey() ?? 'null', + )); } /** @@ -747,107 +1315,174 @@ protected static function reorderNodes( * * @param bool $delete whether to delete nodes that exists but not in the data array */ - public function rebuildTree(array $data, bool $delete = false, int|Model|null $root = null): int + public function rebuildTree(array $data, bool $delete = false, ?Model $root = null): int { - /* @phpstan-ignore-next-line */ - if ($this->model->usesSoftDelete()) { - /* @phpstan-ignore-next-line */ - $this->withTrashed(); + if ($root === null) { + $this->assertConcreteNestedSetScope('rebuild'); + } else { + // Temporary rebuild nodes use zero bounds, so validate first. + $this->assertSubtreeSelectionComplete($root); } - /** @var \Hypervel\Database\Eloquent\Collection $result */ - $result = $this + $model = $root ?? $this->model; + $result = $model + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ ->when($root, function (self $query) use ($root) { return $query->whereDescendantOf($root); }) ->get(); $existing = $result->getDictionary(); + $roots = []; + $childrenByParent = []; + $parentOrder = []; + $parentId = $root?->getKey(); + $scopeAttributes = array_intersect_key( + $model->getAttributes(), + array_flip(array_keys($model->getNestedSetScope())), /* @phpstan-ignore method.notFound */ + ); - $dictionary = []; - $parentId = $root ? $root->getKey() : null; + $this->buildRebuildDictionary( + $roots, + $childrenByParent, + $parentOrder, + $data, + $existing, + $scopeAttributes, + $parentId, + ); + + if ($existing !== []) { + $usesSoftDeletes = $model::isSoftDeletable(); - $this->buildRebuildDictionary($dictionary, $data, $existing, $parentId); + if ($delete && ! $usesSoftDeletes) { + NodeContext::setHasPerformed($model); - if (! empty($existing)) { - /* @phpstan-ignore-next-line */ - if ($delete && ! $this->model->usesSoftDelete()) { - $this->model - ->newScopedQuery() - ->whereIn($this->model->getKeyName(), array_keys($existing)) + $model + ->newNestedSetQuery() /* @phpstan-ignore method.notFound */ + ->whereIn($model->getKeyName(), array_keys($existing)) ->delete(); } else { - foreach ($existing as $model) { - $dictionary[$model->getParentId()][] = $model; - - /* @phpstan-ignore-next-line */ - if ($delete && $this->model->usesSoftDelete() - && ! $model->{$model->getDeletedAtColumn()} + $deletedAtColumn = $delete && $usesSoftDeletes + ? $model->getDeletedAtColumn() + : null; + $deletedAt = $deletedAtColumn === null + ? null + : $model->fromDateTime($model->freshTimestamp()); + + foreach ($existing as $existingModel) { + if ($deletedAtColumn !== null + && $existingModel->{$deletedAtColumn} === null ) { - $time = $this->model->fromDateTime($this->model->freshTimestamp()); - - $model->{$model->getDeletedAtColumn()} = $time; + $existingModel->{$deletedAtColumn} = $deletedAt; } + + static::addRepairNode( + $roots, + $childrenByParent, + $parentOrder, + $existingModel, + ); } } } - return $this->fixNodes($dictionary, $root); + return $this->fixNodes( + $roots, + $childrenByParent, + $parentOrder, + $root, + ); } - public function rebuildSubtree(mixed $root, array $data, bool $delete = false): int + /** + * Rebuild a subtree from raw data. + */ + public function rebuildSubtree(Model $root, array $data, bool $delete = false): int { return $this->rebuildTree($data, $delete, $root); } + /** + * Build repair dictionaries from nested rebuild data. + */ protected function buildRebuildDictionary( - array &$dictionary, + array &$roots, + array &$childrenByParent, + array &$parentOrder, array $data, array &$existing, - mixed $parentId = null + array $scopeAttributes, + int|string|null $parentId = null, ): void { $keyName = $this->model->getKeyName(); foreach ($data as $itemData) { + $children = $itemData['children'] ?? null; + if (! isset($itemData[$keyName])) { - $model = $this->model->newInstance($this->model->getAttributes()); + $model = $this->model->newInstance($scopeAttributes); - // Set some values that will be fixed later - /* @phpstan-ignore-next-line */ - $model->rawNode(0, 0, $parentId); + // Set temporary values without scheduling a tree action. + $model->rawNode(0, 0, $parentId, 0); /* @phpstan-ignore method.notFound */ } else { - if (! isset($existing[$key = $itemData[$keyName]])) { - throw new ModelNotFoundException; + $key = $itemData[$keyName]; + + if (! isset($existing[$key])) { + throw (new ModelNotFoundException)->setModel($this->model::class, [$key]); } $model = $existing[$key]; - // Disable any tree actions - $model->rawNode($model->getLft(), $model->getRgt(), $parentId); + // Set the intended parent without scheduling a tree action. + $model->rawNode( + $model->getLft(), /* @phpstan-ignore method.notFound */ + $model->getRgt(), /* @phpstan-ignore method.notFound */ + $parentId, + $model->getDepth(), /* @phpstan-ignore method.notFound */ + ); unset($existing[$key]); } - $model->fill(Arr::except($itemData, ['children', $keyName]))->save(); + unset($itemData['children'], $itemData[$keyName]); + + $model->fill($itemData); - $dictionary[$parentId][] = $model; + if (! $model->exists || $model->isDirty([ + $model->getParentIdName(), /* @phpstan-ignore method.notFound */ + $model->getLftName(), /* @phpstan-ignore method.notFound */ + $model->getRgtName(), /* @phpstan-ignore method.notFound */ + $model->getDepthName(), /* @phpstan-ignore method.notFound */ + ])) { + NodeContext::setHasPerformed($model); + } + + static::saveRepairNode($model); + static::addRepairNode($roots, $childrenByParent, $parentOrder, $model); - if (! isset($itemData['children'])) { + if ($children === null) { continue; } $this->buildRebuildDictionary( - $dictionary, - $itemData['children'], + $roots, + $childrenByParent, + $parentOrder, + $children, $existing, - $model->getKey() + $scopeAttributes, + $model->getKey(), ); } } + /** + * Apply the concrete tree scope. + */ public function applyNestedSetScope(?string $table = null): static { - /* @phpstan-ignore-next-line */ + /* @phpstan-ignore method.notFound */ return $this->model->applyNestedSetScope($this, $table); } diff --git a/src/nested-set/src/Eloquent/SiblingsRelation.php b/src/nested-set/src/Eloquent/SiblingsRelation.php new file mode 100644 index 000000000..09ec450c8 --- /dev/null +++ b/src/nested-set/src/Eloquent/SiblingsRelation.php @@ -0,0 +1,232 @@ +andSelf = $andSelf; + + parent::__construct($builder, $model); + } + + /** + * Set the base constraints on the relation query. + */ + public function addConstraints(): void + { + if (! static::shouldAddConstraints()) { + return; + } + + $this->whereParentId($this->query, $this->parent); + + $this->parent->applyNestedSetScope($this->query); /* @phpstan-ignore method.notFound */ + + if (! $this->andSelf) { + $this->query->where( + $this->related->qualifyColumn($this->parent->getKeyName()), + '<>', + $this->parent->getKey(), + ); + } + } + + /** + * Apply an eager constraint for one parent model. + */ + protected function addEagerConstraint(QueryBuilder $query, Model $model): void + { + $query->orWhere(function (QueryBuilder $query) use ($model) { + $this->whereParentId($query, $model); + + $model->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ + + if (! $this->andSelf) { + $query->where( + $model->qualifyColumn($model->getKeyName()), + '<>', + $model->getKey(), + ); + } + }); + } + + /** + * Group eager constraints by exact scope and parent. + */ + protected function constrainEagerModels(QueryBuilder $query, array $models): void + { + if (count($models) === 1) { + $this->addEagerConstraint($query, $models[0]); + + return; + } + + $groups = []; + + foreach ($models as $model) { + $scope = $this->scopeKey($model); + $groups[$scope]['model'] ??= $model; + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + + if ($parentId === null) { + $groups[$scope]['has_root'] = true; + } else { + $groups[$scope]['parents'][$parentId] = $parentId; + } + } + + foreach ($groups as $group) { + $query->orWhere(function (QueryBuilder $query) use ($group) { + $group['model']->applyNestedSetScope($query); /* @phpstan-ignore method.notFound */ + + $query->where(function (QueryBuilder $query) use ($group) { + $parents = array_values($group['parents'] ?? []); + $hasRoot = $group['has_root'] ?? false; + $parentIdName = $group['model']->getParentIdName(); /* @phpstan-ignore method.notFound */ + $qualifiedParentIdName = $group['model']->qualifyColumn($parentIdName); + + if ($parents !== []) { + $query->whereIn($qualifiedParentIdName, $parents); + } + + if ($hasRoot) { + $parents === [] + ? $query->whereNull($qualifiedParentIdName) + : $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(); + + return $key === null + || $relatedKey === null + || (string) $key !== (string) $relatedKey; + } + + /** + * Index eager results by exact scope and parent while preserving query order. + */ + protected function indexResults(Collection $results): array + { + $index = []; + + foreach ($results as $related) { + $scope = $this->scopeKey($related); + $parentId = $related->getParentId(); /* @phpstan-ignore method.notFound */ + + if ($parentId === null) { + $index[$scope]['roots'][] = $related; + } else { + $index[$scope]['parents'][$parentId][] = $related; + } + } + + return $index; + } + + /** + * Match siblings from an exact scope-and-parent bucket. + */ + protected function matchFromIndex(Model $model, array $indexed): Collection + { + $scope = $indexed[$this->scopeKey($model)] ?? null; + + if ($scope === null) { + return $this->related->newCollection(); + } + + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + $candidates = $parentId === null + ? ($scope['roots'] ?? []) + : ($scope['parents'][$parentId] ?? []); + + if ($this->andSelf) { + return $this->related->newCollection($candidates); + } + + $key = $model->getKey(); + $matches = []; + + foreach ($candidates as $candidate) { + $candidateKey = $candidate->getKey(); + + if ($key === null || $candidateKey === null || (string) $candidateKey !== (string) $key) { + $matches[] = $candidate; + } + } + + return $this->related->newCollection($matches); + } + + /** + * Get the sibling relation existence condition. + */ + protected function relationExistenceCondition(string $hash, string $table, string $lft, string $rgt): string + { + $grammar = $this->getBaseQuery()->getGrammar(); + $parentId = $grammar->wrap($this->getForeignKeyName()); + $key = $grammar->wrap($this->parent->getKeyName()); + + $condition = "({$hash}.{$parentId} = {$table}.{$parentId}" + . " or ({$hash}.{$parentId} is null and {$table}.{$parentId} is null))"; + + if (! $this->andSelf) { + $condition .= " and {$hash}.{$key} <> {$table}.{$key}"; + } + + return $condition; + } + + /** + * Constrain a query to the model's parent ID. + */ + protected function whereParentId(QueryBuilder $query, Model $model): void + { + $parentIdName = $model->getParentIdName(); /* @phpstan-ignore method.notFound */ + $qualifiedParentIdName = $model->qualifyColumn($parentIdName); + $parentId = $model->getParentId(); /* @phpstan-ignore method.notFound */ + + $parentId === null + ? $query->whereNull($qualifiedParentIdName) + : $query->where($qualifiedParentIdName, '=', $parentId); + } +} diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index b6eaa57af..181bcadb0 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -4,24 +4,29 @@ namespace Hypervel\NestedSet; -use Carbon\CarbonInterface; +use DateTimeInterface; use Exception; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; -use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder as BaseQueryBuilder; use Hypervel\NestedSet\Eloquent\AncestorsRelation; use Hypervel\NestedSet\Eloquent\Collection; use Hypervel\NestedSet\Eloquent\DescendantsRelation; use Hypervel\NestedSet\Eloquent\QueryBuilder; +use Hypervel\NestedSet\Eloquent\SiblingsRelation; use Hypervel\Support\Arr; use LogicException; +use Stringable; + +use function Hypervel\Support\enum_value; /** * @template TModel of Model * - * @property int $parent_id + * @property null|int|string $parent_id + * @property ?int $depth * @property ?static $parent */ trait HasNode @@ -37,16 +42,34 @@ trait HasNode protected bool $moved = false; /** - * Whether the node has soft delete. + * Whether the node is being deleted by an evented descendant cascade. */ - protected static ?bool $hasSoftDelete = null; + protected bool $deletingAsDescendant = false; /** * Create a new Eloquent query builder for the model. */ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder { - return new QueryBuilder($query); + $builderClass = static::$resolvedBuilderClasses[static::class] + ??= $this->resolveCustomBuilderClass(); + + if ($builderClass === false) { + return new QueryBuilder($query); + } + + if (! is_subclass_of($builderClass, QueryBuilder::class)) { + throw new LogicException(sprintf( + 'Nested set model [%s] must use a builder that extends [%s].', + static::class, + QueryBuilder::class, + )); + } + + /** @var QueryBuilder $builder */ + $builder = new $builderClass($query); + + return $builder; } /** @@ -54,19 +77,30 @@ public function newEloquentBuilder(BaseQueryBuilder $query): QueryBuilder */ public static function bootHasNode(): void { - // Keep event registration in the boot phase; soft-delete detection avoids - // constructing a model before boot publication. static::saving(fn ($model) => $model->callPendingActions()); - static::deleting(fn ($model) => $model->refreshNode()); - - static::deleted(fn ($model) => $model->deleteDescendants()); + static::deleting(function ($model): void { + if (! $model->deletingAsDescendant) { + $model->refreshNode(); + } + }); - if (static::usesSoftDelete()) { - static::restoring(fn ($model) => NodeContext::keepDeletedAt($model)); - static::restored( - fn ($model) => $model->restoreDescendants(NodeContext::restoreDeletedAt($model)) - ); + static::deleted(function ($model): void { + if (! $model->deletingAsDescendant) { + $model->deleteDescendants(); + } + }); + + // The restore events are supplied by SoftDeletes rather than Model. + if (static::isSoftDeletable()) { + static::restored(function ($model): void { + /** @var null|DateTimeInterface|int|string $deletedAt */ + $deletedAt = $model->getPrevious()[$model->getDeletedAtColumn()] ?? null; + + if ($deletedAt !== null) { + $model->restoreDescendants($deletedAt); + } + }); } } @@ -103,24 +137,44 @@ protected function callPendingActions(): void $this->moved = call_user_func_array([$this, $method], $parameters); } + /** + * Determine whether the model uses soft deletes. + */ public static function usesSoftDelete(): bool { - if (! is_null(static::$hasSoftDelete)) { - return static::$hasSoftDelete; - } - - return static::$hasSoftDelete = in_array( - SoftDeletes::class, - class_uses_recursive(static::class), - true, - ); + return static::isSoftDeletable(); } + /** + * Apply a raw node action. + */ protected function actionRaw(): bool { return true; } + /** + * Resolve the deferred parent and append the node to it. + */ + protected function actionAppendToParentId(int|string $parentId): bool + { + $query = $this->newNestedSetQuery(); + + if (static::isSoftDeletable()) { + $query->withoutTrashed(); + } + + $parent = $query->findOrFail($parentId); + + $this->assertNodeInTree($parent) + ->assertNotDescendant($parent) + ->assertSameScope($parent); + + $this->setParent($parent)->dirtyBounds(); + + return $this->actionAppendOrPrepend($parent); + } + /** * Make a root node. */ @@ -132,11 +186,12 @@ protected function actionRoot(): bool $this->setLft($cut); $this->setRgt($cut + 1); + $this->setDepth(0); return true; } - return $this->insertAt($this->getLowerBound() + 1); + return $this->insertAt($this->getLowerBound() + 1, 0); } /** @@ -154,8 +209,9 @@ protected function actionAppendOrPrepend(self $parent, bool $prepend = false): b { $parent->refreshNode(); $cut = $prepend ? $parent->getLft() + 1 : $parent->getRgt(); + $targetDepth = $parent->getDepth() + 1; - if (! $this->insertAt($cut)) { + if (! $this->insertAt($cut, $targetDepth)) { return false; } @@ -182,7 +238,10 @@ protected function actionBeforeOrAfter(self $node, bool $after = false): bool { $node->refreshNode(); - return $this->insertAt($after ? $node->getRgt() + 1 : $node->getLft()); + return $this->insertAt( + $after ? $node->getRgt() + 1 : $node->getLft(), + $node->getDepth(), + ); } /** @@ -197,6 +256,7 @@ public function refreshNode(): void $attributes = $this->newNestedSetQuery()->getNodeData($this->getKey()); $this->attributes = array_merge($this->attributes, $attributes); + $this->unsetNestedSetRelations(); } /** @@ -204,7 +264,7 @@ public function refreshNode(): void */ public function parent(): BelongsTo { - return $this->belongsTo(get_class($this), $this->getParentIdName()) + return $this->belongsTo(static::class, $this->getParentIdName()) ->setModel($this); } @@ -213,7 +273,7 @@ public function parent(): BelongsTo */ public function children(): HasMany { - return $this->hasMany(get_class($this), $this->getParentIdName()) + return $this->hasMany(static::class, $this->getParentIdName()) ->setModel($this); } @@ -228,24 +288,21 @@ public function descendants(): DescendantsRelation /** * Get query for siblings of the node. */ - public function siblings(): QueryBuilder + public function siblings(): SiblingsRelation { - return $this->newScopedQuery() - ->where($this->getKeyName(), '<>', $this->getKey()) - ->where($this->getParentIdName(), '=', $this->getParentId()); + return new SiblingsRelation($this->newQuery(), $this); } /** - * Get the node siblings and the node itself. + * Get the relation for the node siblings and the node itself. */ - public function siblingsAndSelf(): QueryBuilder + public function siblingsAndSelf(): SiblingsRelation { - return $this->newScopedQuery() - ->where($this->getParentIdName(), '=', $this->getParentId()); + return new SiblingsRelation($this->newQuery(), $this, true); } /** - * Get query for the node siblings and the node itself. + * Get the node siblings and the node itself. * * @return Collection */ @@ -260,7 +317,11 @@ public function getSiblingsAndSelf(array $columns = ['*']): Collection public function nextSiblings(): QueryBuilder { return $this->nextNodes() - ->where($this->getParentIdName(), '=', $this->getParentId()); + ->where( + $this->qualifyColumn($this->getParentIdName()), + '=', + $this->getParentId(), + ); } /** @@ -269,7 +330,11 @@ public function nextSiblings(): QueryBuilder public function prevSiblings(): QueryBuilder { return $this->prevNodes() - ->where($this->getParentIdName(), '=', $this->getParentId()); + ->where( + $this->qualifyColumn($this->getParentIdName()), + '=', + $this->getParentId(), + ); } /** @@ -278,7 +343,11 @@ public function prevSiblings(): QueryBuilder public function nextNodes(): QueryBuilder { return $this->newScopedQuery() - ->where($this->getLftName(), '>', $this->getLft()); + ->where( + $this->qualifyColumn($this->getLftName()), + '>', + $this->getLft(), + ); } /** @@ -287,7 +356,11 @@ public function nextNodes(): QueryBuilder public function prevNodes(): QueryBuilder { return $this->newScopedQuery() - ->where($this->getLftName(), '<', $this->getLft()); + ->where( + $this->qualifyColumn($this->getLftName()), + '<', + $this->getLft(), + ); } /** @@ -352,9 +425,12 @@ public function prependToNode(self $parent): static return $this->appendOrPrependTo($parent, true); } + /** + * Prepare the node for insertion as a child. + */ public function appendOrPrependTo(self $parent, bool $prepend = false): static { - $this->assertNodeExists($parent) + $this->assertNodeInTree($parent) ->assertNotDescendant($parent) ->assertSameScope($parent); @@ -379,9 +455,12 @@ public function beforeNode(self $node): static return $this->beforeOrAfterNode($node); } + /** + * Prepare the node for insertion beside another node. + */ public function beforeOrAfterNode(self $node, bool $after = false): static { - $this->assertNodeExists($node) + $this->assertNodeInTree($node) ->assertNotDescendant($node) ->assertSameScope($node); @@ -399,7 +478,13 @@ public function beforeOrAfterNode(self $node, bool $after = false): static */ public function insertAfterNode(self $node): bool { - return $this->afterNode($node)->save(); + if (! $this->afterNode($node)->save()) { + return false; + } + + $node->refreshNode(); + + return true; } /** @@ -417,9 +502,19 @@ public function insertBeforeNode(self $node): bool return true; } - public function rawNode(mixed $lft, mixed $rgt, mixed $parentId): static - { - $this->setLft($lft)->setRgt($rgt)->setParentId($parentId); + /** + * Set raw structural values. + */ + public function rawNode( + int $lft, + int $rgt, + int|string|null $parentId, + ?int $depth, + ): static { + $this->setLft($lft) + ->setRgt($rgt) + ->setParentId($parentId) + ->setDepth($depth); return $this->setNodeAction('raw'); } @@ -461,25 +556,59 @@ public function down(int $amount = 1): bool /** * Insert node at specific position. */ - protected function insertAt(int $position): bool + protected function insertAt(int $position, ?int $targetDepth = null): bool { - NodeContext::setHasPerformed($this); - return $this->exists - ? $this->moveNode($position) - : $this->insertNode($position); + ? $this->moveNode($position, $targetDepth) + : $this->insertNode($position, $targetDepth); } /** * Move a node to the new position. */ - protected function moveNode(int $position): bool + protected function moveNode(int $position, ?int $targetDepth = null): bool { - $updated = $this->newNestedSetQuery() - ->moveNode($this->getKey(), $position) > 0; + $this->refreshNode(); + + $lft = $this->getLft(); + $rgt = $this->getRgt(); + $height = $rgt - $lft + 1; + + NodeContext::setHasPerformed($this); + + $updated = $this->newNestedSetQuery()->moveNode( + $this->getKey(), + $position, + $targetDepth, + [ + $this->getLftName() => $lft, + $this->getRgtName() => $rgt, + $this->getDepthName() => $this->getDepth(), + ], + ) > 0; if ($updated) { - $this->refreshNode(); + if ($position > $lft) { + $this->setLft($position - $height); + $this->setRgt($position - 1); + } else { + $this->setLft($position); + $this->setRgt($position + $height - 1); + } + + if ($targetDepth !== null) { + $this->setDepth($targetDepth); + } else { + $this->refreshNode(); + } + + $this->syncOriginalAttributes([ + $this->getLftName(), + $this->getRgtName(), + $this->getDepthName(), + ]); + + $this->unsetNestedSetRelations(); } return $updated; @@ -488,14 +617,17 @@ protected function moveNode(int $position): bool /** * Insert new node at specified position. */ - protected function insertNode(int $position): bool + protected function insertNode(int $position, ?int $targetDepth = null): bool { + NodeContext::setHasPerformed($this); + $this->newNestedSetQuery()->makeGap($position, 2); $height = $this->getNodeHeight(); $this->setLft($position); $this->setRgt($position + $height - 1); + $this->setDepth($targetDepth ?? $this->newNestedSetQuery()->depthForPosition($position)); return true; } @@ -508,64 +640,138 @@ protected function deleteDescendants(): void $lft = $this->getLft(); $rgt = $this->getRgt(); - $method = $this->usesSoftDelete() && $this->forceDeleting + $method = static::isSoftDeletable() && $this->forceDeleting ? 'forceDelete' : 'delete'; - $this->descendants()->{$method}(); + if ($this->shouldFireDescendantEvents()) { + $this->deleteDescendantsWithEvents($method === 'forceDelete'); + } else { + $this->descendants()->{$method}(); + } if ($this->hasForceDeleting()) { $height = $rgt - $lft + 1; + NodeContext::setHasPerformed($this); + $this->newNestedSetQuery()->makeGap($rgt + 1, -$height); // In case if user wants to re-create the node $this->makeRoot(); - - NodeContext::setHasPerformed($this); } } /** - * Restore the descendants. + * Determine whether descendant model events should be fired during deletion. */ - protected function restoreDescendants(CarbonInterface|string $deletedAt): void + protected function shouldFireDescendantEvents(): bool { - $this->descendants() - ->where($this->getDeletedAtColumn(), '>=', $deletedAt) - ->restore(); + return false; } /** - * Create a new Model query builder for the model. - * - * @param BaseQueryBuilder $query + * Get the descendant deletion chunk size. + */ + protected function getDescendantDeleteChunkSize(): int + { + return 1000; + } + + /** + * Delete descendants through their model lifecycle in children-first chunks. + */ + protected function deleteDescendantsWithEvents(bool $forceDelete): void + { + $lftName = $this->getLftName(); + $query = $this->newNestedSetQuery() + ->where($lftName, '>', $this->getLft()) + ->where($lftName, '<', $this->getRgt()) + ->orderBy($lftName, 'desc'); + + if (static::isSoftDeletable() && ! $forceDelete) { + $query->whereNull($this->getDeletedAtColumn()); + } + + $cursor = null; + + do { + $chunk = clone $query; + + if ($cursor !== null) { + $chunk->where($lftName, '<', $cursor); + } + + $descendants = $chunk + ->limit($this->getDescendantDeleteChunkSize()) + ->get(); + + foreach ($descendants as $descendant) { + $cursor = $descendant->getLft(); /* @phpstan-ignore method.notFound */ + $descendant->deletingAsDescendant = true; /* @phpstan-ignore property.notFound */ + + try { + $deleted = $forceDelete + ? $descendant->forceDelete() + : $descendant->delete(); + + if ($deleted === false) { + throw new LogicException(sprintf( + 'Deleting nested set descendant [%s] with key [%s] was vetoed.', + $descendant::class, + $descendant->getKey() ?? 'null', + )); + } + } finally { + $descendant->deletingAsDescendant = false; /* @phpstan-ignore property.notFound */ + } + } + } while ($descendants->isNotEmpty()); + } + + /** + * Restore the descendants. */ - public function newModelBuilder($query): QueryBuilder + protected function restoreDescendants(DateTimeInterface|int|string $deletedAt): void { - return new QueryBuilder($query); + $this->descendants() + ->where($this->getDeletedAtColumn(), '>=', $deletedAt) + ->restore(); } /** * Get a new base query that includes deleted nodes. */ - public function newNestedSetQuery(?string $table = null): mixed + public function newNestedSetQuery(?string $table = null): QueryBuilder { - $builder = $this->usesSoftDelete() - ? $this->withTrashed() - : $this->newQuery(); + $builder = $this->newQuery()->withoutGlobalScopes(); return $this->applyNestedSetScope($builder, $table); } - public function newScopedQuery(?string $table = null): mixed + /** + * Get a new query with ordinary visibility and the concrete tree scope. + */ + public function newScopedQuery(?string $table = null): QueryBuilder { return $this->applyNestedSetScope($this->newQuery(), $table); } - public function applyNestedSetScope(mixed $query, ?string $table = null): mixed - { - if (! $scoped = $this->getScopeAttributes()) { + /** + * Apply the concrete tree scope to the query. + * + * @template TQuery of BaseQueryBuilder|EloquentBuilder + * + * @param TQuery $query + * @return TQuery + */ + public function applyNestedSetScope( + BaseQueryBuilder|EloquentBuilder $query, + ?string $table = null, + ): BaseQueryBuilder|EloquentBuilder { + $scope = $this->getNestedSetScope(); + + if ($scope === []) { return $query; } @@ -573,23 +779,85 @@ public function applyNestedSetScope(mixed $query, ?string $table = null): mixed $table = $this->getTable(); } - foreach ($scoped as $attribute) { + foreach ($scope as $attribute => $value) { $query->where( $table . '.' . $attribute, '=', - $this->getAttributeValue($attribute) + $value, ); } return $query; } + /** + * Get the attributes that partition nested set trees. + */ protected function getScopeAttributes(): array { return []; } - public static function scoped(array $attributes): mixed + /** + * Get the normalized scope values for this tree. + * + * @return array + */ + public function getNestedSetScope(): array + { + $scope = []; + + foreach ($this->getScopeAttributes() as $attribute) { + $scope[$attribute] = $this->normalizeNestedSetScopeValue( + $attribute, + $this->getAttributeValue($attribute), + ); + } + + return $scope; + } + + /** + * Normalize a nested set scope value for SQL and identity comparisons. + */ + protected function normalizeNestedSetScopeValue(string $attribute, mixed $value): int|string|null + { + $value = enum_value($value); + + return match (true) { + $value === null, is_int($value), is_string($value) => $value, + is_bool($value) => (int) $value, + $value instanceof DateTimeInterface => $value->format('Y-m-d H:i:s'), + $value instanceof Stringable => (string) $value, + default => throw new LogicException(sprintf( + 'Nested set model [%s] has unsupported scope value [%s] for attribute [%s].', + static::class, + get_debug_type($value), + $attribute, + )), + }; + } + + /** + * Get the stable identity key for this tree scope. + */ + public function getNestedSetScopeKey(): string + { + $key = ''; + + foreach ($this->getNestedSetScope() as $value) { + $key .= $value === null + ? '-1:' + : strlen((string) $value) . ':' . $value; + } + + return $key; + } + + /** + * Begin a query for one concrete nested set scope. + */ + public static function scoped(array $attributes): QueryBuilder { $instance = new static; @@ -599,6 +867,8 @@ public static function scoped(array $attributes): mixed } /** + * Create a new nested set collection. + * * @return Collection */ public function newCollection(array $models = []): Collection @@ -609,7 +879,7 @@ public function newCollection(array $models = []): Collection /** * Use `children` key on `$attributes` to create child nodes. */ - public static function create(array $attributes = [], ?self $parent = null): ?static + public static function create(array $attributes = [], ?self $parent = null): static { $children = Arr::pull($attributes, 'children'); @@ -624,13 +894,18 @@ public static function create(array $attributes = [], ?self $parent = null): ?st $relation = new Collection; foreach ((array) $children as $child) { - $relation->add($child = static::create($child, $instance)); - - $child->setRelation('parent', $instance); + $relation->add(static::create($child, $instance)); } $instance->refreshNode(); + $relationParent = clone $instance; + $relationParent->setRelations([]); + + foreach ($relation as $child) { + $child->setRelation('parent', $relationParent); + } + return $instance->setRelation('children', $relation); } @@ -660,17 +935,24 @@ public function getDescendantCount(): int * * @throws Exception If parent node doesn't exists */ - public function setParentIdAttribute(?int $value): void + public function setParentIdAttribute(int|string|null $value): void { - if ($this->getParentId() == $value) { + $current = $this->getParentId(); + + if ($current === $value + || ($current !== null && $value !== null && (string) $current === (string) $value) + ) { return; } - if ($value) { - $this->appendToNode($this->newScopedQuery()->findOrFail($value)); - } else { + if ($value === null) { $this->makeRoot(); + + return; } + + $this->setParentId($value); + $this->setNodeAction('appendToParentId', $value); } /** @@ -681,9 +963,12 @@ public function isRoot(): bool return is_null($this->getParentId()); } + /** + * Determine whether the node is a leaf. + */ public function isLeaf(): bool { - return $this->getLft() + 1 == $this->getRgt(); + return $this->getLft() + 1 === $this->getRgt(); } /** @@ -710,6 +995,14 @@ public function getParentIdName(): string return NestedSet::PARENT_ID; } + /** + * Get the depth column name. + */ + public function getDepthName(): string + { + return NestedSet::DEPTH; + } + /** * Get the value of the model's lft key. */ @@ -733,18 +1026,28 @@ public function getRgt(): ?int /** * Get the value of the model's parent id key. */ - public function getParentId(): ?int + public function getParentId(): int|string|null { return $this->getAttributeValue($this->getParentIdName()); } + /** + * Get the node depth. + */ + public function getDepth(): ?int + { + $value = $this->getAttributeValue($this->getDepthName()); + + return $value === null ? null : (int) $value; + } + /** * Returns node that is next to current node without constraining to siblings. * This can be either a next sibling or a next sibling of the parent node. * * @return null|TModel */ - public function getNextNode(array $columns = ['*']): mixed + public function getNextNode(array $columns = ['*']): ?Model { return $this->nextNodes()->defaultOrder()->first($columns); } @@ -755,12 +1058,14 @@ public function getNextNode(array $columns = ['*']): mixed * * @return null|TModel */ - public function getPrevNode(array $columns = ['*']): mixed + public function getPrevNode(array $columns = ['*']): ?Model { return $this->prevNodes()->defaultOrder('desc')->first($columns); } /** + * Get the node's ancestors. + * * @return Collection */ public function getAncestors(array $columns = ['*']): Collection @@ -769,6 +1074,8 @@ public function getAncestors(array $columns = ['*']): Collection } /** + * Get the node's descendants. + * * @return Collection */ public function getDescendants(array $columns = ['*']): Collection @@ -777,6 +1084,8 @@ public function getDescendants(array $columns = ['*']): Collection } /** + * Get the node's siblings. + * * @return Collection */ public function getSiblings(array $columns = ['*']): Collection @@ -785,6 +1094,8 @@ public function getSiblings(array $columns = ['*']): Collection } /** + * Get siblings after the node. + * * @return Collection */ public function getNextSiblings(array $columns = ['*']): Collection @@ -793,6 +1104,8 @@ public function getNextSiblings(array $columns = ['*']): Collection } /** + * Get siblings before the node. + * * @return Collection */ public function getPrevSiblings(array $columns = ['*']): Collection @@ -801,17 +1114,21 @@ public function getPrevSiblings(array $columns = ['*']): Collection } /** + * Get the next sibling. + * * @return null|TModel */ - public function getNextSibling(array $columns = ['*']): mixed + public function getNextSibling(array $columns = ['*']): ?Model { return $this->nextSiblings()->defaultOrder()->first($columns); } /** + * Get the previous sibling. + * * @return null|TModel */ - public function getPrevSibling(array $columns = ['*']): mixed + public function getPrevSibling(array $columns = ['*']): ?Model { return $this->prevSiblings()->defaultOrder('desc')->first($columns); } @@ -821,9 +1138,12 @@ public function getPrevSibling(array $columns = ['*']): mixed */ public function isDescendantOf(self $other): bool { - return $this->getLft() > $other->getLft() + return $this->exists + && $other->exists + && $this->isSameTree($other) + && $this->getLft() > $other->getLft() && $this->getLft() < $other->getRgt() - && $this->isSameScope($other); + && ! $this->isSameNode($other); } /** @@ -831,8 +1151,16 @@ public function isDescendantOf(self $other): bool */ public function isSelfOrDescendantOf(self $other): bool { - return $this->getLft() >= $other->getLft() - && $this->getLft() < $other->getRgt(); + return $this->exists + && $other->exists + && $this->isSameTree($other) + && ( + $this->isSameNode($other) + || ( + $this->getLft() > $other->getLft() + && $this->getLft() < $other->getRgt() + ) + ); } /** @@ -840,7 +1168,15 @@ public function isSelfOrDescendantOf(self $other): bool */ public function isChildOf(self $other): bool { - return $this->getParentId() == $other->getKey(); + $parentId = $this->getParentId(); + $otherKey = $other->getKey(); + + return $this->exists + && $other->exists + && $parentId !== null + && $otherKey !== null + && $this->isSameTree($other) + && (string) $parentId === (string) $otherKey; } /** @@ -848,7 +1184,20 @@ public function isChildOf(self $other): bool */ public function isSiblingOf(self $other): bool { - return $this->getParentId() == $other->getParentId(); + if (! $this->exists + || ! $other->exists + || ! $this->isSameTree($other) + || $this->isSameNode($other) + ) { + return false; + } + + $parentId = $this->getParentId(); + $otherParentId = $other->getParentId(); + + return $parentId === null || $otherParentId === null + ? $parentId === $otherParentId + : (string) $parentId === (string) $otherParentId; } /** @@ -875,24 +1224,17 @@ public function hasMoved(): bool return $this->moved; } - protected function getArrayableRelations(): array - { - $result = parent::getArrayableRelations(); - - unset($result['parent']); - - return $result; - } - /** * Get whether user is intended to delete the model from database entirely. */ protected function hasForceDeleting(): bool { - return ! $this->usesSoftDelete() || $this->forceDeleting; + return ! static::isSoftDeletable() || $this->forceDeleting; } /** + * Get the node bounds. + * * @return array{?int, ?int} */ public function getBounds(): array @@ -900,27 +1242,49 @@ public function getBounds(): array return [$this->getLft(), $this->getRgt()]; } - public function setLft(mixed $value): static + /** + * Set the left bound. + */ + public function setLft(?int $value): static { $this->attributes[$this->getLftName()] = $value; return $this; } - public function setRgt(mixed $value): static + /** + * Set the right bound. + */ + public function setRgt(?int $value): static { $this->attributes[$this->getRgtName()] = $value; return $this; } - public function setParentId(mixed $value): static + /** + * Set the parent key. + */ + public function setParentId(int|string|null $value): static { $this->attributes[$this->getParentIdName()] = $value; return $this; } + /** + * Set the node depth. + */ + public function setDepth(?int $value): static + { + $this->attributes[$this->getDepthName()] = $value; + + return $this; + } + + /** + * Mark the stored bounds as dirty. + */ protected function dirtyBounds(): static { $this->original[$this->getLftName()] = null; @@ -929,58 +1293,104 @@ protected function dirtyBounds(): static return $this; } + /** + * Forget relations derived from the node's structural position. + */ + protected function unsetNestedSetRelations(): void + { + foreach ([ + 'parent', + 'children', + 'ancestors', + 'descendants', + 'siblings', + 'siblingsAndSelf', + ] as $relation) { + $this->unsetRelation($relation); + } + } + + /** + * Assert that a node is not this node's descendant. + */ protected function assertNotDescendant(self $node): static { - if ($node == $this || $node->isDescendantOf($this)) { + if ($node === $this || $this->isSameNode($node) || $node->isDescendantOf($this)) { throw new LogicException('Node must not be a descendant.'); } return $this; } - protected function assertNodeExists(self $node): static + /** + * Assert that a node has persisted tree bounds. + */ + protected function assertNodeInTree(self $node): static { - if (! $node->getLft() || ! $node->getRgt()) { - throw new LogicException('Node must exists.'); + if (($node->getLft() ?? 0) < 1 || ($node->getRgt() ?? 0) < 1) { + throw new LogicException('Node must be part of a tree.'); } return $this; } - protected function assertSameScope(self $node): void + /** + * Assert that a node belongs to the same concrete scope. + */ + protected function assertSameScope(self $node): static { - if (! $scoped = $this->getScopeAttributes()) { - return; + if (! $this->isSameScope($node)) { + throw new LogicException('Nodes must be in the same scope.'); } - foreach ($scoped as $attr) { - if ($this->getAttribute($attr) != $node->getAttribute($attr)) { - throw new LogicException('Nodes must be in the same scope'); - } - } + return $this; } + /** + * Determine whether a node belongs to the same concrete scope. + */ protected function isSameScope(self $node): bool { - if (! $scoped = $this->getScopeAttributes()) { + return $this->getNestedSetScopeKey() === $node->getNestedSetScopeKey(); + } + + /** + * Determine whether the models address the same nested set tree. + */ + protected function isSameTree(self $node): bool + { + return NodeContext::structuralIdentity($this) === NodeContext::structuralIdentity($node) + && $this->isSameScope($node); + } + + /** + * Determine whether the models address the same persisted row. + */ + protected function isSameNode(self $node): bool + { + if ($this->is($node)) { return true; } - foreach ($scoped as $attr) { - if ($this->getAttribute($attr) != $node->getAttribute($attr)) { - return false; - } - } + $key = $this->getKey(); + $nodeKey = $node->getKey(); - return true; + return $key !== null + && $nodeKey !== null + && (string) $key === (string) $nodeKey + && NodeContext::structuralIdentity($this) === NodeContext::structuralIdentity($node); } + /** + * Clone the node without structural attributes. + */ public function replicate(?array $except = null): static { $defaults = [ $this->getParentIdName(), $this->getLftName(), $this->getRgtName(), + $this->getDepthName(), ]; $except = $except ? array_unique(array_merge($except, $defaults)) : $defaults; diff --git a/src/nested-set/src/NodeContext.php b/src/nested-set/src/NodeContext.php index 061eef2ae..a2a9300a1 100644 --- a/src/nested-set/src/NodeContext.php +++ b/src/nested-set/src/NodeContext.php @@ -4,49 +4,56 @@ namespace Hypervel\NestedSet; -use DateTimeInterface; use Hypervel\Context\CoroutineContext; use Hypervel\Database\Eloquent\Model; class NodeContext { /** - * Context key prefix for preserved deleted_at timestamps. + * Context key prefix for tracking whether a node operation has been performed. */ - protected const DELETED_AT_CONTEXT_PREFIX = '__nested_set.deleted_at.'; + protected const HAS_PERFORMED_CONTEXT_KEY_PREFIX = '__nested_set.has_performed.'; /** - * Context key prefix for tracking whether a node operation has been performed. + * Determine whether the logical tree has changed in this coroutine. */ - protected const HAS_PERFORMED_CONTEXT_PREFIX = '__nested_set.has_performed.'; + public static function hasPerformed(Model $model): bool + { + return CoroutineContext::get(static::performedKey($model), false); + } - public static function keepDeletedAt(Model $model): void + /** + * Mark that the logical tree has changed in this coroutine. + */ + public static function setHasPerformed(Model $model): void { - CoroutineContext::set( - self::DELETED_AT_CONTEXT_PREFIX . get_class($model), - $model->{$model->getDeletedAtColumn()} // @phpstan-ignore-line - ); + CoroutineContext::set(static::performedKey($model), true); } - public static function restoreDeletedAt(Model $model): DateTimeInterface|int|string + /** + * Get the model's logical nested set table identity. + */ + public static function structuralIdentity(Model $model): string { - $deletedAt = CoroutineContext::get(self::DELETED_AT_CONTEXT_PREFIX . get_class($model)); + $connection = $model->getConnection(); + $name = $connection->getName(); - if (! is_null($deletedAt)) { - /* @phpstan-ignore-next-line */ - $model->{$model->getDeletedAtColumn()} = $deletedAt; + if ($name === null || $name === '') { + $name = $model::getConnectionResolver()?->getDefaultConnection(); } - return $deletedAt; - } + if ($name === null || $name === '') { + $name = 'default'; + } - public static function hasPerformed(Model $model): bool - { - return CoroutineContext::get(self::HAS_PERFORMED_CONTEXT_PREFIX . get_class($model), false); + return strlen($name) . ':' . $name . ':' . $model->getTable(); } - public static function setHasPerformed(Model $model, bool $performed = true): void + /** + * Get the structural freshness key for the model's logical table. + */ + protected static function performedKey(Model $model): string { - CoroutineContext::set(self::HAS_PERFORMED_CONTEXT_PREFIX . get_class($model), $performed); + return self::HAS_PERFORMED_CONTEXT_KEY_PREFIX . static::structuralIdentity($model); } } From 6268cc753c0d5aa807d1b3b1ba09492ea1d052f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:35 +0000 Subject: [PATCH 05/11] test(nested-set): cover scoped tree invariants Add deterministic coverage for logical connection and table freshness, custom builders, normalized compound scopes, UUID and scalar key boundaries, qualified structural reads, nested relation existence queries, sibling relations, and adaptive eager matching. Exercise stored depth and relation invalidation across every movement direction, partial selects, zero-distance operations, soft and force deletion, restoration, bulk and evented descendant paths, collection relinking, and recursive creation. Cover every named integrity category plus scoped diagnostics, repair and rebuild of roots, subtrees, orphans, cycles, vetoes, gap-shifted rows, and invalid repair boundaries. Correct inherited upstream tests so they call the real node APIs and assert the intended guards instead of passing on unrelated method failures. --- tests/NestedSet/Models/Category.php | 5 - tests/NestedSet/Models/MenuItem.php | 7 +- tests/NestedSet/NestedSetTest.php | 192 ++++ tests/NestedSet/NodeContextTest.php | 158 ++++ tests/NestedSet/NodeTest.php | 1288 +++++++++++++++++++++++++-- tests/NestedSet/ScopedNodeTest.php | 224 ++++- 6 files changed, 1777 insertions(+), 97 deletions(-) create mode 100644 tests/NestedSet/NodeContextTest.php diff --git a/tests/NestedSet/Models/Category.php b/tests/NestedSet/Models/Category.php index c065ad90a..8f96efba8 100644 --- a/tests/NestedSet/Models/Category.php +++ b/tests/NestedSet/Models/Category.php @@ -16,9 +16,4 @@ class Category extends Model protected array $fillable = ['name', 'parent_id']; public bool $timestamps = false; - - // public static function resetActionsPerformed() - // { - // static::$actionsPerformed = 0; - // } } diff --git a/tests/NestedSet/Models/MenuItem.php b/tests/NestedSet/Models/MenuItem.php index dfecfbcb3..ddc0c10b1 100644 --- a/tests/NestedSet/Models/MenuItem.php +++ b/tests/NestedSet/Models/MenuItem.php @@ -15,12 +15,7 @@ class MenuItem extends Model protected array $fillable = ['menu_id', 'parent_id']; - // public static function resetActionsPerformed() - // { - // static::$actionsPerformed = 0; - // } - - protected function getScopeAttributes() + protected function getScopeAttributes(): array { return ['menu_id']; } diff --git a/tests/NestedSet/NestedSetTest.php b/tests/NestedSet/NestedSetTest.php index 415a8f5fb..f248714c2 100644 --- a/tests/NestedSet/NestedSetTest.php +++ b/tests/NestedSet/NestedSetTest.php @@ -4,12 +4,22 @@ namespace Hypervel\Tests\NestedSet; +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; +use Hypervel\Database\Eloquent\Builder; 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; use Hypervel\NestedSet\NestedSet; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use LogicException; +use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use stdClass; +use Stringable; class NestedSetTest extends TestCase { @@ -18,6 +28,11 @@ public function testIsNodeReturnsTrueForModelUsingHasNode(): void $this->assertTrue(NestedSet::isNode(new NestedSetTestNodeModel)); } + public function testIsNodeReturnsTrueForModelUsingHasNodeThroughAnotherTrait(): void + { + $this->assertTrue(NestedSet::isNode(new NestedSetTestNestedTraitNodeModel)); + } + public function testNodeBootDetectsSoftDeletesWithoutNestedModelConstruction(): void { $node = new NestedSetTestNodeModel; @@ -27,6 +42,31 @@ public function testNodeBootDetectsSoftDeletesWithoutNestedModelConstruction(): $this->assertTrue($softDeletingNode::usesSoftDelete()); } + public function testNodeUsesNestedSetBuilderByDefault(): void + { + $builder = (new NestedSetTestNodeModel) + ->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + + $this->assertInstanceOf(QueryBuilder::class, $builder); + } + + public function testNodeUsesCompatibleAttributedBuilder(): void + { + $builder = (new NestedSetTestCustomBuilderNodeModel) + ->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + + $this->assertInstanceOf(NestedSetTestCustomBuilder::class, $builder); + } + + public function testNodeRejectsIncompatibleAttributedBuilder(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must use a builder that extends'); + + (new NestedSetTestIncompatibleBuilderNodeModel) + ->newEloquentBuilder(m::mock(BaseQueryBuilder::class)); + } + public function testIsNodeReturnsFalseForPlainEloquentModel(): void { $this->assertFalse(NestedSet::isNode(new NestedSetTestPlainModel)); @@ -44,6 +84,115 @@ 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; + $model->setRawAttributes([ + 'first' => NestedSetTestScope::One, + 'second' => true, + 'third' => CarbonImmutable::parse('2026-01-02 03:04:05'), + 'fourth' => new NestedSetTestStringable('value'), + 'fifth' => null, + ]); + + $this->assertSame([ + 'first' => 1, + 'second' => 1, + 'third' => '2026-01-02 03:04:05', + 'fourth' => 'value', + 'fifth' => null, + ], $model->getNestedSetScope()); + } + + public function testScopeKeysDistinguishCompositeValuesWithoutSeparatorsColliding(): void + { + $first = new NestedSetTestScopeNodeModel; + $first->setRawAttributes(['first' => '1', 'second' => '23']); + + $second = new NestedSetTestScopeNodeModel; + $second->setRawAttributes(['first' => '12', 'second' => '3']); + + $integer = new NestedSetTestScopeNodeModel; + $integer->setRawAttributes(['first' => 1, 'second' => null]); + + $string = new NestedSetTestScopeNodeModel; + $string->setRawAttributes(['first' => '1', 'second' => null]); + + $empty = new NestedSetTestScopeNodeModel; + $empty->setRawAttributes(['first' => '']); + + $null = new NestedSetTestScopeNodeModel; + $null->setRawAttributes(['first' => null]); + + $this->assertNotSame($first->getNestedSetScopeKey(), $second->getNestedSetScopeKey()); + $this->assertSame($integer->getNestedSetScopeKey(), $string->getNestedSetScopeKey()); + $this->assertNotSame($empty->getNestedSetScopeKey(), $null->getNestedSetScopeKey()); + } + + #[DataProvider('unsupportedScopeValues')] + public function testUnsupportedScopeValuesFailDescriptively(mixed $value, string $type): void + { + $model = new NestedSetTestScopeNodeModel; + $model->setRawAttributes(['first' => $value]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage("unsupported scope value [{$type}] for attribute [first]"); + + $model->getNestedSetScope(); + } + + public static function unsupportedScopeValues(): array + { + return [ + 'float' => [1.5, 'float'], + 'non-stringable object' => [new stdClass, stdClass::class], + ]; + } +} + +enum NestedSetTestScope: int +{ + case One = 1; +} + +class NestedSetTestStringable implements Stringable +{ + public function __construct(protected string $value) + { + } + + public function __toString(): string + { + return $this->value; + } +} + +trait NestedSetTestNestedNode +{ + use HasNode; } class NestedSetTestNodeModel extends Model @@ -58,6 +207,13 @@ class NestedSetTestPlainModel extends Model protected ?string $table = 'nested_set_test_plain'; } +class NestedSetTestNestedTraitNodeModel extends Model +{ + use NestedSetTestNestedNode; + + protected ?string $table = 'nested_set_test_nested_trait_nodes'; +} + class NestedSetTestSoftDeletingNodeModel extends Model { use SoftDeletes; @@ -65,3 +221,39 @@ class NestedSetTestSoftDeletingNodeModel extends Model protected ?string $table = 'nested_set_test_soft_deleting_nodes'; } + +class NestedSetTestScopeNodeModel extends Model +{ + use HasNode; + + protected ?string $table = 'nested_set_test_scope_nodes'; + + protected function getScopeAttributes(): array + { + return ['first', 'second', 'third', 'fourth', 'fifth']; + } +} + +/** + * @template TModel of Model + * @extends QueryBuilder + */ +class NestedSetTestCustomBuilder extends QueryBuilder +{ +} + +#[UseEloquentBuilder(NestedSetTestCustomBuilder::class)] +class NestedSetTestCustomBuilderNodeModel extends Model +{ + use HasNode; + + protected ?string $table = 'nested_set_test_custom_builder_nodes'; +} + +#[UseEloquentBuilder(Builder::class)] +class NestedSetTestIncompatibleBuilderNodeModel extends Model +{ + use HasNode; + + protected ?string $table = 'nested_set_test_incompatible_builder_nodes'; +} diff --git a/tests/NestedSet/NodeContextTest.php b/tests/NestedSet/NodeContextTest.php new file mode 100644 index 000000000..739e78426 --- /dev/null +++ b/tests/NestedSet/NodeContextTest.php @@ -0,0 +1,158 @@ +shouldReceive('getName')->andReturn('primary'); + + $secondary = m::mock(Connection::class); + $secondary->shouldReceive('getName')->andReturn('secondary'); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($primary); + $resolver->shouldReceive('connection')->with('alias')->andReturn($primary); + $resolver->shouldReceive('connection')->with('secondary')->andReturn($secondary); + + Model::setConnectionResolver($resolver); + + $default = new NodeContextTestModel; + $alias = (new NodeContextTestAliasModel)->setConnection('alias'); + $otherTable = new NodeContextTestOtherTableModel; + $otherConnection = (new NodeContextTestModel)->setConnection('secondary'); + + $this->assertSame( + NodeContext::structuralIdentity($default), + NodeContext::structuralIdentity($alias), + ); + $this->assertNotSame( + NodeContext::structuralIdentity($default), + NodeContext::structuralIdentity($otherTable), + ); + $this->assertNotSame( + NodeContext::structuralIdentity($default), + NodeContext::structuralIdentity($otherConnection), + ); + } + + public function testStructuralIdentityFallsBackToTheResolversDefaultConnection(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->andReturn(null); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + $resolver->shouldReceive('connection')->with('primary')->andReturn($connection); + $resolver->shouldReceive('getDefaultConnection')->andReturn('primary'); + + Model::setConnectionResolver($resolver); + + $default = new NodeContextTestModel; + $explicit = (new NodeContextTestModel)->setConnection('primary'); + + $this->assertSame( + NodeContext::structuralIdentity($default), + NodeContext::structuralIdentity($explicit), + ); + } + + public function testStructuralIdentityUsesAStableMarkerWithoutAnyConnectionName(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->andReturn(null); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + $resolver->shouldReceive('getDefaultConnection')->andReturn(null); + + Model::setConnectionResolver($resolver); + + $this->assertSame( + '7:default:nodes', + NodeContext::structuralIdentity(new NodeContextTestModel), + ); + } + + public function testFreshnessIsSharedByLogicalTableAndSeparatedByConnectionAndTable(): void + { + $primary = m::mock(Connection::class); + $primary->shouldReceive('getName')->andReturn('primary'); + + $secondary = m::mock(Connection::class); + $secondary->shouldReceive('getName')->andReturn('secondary'); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($primary); + $resolver->shouldReceive('connection')->with('alias')->andReturn($primary); + $resolver->shouldReceive('connection')->with('secondary')->andReturn($secondary); + + Model::setConnectionResolver($resolver); + + NodeContext::setHasPerformed(new NodeContextTestModel); + + $this->assertTrue(NodeContext::hasPerformed((new NodeContextTestAliasModel)->setConnection('alias'))); + $this->assertFalse(NodeContext::hasPerformed(new NodeContextTestOtherTableModel)); + $this->assertFalse(NodeContext::hasPerformed( + (new NodeContextTestModel)->setConnection('secondary'), + )); + } + + public function testFreshnessIsIsolatedBetweenCoroutines(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->andReturn('primary'); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + + Model::setConnectionResolver($resolver); + + $model = new NodeContextTestModel; + + [$writer, $reader] = parallel([ + function () use ($model): bool { + NodeContext::setHasPerformed($model); + usleep(5000); + + return NodeContext::hasPerformed($model); + }, + function () use ($model): bool { + usleep(1000); + + return NodeContext::hasPerformed($model); + }, + ]); + + $this->assertTrue($writer); + $this->assertFalse($reader); + } +} + +class NodeContextTestModel extends Model +{ + protected ?string $table = 'nodes'; +} + +class NodeContextTestAliasModel extends Model +{ + protected ?string $table = 'nodes'; +} + +class NodeContextTestOtherTableModel extends Model +{ + protected ?string $table = 'other_nodes'; +} diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index 223706d66..120f72ff6 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -4,17 +4,22 @@ namespace Hypervel\Tests\NestedSet; -use BadMethodCallException; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; +use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\QueryException; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\NestedSet\Eloquent\Collection; +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\Tests\NestedSet\Models\Category; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; class NodeTest extends TestCase { @@ -50,17 +55,17 @@ public function setUp(): void protected function getMockCategories(): array { return [ - ['id' => 1, 'name' => 'store', '_lft' => 1, '_rgt' => 20, 'parent_id' => null], - ['id' => 2, 'name' => 'notebooks', '_lft' => 2, '_rgt' => 7, 'parent_id' => 1], - ['id' => 3, 'name' => 'apple', '_lft' => 3, '_rgt' => 4, 'parent_id' => 2], - ['id' => 4, 'name' => 'lenovo', '_lft' => 5, '_rgt' => 6, 'parent_id' => 2], - ['id' => 5, 'name' => 'mobile', '_lft' => 8, '_rgt' => 19, 'parent_id' => 1], - ['id' => 6, 'name' => 'nokia', '_lft' => 9, '_rgt' => 10, 'parent_id' => 5], - ['id' => 7, 'name' => 'samsung', '_lft' => 11, '_rgt' => 14, 'parent_id' => 5], - ['id' => 8, 'name' => 'galaxy', '_lft' => 12, '_rgt' => 13, 'parent_id' => 7], - ['id' => 9, 'name' => 'sony', '_lft' => 15, '_rgt' => 16, 'parent_id' => 5], - ['id' => 10, 'name' => 'lenovo', '_lft' => 17, '_rgt' => 18, 'parent_id' => 5], - ['id' => 11, 'name' => 'store_2', '_lft' => 21, '_rgt' => 22, 'parent_id' => null], + ['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], ]; } @@ -72,47 +77,17 @@ public function tearDown(): void parent::tearDown(); } - protected function assertTreeNotBroken(string $table = 'categories'): void + protected function assertTreeNotBroken(): void { - $checks = []; - $connection = DB::connection(); - $table = $connection->getQueryGrammar()->wrapTable($table); - - // Check if lft and rgt values are ok - $checks[] = "from {$table} where _lft >= _rgt or (_rgt - _lft) % 2 = 0"; - - // Check if lft and rgt values are unique - $checks[] = "from {$table} c1, {$table} c2 where c1.id <> c2.id and " - . '(c1._lft=c2._lft or c1._rgt=c2._rgt or c1._lft=c2._rgt or c1._rgt=c2._lft)'; - - // Check if parent_id is set correctly - $checks[] = "from {$table} c, {$table} p, {$table} m where c.parent_id=p.id and m.id <> p.id and m.id <> c.id and " - . '(c._lft not between p._lft and p._rgt or c._lft between m._lft and m._rgt and m._lft between p._lft and p._rgt)'; - - foreach ($checks as $i => $check) { - $checks[$i] = 'select 1 as error ' . $check; - } - - $sql = 'select max(error) as errors from (' . implode(' union ', $checks) . ') _'; - $actual = $connection->selectOne($sql); - - $this->assertEquals(null, $actual->errors, "The tree structure of {$table} is broken!"); - - $this->assertEquals( - ['errors' => null], - (array) DB::connection()->selectOne($sql), - "The tree structure of {$table} is broken!" - ); - } - - // for debugging purposes - private function dumpTree($items = null): void - { - $items = $items ?: Category::defaultOrder()->withTrashed()->get(); - - foreach ($items as $item) { - echo PHP_EOL . ($item->trashed() ? '-' : '+') . ' ' . $item->name . ' ' . $item->getKey() . ' ' . $item->getLft() . ' ' . $item->getRgt() . ' ' . $item->getParentId(); - } + $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()); } protected function assertNodeReceivesValidValues($node): void @@ -144,14 +119,14 @@ protected function testTreeNotBroken(): void protected function nodeValues($node): array { - return [$node->_lft, $node->_rgt, $node->parent_id]; + return [$node->_lft, $node->_rgt, $node->parent_id, $node->depth]; } public function testGetsNodeData(): void { $data = Category::getNodeData(3); - $this->assertEquals(['_lft' => 3, '_rgt' => 4], $data); + $this->assertEquals(['_lft' => 3, '_rgt' => 4, 'depth' => 2], $data); } public function testGetsPlainNodeData(): void @@ -161,12 +136,55 @@ public function testGetsPlainNodeData(): void $this->assertEquals([3, 4], $data); } + public function testZeroHeightGapDoesNotIssueAQuery(): void + { + DB::flushQueryLog(); + + $this->assertSame(0, Category::query()->makeGap(5, 0)); + $this->assertSame([], DB::getQueryLog()); + } + + public function testLowLevelMoveDerivesDepthWhenTheCallerOmitsIt(): void + { + $this->assertSame(0, Category::query()->depthForPosition(21)); + $this->assertSame(3, Category::query()->depthForPosition(12)); + + Category::query()->moveNode(2, 12); + + $this->assertSame(3, Category::findOrFail(2)->getDepth()); + $this->assertSame(4, Category::findOrFail(3)->getDepth()); + } + + public function testLowLevelMoveRejectsIncompleteNodeData(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Node data for [Hypervel\Tests\NestedSet\Models\Category] must contain [_lft], [_rgt], and [depth].', + ); + + Category::query()->moveNode(2, 12, nodeData: [ + '_lft' => 2, + '_rgt' => 7, + ]); + } + + public function testFirstMoveDoesNotRefreshAFreshSourceNode(): void + { + $node = Category::findOrFail(3); + $parent = Category::findOrFail(5); + DB::flushQueryLog(); + + $node->appendToNode($parent)->save(); + + $this->assertCount(3, DB::getQueryLog()); + } + public function testReceivesValidValuesWhenAppendedTo(): void { $node = new Category(['name' => 'test']); $root = Category::root(); - $accepted = [$root->_rgt, $root->_rgt + 1, $root->id]; + $accepted = [$root->_rgt, $root->_rgt + 1, $root->id, $root->depth + 1]; $root->appendNode($node); @@ -184,7 +202,7 @@ public function testReceivesValidValuesWhenPrependedTo(): void $root->prependNode($node); $this->assertTrue($node->hasMoved()); - $this->assertEquals([$root->_lft + 1, $root->_lft + 2, $root->id], $this->nodeValues($node)); + $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)); @@ -198,12 +216,29 @@ public function testReceivesValidValuesWhenInsertedAfter(): void $node->afterNode($target)->save(); $this->assertTrue($node->hasMoved()); - $this->assertEquals([$target->_rgt + 1, $target->_rgt + 2, $target->parent->id], $this->nodeValues($node)); + $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 = Category::with(['parent', 'siblings'])->findOrFail(3); + $target = Category::with(['parent', 'siblings'])->findOrFail(9); + + $this->assertTrue($node->insertAfterNode($target)); + + $storedTarget = Category::findOrFail(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'); @@ -211,7 +246,7 @@ public function testReceivesValidValuesWhenInsertedBefore(): void $node->beforeNode($target)->save(); $this->assertTrue($node->hasMoved()); - $this->assertEquals([$target->_lft, $target->_lft + 1, $target->parent->id], $this->nodeValues($node)); + $this->assertEquals([$target->_lft, $target->_lft + 1, $target->parent->id, $target->depth], $this->nodeValues($node)); $this->assertTreeNotBroken(); } @@ -239,6 +274,58 @@ public function testCategoryMovesUp(): void $this->assertNodeReceivesValidValues($node); } + #[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()); + + if ($childName !== null) { + $this->assertSame($expectedDepth + 1, $this->findCategory($childName)->getDepth()); + } + + $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 + { + $node = $this->findCategory('samsung'); + $target = Category::query() + ->select(['id', 'name', '_lft', '_rgt', 'parent_id']) + ->findOrFail(3); + + $node->insertBeforeNode($target); + + $this->assertSame(2, Category::findOrFail($node->getKey())->getDepth()); + $this->assertSame(3, $this->findCategory('galaxy')->getDepth()); + $this->assertTreeNotBroken(); + } + public function testFailsToInsertIntoChild(): void { $this->expectException(LogicException::class); @@ -261,10 +348,23 @@ public function testFailsToAppendIntoItself(): void public function testFailsToPrependIntoItself(): void { $this->expectException(LogicException::class); + $this->expectExceptionMessage('Node must not be a descendant.'); $node = $this->findCategory('notebooks'); - $node->prependTo($node)->save(); + $node->prependToNode($node)->save(); + } + + public function testStructuralTargetsMustHavePositiveStoredBounds(): void + { + $target = $this->findCategory('apple') + ->setLft(0) + ->setRgt(0); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Node must be part of a tree.'); + + (new Category(['name' => 'test']))->appendToNode($target); } public function testWithoutRootWorks(): void @@ -274,6 +374,129 @@ public function testWithoutRootWorks(): void $this->assertNotEquals('store', $result); } + 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'); @@ -407,6 +630,51 @@ public function testNodeIsSoftDeleted(): void $this->assertNotNull($this->findCategory('nokia')); } + 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 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->assertNotNull($this->findCategory('apple')); + $this->assertNotNull($this->findCategory('nokia')); + $this->assertNull($this->findCategory('samsung')); + } + public function testSoftDeletedNodeIsDeletedWhenParentIsDeleted(): void { $this->findCategory('samsung')->delete(); @@ -419,14 +687,76 @@ public function testSoftDeletedNodeIsDeletedWhenParentIsDeleted(): void $this->assertNull($this->findCategory('sony')); } + public function testEventedDescendantDeletionRunsChildrenFirstInChunks(): void + { + $deleting = []; + + EventedCategoryModel::deleting(function (EventedCategoryModel $model) use (&$deleting): void { + $deleting[] = $model->getKey(); + }); + + EventedCategoryModel::findOrFail(5)->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); + } + } + + 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(BadMethodCallException::class); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Node must be part of a tree.'); $node = new Category(['name' => 'Node']); $parent = new Category(['name' => 'Parent']); - $node->appendTo($parent)->save(); + $node->appendToNode($parent)->save(); } public function testSiblings(): void @@ -455,6 +785,155 @@ public function testSiblings(): void $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 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 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'); @@ -486,7 +965,9 @@ public function testToTreeBuildsWithCustomOrder(): void $root = $tree->first(); $this->assertEquals('mobile', $root->name); $this->assertEquals(4, count($root->children)); - $this->assertEquals($root, $root->children->first()->parent); + $this->assertNotSame($root, $root->children->first()->parent); + $this->assertSame($root->getKey(), $root->children->first()->parent->getKey()); + $this->assertSame([], $root->children->first()->parent->getRelations()); } public function testToTreeWithSpecifiedRoot(): void @@ -501,6 +982,126 @@ public function testToTreeWithSpecifiedRoot(): void $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 testToTreeBuildsWithDefaultOrderAndMultipleRootNodes(): void { $tree = Category::withoutRoot()->get()->toTree(); @@ -535,6 +1136,45 @@ public function testRetrievesPrevNode(): void $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'); @@ -563,9 +1203,11 @@ public function testDefaultCategoryIsSavedAsRoot(): void public function testExistingCategorySavedAsRoot(): void { - $node = $this->findCategory('apple'); + $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()); } @@ -590,25 +1232,79 @@ public function testNodeMovesUpSeveralPositions(): void 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->assertEquals([ - 'oddness' => 0, - 'duplicates' => 0, - 'wrong_parent' => 0, - 'missing_parent' => 0, - ], $errors); + $this->assertSame(1, $errors['duplicate_endpoints']); + $this->assertSame(0, $errors['crossing_intervals']); + } - 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', '=', 4)->update(['parent_id' => 24]); + 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->assertEquals(1, $errors['oddness']); - $this->assertEquals(2, $errors['duplicates']); - $this->assertEquals(1, $errors['missing_parent']); + $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 @@ -622,7 +1318,7 @@ public function testCreatesViaRelationship(): void { $node = $this->findCategory('apple'); - $child = $node->children()->create(['name' => 'test']); + $node->children()->create(['name' => 'test']); $this->assertTreeNotBroken(); } @@ -642,6 +1338,11 @@ public function testCreatesTree(): void $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'); @@ -659,6 +1360,9 @@ public function testDescendantsOfNonExistingNode(): void public function testWhereDescendantsOf(): void { $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage( + 'No query results for model [Hypervel\Tests\NestedSet\Models\Category] 124', + ); Category::whereDescendantOf(124)->get(); } @@ -711,6 +1415,208 @@ public function testTreeIsFixed(): void $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 testFixTreePublishesFreshnessBeforeChangingStoredBounds(): 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->expectExceptionMessage( + 'Nested set subtree for [Hypervel\Tests\NestedSet\Models\Category] with key [1] cannot be repaired because parentage crosses its stored bounds.', + ); + + Category::fixSubtree(Category::findOrFail(1)); + } + public function testSubtreeIsFixed(): void { Category::where('id', '=', 8)->update(['_lft' => 11]); @@ -782,6 +1688,22 @@ public function testDescendantsEagerlyLoaded(): void $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')]); + + $this->assertEquals( + ['apple', 'lenovo'], + $this->getAll($nodes->find(2)->descendants->pluck('name')), + ); + $this->assertEquals( + ['galaxy', 'lenovo', 'nokia', 'samsung', 'sony'], + $this->getAll($nodes->find(5)->descendants->pluck('name')), + ); + } + public function testDescendantsRelationQuery(): void { $nodes = Category::has('descendants')->whereIn('id', [2, 3])->get(); @@ -806,6 +1728,8 @@ public function testParentRelationQuery(): void public function testRebuildTree(): void { + $root = Category::findOrFail(1); + $fixed = Category::rebuildTree([ [ 'id' => 1, @@ -820,6 +1744,10 @@ public function testRebuildTree(): void $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()); @@ -848,6 +1776,92 @@ public function testRebuildSubtree(): void $this->assertEquals($node->getLft(), 12); } + public function testRebuildSubtreePersistsRowsShiftedByItsGapUpdate(): 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::rebuildSubtree(Category::findOrFail(1), [ + ['id' => 2], + ['id' => 3], + ]); + + $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 testRebuildSubtreeRejectsParentageOutsideItsStoredBounds(): 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->expectExceptionMessage( + 'Nested set subtree for [Hypervel\Tests\NestedSet\Models\Category] with key [1] cannot be repaired because parentage crosses its stored bounds.', + ); + + Category::rebuildSubtree(Category::findOrFail(1), []); + } + + public function testRebuildSubtreeRepairsAnExistingOrphanedBranch(): void + { + Category::whereKey(8)->update(['parent_id' => 99]); + + Category::rebuildSubtree($root = Category::find(7), []); + + $node = Category::find(8); + + $this->assertSame($root->getKey(), $node->getParentId()); + $this->assertSame($root->getDepth() + 1, $node->getDepth()); + $this->assertTreeNotBroken(); + } + + public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void + { + $before = Category::orderBy('id')->pluck('name', 'id')->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::rebuildTree([ + ['id' => 1, 'name' => 'updated store'], + ['id' => 11, 'name' => 'updated second store'], + ])); + $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(), + ); + } + + $this->assertSame($before, Category::orderBy('id')->pluck('name', 'id')->all()); + } + public function testRebuildTreeWithDeletion(): void { Category::rebuildTree([['name' => 'all deleted']], true); @@ -867,6 +1881,9 @@ public function testRebuildTreeWithDeletion(): void public function testRebuildFailsWithInvalidPK(): void { $this->expectException(ModelNotFoundException::class); + $this->expectExceptionMessage( + 'No query results for model [Hypervel\Tests\NestedSet\Models\Category] 24', + ); Category::rebuildTree([['id' => 24]]); } @@ -978,6 +1995,42 @@ public function testWhereHasCountQueryForAncestors(): void $this->assertEquals(['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], $categories); } + 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(), + ); + } + + public function testAncestorsAreOrderedFromRootToParent(): void + { + $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()), + ); + } + public function testReplication(): void { $category = $this->findCategory('nokia'); @@ -1001,4 +2054,85 @@ protected function getAll(array|BaseCollection $items): array { return is_array($items) ? $items : $items->all(); } + + 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; + } +} + +class CustomParentCategoryModel extends Model +{ + use HasNode; + + public bool $timestamps = false; + + protected ?string $table = 'custom_parent_categories'; + + /** + * Get the parent ID column name. + */ + public function getParentIdName(): string + { + return 'ancestor_id'; + } +} + +class EventedCategoryModel extends Category +{ + protected ?string $table = 'categories'; + + /** + * Determine whether descendant model events should be fired during deletion. + */ + protected function shouldFireDescendantEvents(): bool + { + return true; + } + + /** + * Get the descendant deletion chunk size. + */ + protected function getDescendantDeleteChunkSize(): int + { + return 2; + } +} + +class StringKeyCategoryModel extends Category +{ + public bool $incrementing = false; + + protected string $keyType = 'string'; +} + +class GloballyScopedCategoryModel extends Category +{ + protected ?string $table = 'categories'; + + /** + * Register the visibility scope. + */ + protected static function booted(): void + { + static::addGlobalScope( + 'visible', + fn (EloquentBuilder $query) => $query->where('name', '<>', 'galaxy'), + ); + } } diff --git a/tests/NestedSet/ScopedNodeTest.php b/tests/NestedSet/ScopedNodeTest.php index 22348b534..aa072f7b3 100644 --- a/tests/NestedSet/ScopedNodeTest.php +++ b/tests/NestedSet/ScopedNodeTest.php @@ -45,12 +45,12 @@ public function setUp(): void protected function getMockMenuItems(): array { return [ - ['id' => 1, 'menu_id' => 1, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'], - ['id' => 2, 'menu_id' => 1, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'], - ['id' => 5, 'menu_id' => 1, '_lft' => 4, '_rgt' => 5, 'parent_id' => 2, 'title' => 'menu item 3'], - ['id' => 3, 'menu_id' => 2, '_lft' => 1, '_rgt' => 2, 'parent_id' => null, 'title' => 'menu item 1'], - ['id' => 4, 'menu_id' => 2, '_lft' => 3, '_rgt' => 6, 'parent_id' => null, 'title' => 'menu item 2'], - ['id' => 6, 'menu_id' => 2, '_lft' => 4, '_rgt' => 5, 'parent_id' => 4, 'title' => 'menu item 3'], + ['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], ]; } @@ -65,6 +65,71 @@ public function testNotBroken(): void $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 testScalarLookupsRequireAConcreteScopeSelection(): void + { + $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()); + } + } + } + + 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 testMovingNodeNotAffectingOtherMenu(): void { $node = MenuItem::where('menu_id', '=', 1)->first(); @@ -83,6 +148,36 @@ public function testScoped(): void $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); @@ -103,6 +198,56 @@ public function testSiblings(): void $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); + $secondScopeChild = MenuItem::findOrFail(6); + + $this->assertTrue($firstScopeChild->isChildOf($firstScopeParent)); + $this->assertFalse($secondScopeChild->isChildOf($firstScopeParent)); + $this->assertFalse($firstScopeRoot->isSiblingOf($secondScopeRoot)); + $this->assertFalse($firstScopeRoot->isSelfOrDescendantOf($secondScopeRoot)); + $this->assertFalse($firstScopeRoot->isSelfOrAncestorOf($secondScopeRoot)); + } + + 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()); + } + + public function testRelationExistenceQueriesCorrelateExactScopes(): void + { + DB::table('menu_items')->insert([ + 'id' => 7, + 'menu_id' => 3, + '_lft' => 1, + '_rgt' => 2, + 'parent_id' => null, + 'title' => 'only item', + 'depth' => 0, + ]); + + $this->assertFalse(MenuItem::whereKey(7)->has('siblings')->exists()); + $this->assertFalse(MenuItem::whereKey(7)->has('ancestors')->exists()); + + $node = MenuItem::with(['siblings', 'ancestors'])->findOrFail(7); + + $this->assertTrue($node->siblings->isEmpty()); + $this->assertTrue($node->ancestors->isEmpty()); + } + public function testDescendants(): void { $node = MenuItem::find(2); @@ -150,6 +295,41 @@ public function testDepth(): void $this->assertEquals(1, $result->first()->depth); } + 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]); + $otherScope = MenuItem::scoped(['menu_id' => 2]) + ->defaultOrder() + ->get() + ->map + ->getBounds() + ->all(); + + MenuItem::scoped(['menu_id' => 1])->fixTree(); + + $this->assertTreeNotBroken(1); + $this->assertSame( + $otherScope, + MenuItem::scoped(['menu_id' => 2]) + ->defaultOrder() + ->get() + ->map + ->getBounds() + ->all(), + ); + } + public function testSaveAsRoot(): void { $node = MenuItem::find(5); @@ -172,16 +352,42 @@ public function testInsertion(): void $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); - $node = MenuItem::create(['menu_id' => 2, 'parent_id' => 5]); + 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 testDeletion(): void { - $node = MenuItem::find(2)->delete(); + MenuItem::find(2)->delete(); $node = MenuItem::find(1); @@ -198,7 +404,7 @@ public function testMoving(): void $this->assertOtherScopeNotAffected(); } - protected function assertOtherScopeNotAffected() + protected function assertOtherScopeNotAffected(): void { $node = MenuItem::find(3); From 5c3efe4e451b6e0836e676295f811efe6f317627 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:13:48 +0000 Subject: [PATCH 06/11] test(nested-set): validate every database backend Add driver-routed integration coverage for MySQL, MariaDB, PostgreSQL, and SQLite using the repository's existing database test infrastructure. Verify exact bigint, integer, UUID, and ULID parent storage; mandatory depth; scoped and unscoped index order; symmetric schema drops; and native UUID behavior where each database supports it. Exercise integer and UUID tree mutation, scope isolation, relations, soft deletion, repair, portable endpoint-window diagnostics, and persisted moved-subtree depth against real database grammars and assignment semantics. --- .../MariaDb/NestedSetDatabaseTest.php | 13 + .../Database/MySql/NestedSetDatabaseTest.php | 13 + .../Database/NestedSetDatabaseTestCase.php | 280 ++++++++++++++++++ .../Postgres/NestedSetDatabaseTest.php | 13 + .../Database/Sqlite/NestedSetDatabaseTest.php | 13 + 5 files changed, 332 insertions(+) create mode 100644 tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php create mode 100644 tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php create mode 100644 tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php create mode 100644 tests/Integration/NestedSet/Database/Postgres/NestedSetDatabaseTest.php create mode 100644 tests/Integration/NestedSet/Database/Sqlite/NestedSetDatabaseTest.php diff --git a/tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php b/tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php new file mode 100644 index 000000000..cba8c24d6 --- /dev/null +++ b/tests/Integration/NestedSet/Database/MariaDb/NestedSetDatabaseTest.php @@ -0,0 +1,13 @@ +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 testUuidTreeRepairAndSoftDeleteRemainScopeCorrect(): 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()); + + $root->delete(); + $root->restore(); + + $this->assertFalse(UuidNestedSetNode::scoped([ + 'tenant_id' => self::FIRST_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/NestedSetDatabaseTest.php new file mode 100644 index 000000000..8b2e33903 --- /dev/null +++ b/tests/Integration/NestedSet/Database/Postgres/NestedSetDatabaseTest.php @@ -0,0 +1,13 @@ + Date: Wed, 29 Jul 2026 23:13:57 +0000 Subject: [PATCH 07/11] docs(nested-set): document modern tree APIs Adopt the maintained Aimeos fork as the package reference and document typed schema helpers, mandatory stored depth, custom builders, sibling relations, scoped diagnostics, repair boundaries, and evented descendant deletion. Explain the transaction and application-serialization requirements for structural writes together with the measured read/write index tradeoff. Use generic menu scoping throughout and include one concise compound-scope example for a multi-tenant application with multiple menus per tenant without implying built-in tenancy support. Keep the guide focused on public Laravel-style usage while removing stale schema, depth, integrity, and performance guidance. --- src/boost/docs/nested-set.md | 155 ++++++++++++++++++++++++++++++----- src/nested-set/README.md | 4 +- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/boost/docs/nested-set.md b/src/boost/docs/nested-set.md index ccc758895..e074ea1de 100644 --- a/src/boost/docs/nested-set.md +++ b/src/boost/docs/nested-set.md @@ -36,7 +36,7 @@ Hypervel's nested set package provides tools for storing hierarchical data in a 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. -The package is based on Lazychaser's `laravel-nestedset` package and adapted for Hypervel's Eloquent implementation. +The package is based on Aimeos's maintained `laravel-nestedset` package and adapted for Hypervel's Eloquent implementation. ## Installation @@ -50,7 +50,7 @@ composer require hypervel/nested-set ## Database Setup -Add the nested set columns to your table using the `NestedSet::columns` helper: +Add the nested set columns to your table using the `nestedSet` Blueprint method: ```php increments('id'); + $table->id(); $table->string('name'); - NestedSet::columns($table); + $table->nestedSet(); $table->timestamps(); }); } }; ``` -The `NestedSet::columns` method adds the `_lft`, `_rgt`, and `parent_id` columns and creates an index over those columns. +The `nestedSet` method adds `_lft`, `_rgt`, `depth`, and a nullable `parent_id` matching `$table->id()`. It also creates indexes for ancestor, descendant, child, and sibling queries. -If you need to remove the nested set columns from an existing table, you may use the `NestedSet::dropColumns` helper: +Use the helper that matches your model's primary key: + +```php +$table->increments('id'); +$table->integerNestedSet(); + +$table->uuid('id')->primary(); +$table->uuidNestedSet(); + +$table->ulid('id')->primary(); +$table->ulidNestedSet(); +``` + +If you need to remove the nested set columns from an existing table, you may use the `dropNestedSet` Blueprint method: ```php Schema::table('categories', function (Blueprint $table) { - NestedSet::dropColumns($table); + $table->dropNestedSet(); }); ``` +Pass the same ordered scope columns to both methods when using scoped trees: + +```php +$table->foreignId('menu_id'); +$table->nestedSet(['menu_id']); + +// In the rollback migration: +$table->dropNestedSet(['menu_id']); +``` + ## Model Setup @@ -117,6 +139,24 @@ class Category extends Model The trait adds the nested set query builder, relationships, node movement operations, and collection helpers used throughout this document. +Custom Eloquent builders must extend the package's nested set builder: + +```php +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; +use Hypervel\NestedSet\Eloquent\QueryBuilder; + +#[UseEloquentBuilder(CategoryQueryBuilder::class)] +class Category extends Model +{ + use HasNode; +} + +class CategoryQueryBuilder extends QueryBuilder +{ + // ... +} +``` + ## Creating Nodes @@ -273,7 +313,7 @@ Hypervel will throw a `LogicException` if you try to move a node into itself or ### Relationships -The `HasNode` trait adds `parent`, `children`, `ancestors`, and `descendants` relationships: +The `HasNode` trait adds `parent`, `children`, `ancestors`, `descendants`, `siblings`, and `siblingsAndSelf` relationships: ```php $category = Category::find(1); @@ -285,12 +325,16 @@ $children = $category->children; $ancestors = $category->ancestors; $descendants = $category->descendants; + +$siblings = $category->siblings; ``` -You may eager load ancestors and descendants like any other Eloquent relationship: +You may eager load these relationships or use them in existence and count queries like any other Eloquent relationship: ```php -$categories = Category::with(['ancestors', 'descendants'])->get(); +$categories = Category::with(['ancestors', 'descendants', 'siblings']) + ->withCount('siblings') + ->get(); ``` @@ -459,7 +503,7 @@ $nodesWithParent = Category::hasParent()->get(); ### Depth -You may include each node's depth in the query result using `withDepth`: +Every node stores its depth, with root nodes at depth `0`. You may select the depth column explicitly using `withDepth`: ```php $categories = Category::withDepth() @@ -479,15 +523,17 @@ $categories = Category::withDepth('level')->get(); $categories->first()->level; ``` -The depth value is selected as a query alias. If you need to filter by depth, retrieve the results and filter the collection: +You may filter by depth directly: ```php -$topTwoLevels = Category::withDepth() +$topTwoLevels = Category::query() + ->where('depth', '<=', 2) ->defaultOrder() - ->get() - ->filter(fn (Category $category): bool => $category->depth <= 2); + ->get(); ``` +The standard nested set indexes favor ancestor, descendant, child, and sibling reads. If your application frequently filters large trees by depth, you may add an index on `['depth', '_lft']`, prefixed by any scope columns. + ### Ordering @@ -541,10 +587,13 @@ You may check a tree for structural errors using `countErrors`: $errors = Category::countErrors(); // [ -// 'oddness' => 0, -// 'duplicates' => 0, -// 'wrong_parent' => 0, +// 'invalid_intervals' => 0, +// 'duplicate_endpoints' => 0, +// 'missing_endpoints' => 0, +// 'crossing_intervals' => 0, // 'missing_parent' => 0, +// 'wrong_parent' => 0, +// 'wrong_depth' => 0, // ] ``` @@ -556,10 +605,12 @@ $totalErrors = Category::getTotalErrors(); $isBroken = Category::isBroken(); ``` +The total is useful as a broken-or-healthy signal. Since one damaged node may violate more than one invariant, it is not a unique count of damaged nodes. + ### Fixing Existing Trees -The `fixTree` method repairs `_lft` and `_rgt` values using the existing `parent_id` values: +The `fixTree` method repairs `_lft`, `_rgt`, `depth`, and invalid parentage using the existing `parent_id` values: ```php if (Category::isBroken()) { @@ -573,6 +624,16 @@ You may also fix a subtree: $fixed = Category::fixSubtree($rootNode); ``` +Repair selects only structural columns by default. If a model observer needs other attributes, pass them explicitly: + +```php +$fixed = Category::fixTree(extraColumns: ['name', 'slug']); +``` + +Nodes with missing or cyclic parents become roots. During subtree repair, they become direct children of the supplied root so they remain inside that subtree. + +Subtree repair requires the root's stored bounds to contain every child linked beneath it. If the damage crosses that boundary, repair the complete tree with `fixTree()` first. + ### Rebuilding Trees From Data @@ -623,6 +684,8 @@ Category::rebuildSubtree($rootNode, [ ]); ``` +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. + ## Scoped Trees @@ -655,6 +718,24 @@ class MenuItem extends Model } ``` +The scope attributes define the physical tree partition. Create those columns before calling the matching schema helper so they prefix each nested set index: + +```php +$table->foreignId('menu_id'); +$table->nestedSet(['menu_id']); +``` + +Scopes may contain multiple columns. For example, a multi-tenant application that stores multiple menus for each tenant may scope a tree by both values: + +```php +$table->uuid('id')->primary(); +$table->uuid('tenant_id'); +$table->uuid('menu_id'); +$table->uuidNestedSet(['tenant_id', 'menu_id']); +``` + +Return `['tenant_id', 'menu_id']` from `getScopeAttributes()` and provide both values when starting a scoped query. + When querying a scoped tree by plain IDs, start from the `scoped` query: ```php @@ -666,6 +747,16 @@ $descendants = MenuItem::scoped(['menu_id' => 1]) ->descendantsOf($nodeId); ``` +Diagnostics, whole-tree repair, and rebuild operations also require a concrete scope: + +```php +$errors = MenuItem::scoped(['menu_id' => 1])->countErrors(); + +MenuItem::scoped(['menu_id' => 1])->fixTree(); +``` + +Ordinary Eloquent global scopes only control visibility; they do not create separate nested set trees. Use `getScopeAttributes()` for menu IDs or any other value that partitions the stored boundaries. + Node operations also respect scope. Moving a node across scopes will throw a `LogicException`: ```php @@ -719,6 +810,22 @@ Force deleting a node removes the node and its descendants from the table and cl $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: + +```php +protected function shouldFireDescendantEvents(): bool +{ + return true; +} + +protected function getDescendantDeleteChunkSize(): int +{ + return 1000; +} +``` + +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 @@ -758,7 +865,7 @@ 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, and rebuilding nodes update boundary values across affected rows. If you perform several related tree changes, wrap them in a database transaction: +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 { @@ -768,4 +875,8 @@ DB::transaction(function () use ($electronics, $computers, $phones): void { }); ``` -The `NestedSet::columns` helper creates an index for the nested set columns. If you use scoped trees, add indexes for the scope columns you query by most often. +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. + +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. diff --git a/src/nested-set/README.md b/src/nested-set/README.md index 4c26f7ac5..2b7756740 100644 --- a/src/nested-set/README.md +++ b/src/nested-set/README.md @@ -1,6 +1,6 @@ NestedSet for Hypervel === -Migrated from: https://github.com/lazychaser/laravel-nestedset +Ported from: https://github.com/aimeos/laravel-nestedset -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/nested-set) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/nested-set) From a20683f45b81074397aa8bdf1f3247c007c0cc67 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:14:03 +0000 Subject: [PATCH 08/11] docs: record the completed Nested Set work Add the durable Nested Set finding ledger with the final architecture, accepted defects, upstream test correction, rejected machinery, implementation result, measured performance tradeoffs, validation, and review outcome. Route the completed parallel work alongside the active Reverb audit without replacing any Reverb dependency. Record Nested Set's Testing cleanup dependency and completed Database revalidation while deliberately leaving the package checklist open for the owner's later full-audit decision. --- ...amework-coroutine-state-lifecycle-audit.md | 7 ++-- ...-coroutine-state-lifecycle-audit-ledger.md | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 708420ed8..c8dcaa78e 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `reverb` -- **Ledger entries required for the active work:** `Preserve configuration identity across worker reloads`; `Normalize framework enum identifiers at string boundaries`; `Complete Console command, scheduling, and generator lifecycles`; `Unify HTTP response emission and harden native server boundaries`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; `Complete Horizon cluster, process, publication, and current Laravel parity`. -- **Pending revalidation carried into the active work:** `config-02`, `support-02`, `reverb-03`, `reverb-04`, `redis-10`, `redis-11`, `reverb-05`, `http-server-06`, and `reverb-06`. +- **Active package or work unit:** `reverb`; completed parallel work unit: `nested-set` +- **Ledger entries required for the active or completed parallel work:** `Preserve configuration identity across worker reloads`; `Normalize framework enum identifiers at string boundaries`; `Complete Console command, scheduling, and generator lifecycles`; `Unify HTTP response emission and harden native server boundaries`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; `Complete Horizon cluster, process, publication, and current Laravel parity`; `Complete Nested Set invariants, performance, and modern APIs`. +- **Pending revalidation carried into the active work:** `config-02`, `support-02`, `reverb-03`, `reverb-04`, `redis-10`, `redis-11`, `reverb-05`, `http-server-06`, and `reverb-06`. `nested-set-13` revalidation is complete for Testing and remains indexed for its later full audit. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1117,6 +1117,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `telescope-03` | `telescope` | `telescope` (targeted correction complete); later full `telescope` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `telescope-03` | | `fortify-01` | `fortify` | `fortify` (targeted correction complete); later full `fortify` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `fortify-01` | | `reverb-06` | `reverb` | `reverb` (targeted correction complete); later full `reverb` audit | `Complete Horizon cluster, process, publication, and current Laravel parity`; finding `reverb-06` | +| `nested-set-13` | `nested-set` | `testing` (revalidation complete); later full `testing` audit | `Complete Nested Set invariants, performance, and modern APIs`; finding `nested-set-13` | ## Package checklist diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 93260c6f6..8b19bcbb2 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1367,3 +1367,35 @@ Append package entries in checklist order. Keep each entry compact but complete - **Laravel-facing result:** Current supported Horizon commands, options, CSP/dev APIs, configuration, controllers, metrics, process behavior, locks, docs, and Boost resources are restored or preserved. Hypervel's public Queue and Horizon APIs remain Laravel-shaped; differences are limited to approved Swoole/pool/coroutine adaptations and verified upstream fixes. No useful Hypervel-specific capability was removed. - **Validation and review:** Every changed/new focused package test and live Horizon/Redis/MySQL path passed during implementation. The authoritative `composer fix` gate passed both PHPStan configurations and the complete parallel components, Testbench package, and dogfood suites. Split metadata and npm locks validate; `git diff --check`, stale-reference, direct-pipeline, installer-write, test-type, dependency, and documentation/resource scans are clean. Fresh caller/callee, signal/process, Redis ownership, publication, API, hot-path, retained-state, and overengineering review is complete. Independent review corrected final typing, confirmed the Reverb post-reap race empirically and rejected its accompanying timeout increase, and found the Horizon command readiness race. Later pull-request review surfaced the CSP defect, CI investigation surfaced the permission-test defects, and follow-up review verified the capability probes, finding allocation, and native niceness boundary. - **Assessment:** Every accepted Horizon and cross-package finding is fixed at its lowest owner. The result removes fatal Cluster paths, stale process work, non-atomic locks/publication, leaked transient state, false cleanup ownership, metadata drift, and timing-dependent test harnesses while preserving all useful Laravel and Hypervel behavior. It contains no workaround, speculative mechanism, meaningful hot-path regression, or unresolved accepted defect. + +### Complete Nested Set invariants, performance, and modern APIs + +- **Architecture and inspected risk surfaces:** Nested Set is an Eloquent tree package whose model actions stage intent, database rows own bounds/parent/depth within an optional concrete scope, structural freshness is coroutine-local per logical connection/table, and relations own operation-local matching indexes. The work covered every package source and test file; schema types and indexes; integer, UUID, ULID, and scoped trees; model/builder metadata; structural writes, deletion, repair, rebuild, and diagnostics; relations and collections; testing cleanup; all supported database drivers; and the maintained Aimeos fork as a behavior and performance reference rather than a parity contract. The detailed design and benchmark evidence are recorded in [`2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md`](2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md). + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `nested-set-01` | Schema and API improvement | Major | High | The package exposes only one integer schema shape, omits depth, and lacks scope-prefixed indexes | Add discovered Blueprint helpers for bigint, integer, UUID, and ULID parents, mandatory stored depth, symmetric drops, and scoped structural indexes | +| `nested-set-02` | Type and key defect | Major | High | Integer-only parent/root/model boundaries truncate or reject valid UUID, ULID, zero, empty-string, and numeric-string keys | Use truthful `int|string|null` model-key contracts and preserve explicit root values | +| `nested-set-03` | Coroutine lifecycle defect | Major | High | Model-class freshness and mutable restore state can miss shared-table writes or leak across nested operations | Key freshness by logical connection/table and derive exact restore state from Eloquent's previous attributes | +| `nested-set-04` | Structural query defect | Major | High | Global visibility scopes, blank scope values, stale builder construction, and unqualified columns can hide or corrupt structural work | Separate structural from user queries, preserve Eloquent builder extensions/metadata, require concrete scopes, qualify ordinary composable reads, and order compound results by their projected column | +| `nested-set-05` | Scope identity defect | Major | High | Ad hoc scope values and ambiguous bucket keys can cross scoped trees or collide under eager matching | Normalize supported scalar/enum/date/stringable values once and length-prefix ordered scope tuples | +| `nested-set-06` | Relation defect | Major | High | Sibling access is not a real relation, while ancestor/descendant existence queries duplicate scope and correlate bounds to the wrong outer table, returning wrong rows from nested `whereHas()` | Add a scope-aware `SiblingsRelation`, correct foreign keys/aliases, and correlate every relation to its immediate outer query and scope | +| `nested-set-07` | Performance defect | Major | High | Existing eager matching becomes quadratic across large parent/result sets, while Aimeos's global index retains excessive memory and is slower for scoped workloads | Use four bounded operation-local paths with scope buckets and descendant binary search, preserving query order and releasing all indexes after matching | +| `nested-set-08` | Collection and serialization defect | Major | High | Truthy root inference, recursive linking, and parent objects carrying child relations lose valid keys or create stale/cyclic graphs | Distinguish inferred from explicit roots, use iterative flat linking, clear stale relations, and share relation-free parent clones | +| `nested-set-09` | Structural mutation defect | Major | High | Moves, inserts, gap changes, and partial selects can persist wrong depth, stale bounds, stale relations, or invalid zero-height SQL | Maintain depth in the structural statement, derive omitted target depth, publish freshness before interval writes, and make zero-height gaps no-ops | +| `nested-set-10` | Deletion behavior and scalability | Major | High | Bulk deletion is scalable but cannot offer descendant events, while naïve evented deletion would recurse through package gap handling | Keep set-based deletion as the default and provide protected Laravel-shaped evented deletion hooks with keyset chunks and exact veto handling | +| `nested-set-11` | Integrity defect | Major | High | Pairwise or PHP tree checks are slow, scoped diagnostics can inspect the wrong tree, and prior categories are incomplete or misleading | Use one portable endpoint-window query, exact named categories, concrete-scope enforcement, and cheap-first brokenness checks | +| `nested-set-12` | Repair and rebuild defect | Critical | High | Orphans, cycles, vetoed saves, post-gap snapshots, or parentage outside a subtree's bounds can silently misreport success or corrupt coordinates | Repair iteratively with checked saves, exact original-snapshot reconciliation, complete-set boundary validation, maintained depth/scope, and transaction-visible exceptions | +| `nested-set-13` | Worker metadata and test lifecycle improvement | Minor | High | Repeated trait discovery wastes CPU and obsolete relation cleanup no longer resets the actual package cache | Cache trait membership per concrete class, expose `NestedSet::flushState()`, and make Testing own its one authoritative reset | +| `nested-set-14` | Metadata, documentation, and maintenance defect | Minor | High | Package dependencies, provenance, public guidance, native types, and PHPStan suppressions lag the supported design | Declare exact split metadata, name Aimeos as the maintained reference, document the final contracts/tradeoffs, and retain only identifier-scoped suppressions | +| `nested-set-15` | Upstream test defect | Minor | High | Two Aimeos tests call nonexistent methods; `Model::__call()` forwards them to the builder, so unrelated runtime errors satisfy broad exception expectations instead of testing the named guards | Call the real `prependToNode()` / `appendToNode()` APIs and assert the exact guard messages | + +- **Approved owner gates and intentional differences:** The owner approved mandatory stored depth, bigint/integer/native-UUID-or-compatible/ULID schema helpers, the three-index layout, explicit concrete scopes for structural diagnostics and scalar lookups, stored named integrity categories, bounded worker-static trait metadata, protected evented descendant deletion, and the measured write/storage cost required for much faster reads and better scope isolation. Existing useful Laravel/Eloquent-shaped APIs remain; Aimeos is the ongoing source reference but not a parity constraint. +- **Important rejected concerns:** Do not add a package lock, retry/backoff, timeout, schema-introspection cache, optional-depth fallback, default depth index, widened bounds, self-FK, arbitrary ID-type selector, scope registry, restore stack, worker-retained tree index, global result sort/re-sort, pairwise crossing join, whole-tree PHP scanner, scalar-ID encoder, unconditional movement refresh, implicit relation cache, default per-descendant events, or compatibility layer for the former schema/results. These either duplicate framework/database ownership, regress measured write or memory behavior, or address no supported failure. +- **Implementation:** Schema macros and split discovery now create typed parents, stored depth, and exact scoped indexes. Model actions, builders, relations, collections, repair/rebuild, and diagnostics use one scope/key/structural-query model; structural writes maintain bounds, parentage, and depth together. Real sibling relations, adaptive eager matching, iterative collection linking, exact restore/freshness publication, evented-deletion hooks, and bounded trait metadata replace stale or incomplete paths. Dead properties, overrides, constants, helpers, imports, comments, fixture resets, integrity categories, and obsolete suppressions are removed. +- **Cross-package revalidation:** `database-10` is complete for Nested Set: schema helpers and all structural paths use the pooled Database connection/builder lifecycle without retaining connection objects or bypassing supported query construction. `nested-set-13` updates Testing's authoritative static-state subscriber and its regression; the later full Testing audit must preserve this cleanup registration. Eloquent's existing soft-delete and custom-builder metadata remain the single owners instead of gaining package caches. +- **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes, logical connection/table freshness, custom builders, qualified/nested relation queries, sibling relations, adaptive eager matching, collection linking, every movement/deletion mode, stored depth, integrity categories, repair/rebuild boundary/veto/snapshot behavior, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scope isolation, portable diagnostics, and persisted subtree depth. +- **Performance and complexity:** Benchmarks selected operation-local scope indexes and the three database indexes over Aimeos's retained global index and the former quadratic matcher. Against the former Hypervel matcher, large eager matches fall from hundreds or thousands of milliseconds to tens. Against Aimeos's global index, the final design retains about 6 MiB instead of roughly 37–45 MiB. Representative scoped reads improve materially on every supported driver; structural index writes cost about 1.6x on MySQL and 1.3x on PostgreSQL in the measured worst cases, with MariaDB/SQLite neutral or faster. Ordinary model checks gain one bounded static lookup; no network call, lock, retry, worker-retained tree, or hidden serialization is introduced. +- **Public result:** Nested Set remains Eloquent-shaped and adds modern schema, typed-key, sibling-relation, stored-depth, repair, rebuild, and diagnostic APIs. Bulk deletion remains the scalable default. Transactions and application-owned serialization remain required for concurrent writes to one tree. Native UUID columns are supported where the database does so, including PostgreSQL and supported MariaDB versions. +- **Validation and review:** Every changed/new test file passed immediately; focused Nested Set and Testing suites, real four-driver integration routes, metadata and suppression audits, `git diff --check`, and the authoritative `composer fix` gate passed. Fresh caller/callee, scope, SQL, lifecycle, public API, documentation, hot-path, retained-memory, stale-code, and overengineering review is complete. Independent review re-read every changed source/test file and re-ran the gates. It found and corrected post-gap subtree rows omitted from persistence, wrong nested existence correlation, ambiguous joined sibling predicates, premature parent cloning during recursive creation, and compound-order handling exposed by the added regression; it also corrected the reasoning behind a rejected repair guard before signing off. +- **Assessment:** Every accepted Nested Set finding is fixed at its lowest owner. The result is scope-correct, coroutine-safe, UUID/ULID-capable, substantially faster and more memory-efficient on large scoped trees, and complete across mutation, relation, repair, and diagnostic paths. It contains no workaround, speculative abstraction, hidden I/O, meaningful unaccounted hot-path regression, unresolved accepted defect, or stale superseded path. From 7f851d8fb7277dd7a5be1b00d9b75c4b6af788bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:16:46 +0000 Subject: [PATCH 09/11] fix(nested-set): preserve scope and relation metadata Honor model-specific date formats when normalizing nested-set scope values without resolving a database connection for each eager result. Build relation existence aliases from a class-default model on the parent connection. This keeps nested aliases out of FROM clauses, avoids copying model state, and prevents read queries from dispatching replication events. Add load-bearing regressions for both paths, document the MySQL and MariaDB assignment-order invariant, type the related constants, and synchronize the implementation plan and audit ledger with the final behavior. --- ...-coroutine-state-lifecycle-audit-ledger.md | 10 +++---- ...t-invariants-performance-and-modern-api.md | 29 +++++++++++++------ src/nested-set/src/Eloquent/BaseRelation.php | 11 +++++-- src/nested-set/src/Eloquent/QueryBuilder.php | 2 ++ src/nested-set/src/HasNode.php | 6 +++- src/nested-set/src/NestedSet.php | 8 ++--- .../Database/NestedSetDatabaseTestCase.php | 4 +-- tests/NestedSet/NestedSetTest.php | 10 +++++++ tests/NestedSet/NodeTest.php | 19 ++++++++++++ 9 files changed, 75 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 8b19bcbb2..90b89fde3 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1375,11 +1375,11 @@ Append package entries in checklist order. Keep each entry compact but complete | ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | |---|---|---|---|---|---| | `nested-set-01` | Schema and API improvement | Major | High | The package exposes only one integer schema shape, omits depth, and lacks scope-prefixed indexes | Add discovered Blueprint helpers for bigint, integer, UUID, and ULID parents, mandatory stored depth, symmetric drops, and scoped structural indexes | -| `nested-set-02` | Type and key defect | Major | High | Integer-only parent/root/model boundaries truncate or reject valid UUID, ULID, zero, empty-string, and numeric-string keys | Use truthful `int|string|null` model-key contracts and preserve explicit root values | +| `nested-set-02` | Type and key defect | Major | High | Integer-only parent/root/model boundaries truncate or reject valid UUID, ULID, zero, empty-string, and numeric-string keys | Use truthful `int\|string\|null` model-key contracts and preserve explicit root values | | `nested-set-03` | Coroutine lifecycle defect | Major | High | Model-class freshness and mutable restore state can miss shared-table writes or leak across nested operations | Key freshness by logical connection/table and derive exact restore state from Eloquent's previous attributes | | `nested-set-04` | Structural query defect | Major | High | Global visibility scopes, blank scope values, stale builder construction, and unqualified columns can hide or corrupt structural work | Separate structural from user queries, preserve Eloquent builder extensions/metadata, require concrete scopes, qualify ordinary composable reads, and order compound results by their projected column | -| `nested-set-05` | Scope identity defect | Major | High | Ad hoc scope values and ambiguous bucket keys can cross scoped trees or collide under eager matching | Normalize supported scalar/enum/date/stringable values once and length-prefix ordered scope tuples | -| `nested-set-06` | Relation defect | Major | High | Sibling access is not a real relation, while ancestor/descendant existence queries duplicate scope and correlate bounds to the wrong outer table, returning wrong rows from nested `whereHas()` | Add a scope-aware `SiblingsRelation`, correct foreign keys/aliases, and correlate every relation to its immediate outer query and scope | +| `nested-set-05` | Scope identity defect | Major | High | Ad hoc scope values and ambiguous bucket keys can cross scoped trees or collide under eager matching, while hardcoded date normalization misses custom-format scope rows | Normalize supported scalar/enum/date/stringable values once, honor model date formats without connection resolution, and length-prefix ordered scope tuples | +| `nested-set-06` | Relation defect | Major | High | Sibling access is not a real relation; ancestor/descendant existence queries duplicate scope or correlate bounds to the wrong outer table; and existence aliases copy model state and fire replication events on reads | Add a scope-aware `SiblingsRelation`, correct foreign keys/aliases and immediate-outer scope correlation, and build aliases from event-free class-default models on the parent's connection | | `nested-set-07` | Performance defect | Major | High | Existing eager matching becomes quadratic across large parent/result sets, while Aimeos's global index retains excessive memory and is slower for scoped workloads | Use four bounded operation-local paths with scope buckets and descendant binary search, preserving query order and releasing all indexes after matching | | `nested-set-08` | Collection and serialization defect | Major | High | Truthy root inference, recursive linking, and parent objects carrying child relations lose valid keys or create stale/cyclic graphs | Distinguish inferred from explicit roots, use iterative flat linking, clear stale relations, and share relation-free parent clones | | `nested-set-09` | Structural mutation defect | Major | High | Moves, inserts, gap changes, and partial selects can persist wrong depth, stale bounds, stale relations, or invalid zero-height SQL | Maintain depth in the structural statement, derive omitted target depth, publish freshness before interval writes, and make zero-height gaps no-ops | @@ -1394,8 +1394,8 @@ Append package entries in checklist order. Keep each entry compact but complete - **Important rejected concerns:** Do not add a package lock, retry/backoff, timeout, schema-introspection cache, optional-depth fallback, default depth index, widened bounds, self-FK, arbitrary ID-type selector, scope registry, restore stack, worker-retained tree index, global result sort/re-sort, pairwise crossing join, whole-tree PHP scanner, scalar-ID encoder, unconditional movement refresh, implicit relation cache, default per-descendant events, or compatibility layer for the former schema/results. These either duplicate framework/database ownership, regress measured write or memory behavior, or address no supported failure. - **Implementation:** Schema macros and split discovery now create typed parents, stored depth, and exact scoped indexes. Model actions, builders, relations, collections, repair/rebuild, and diagnostics use one scope/key/structural-query model; structural writes maintain bounds, parentage, and depth together. Real sibling relations, adaptive eager matching, iterative collection linking, exact restore/freshness publication, evented-deletion hooks, and bounded trait metadata replace stale or incomplete paths. Dead properties, overrides, constants, helpers, imports, comments, fixture resets, integrity categories, and obsolete suppressions are removed. - **Cross-package revalidation:** `database-10` is complete for Nested Set: schema helpers and all structural paths use the pooled Database connection/builder lifecycle without retaining connection objects or bypassing supported query construction. `nested-set-13` updates Testing's authoritative static-state subscriber and its regression; the later full Testing audit must preserve this cleanup registration. Eloquent's existing soft-delete and custom-builder metadata remain the single owners instead of gaining package caches. -- **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes, logical connection/table freshness, custom builders, qualified/nested relation queries, sibling relations, adaptive eager matching, collection linking, every movement/deletion mode, stored depth, integrity categories, repair/rebuild boundary/veto/snapshot behavior, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scope isolation, portable diagnostics, and persisted subtree depth. +- **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes, model date-format normalization without connection resolution, logical connection/table freshness, custom builders, event-free connection-preserving existence aliases, qualified/nested relation queries, sibling relations, adaptive eager matching, collection linking, every movement/deletion mode, stored depth, integrity categories, repair/rebuild boundary/veto/snapshot behavior, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scope isolation, portable diagnostics, and persisted subtree depth. - **Performance and complexity:** Benchmarks selected operation-local scope indexes and the three database indexes over Aimeos's retained global index and the former quadratic matcher. Against the former Hypervel matcher, large eager matches fall from hundreds or thousands of milliseconds to tens. Against Aimeos's global index, the final design retains about 6 MiB instead of roughly 37–45 MiB. Representative scoped reads improve materially on every supported driver; structural index writes cost about 1.6x on MySQL and 1.3x on PostgreSQL in the measured worst cases, with MariaDB/SQLite neutral or faster. Ordinary model checks gain one bounded static lookup; no network call, lock, retry, worker-retained tree, or hidden serialization is introduced. - **Public result:** Nested Set remains Eloquent-shaped and adds modern schema, typed-key, sibling-relation, stored-depth, repair, rebuild, and diagnostic APIs. Bulk deletion remains the scalable default. Transactions and application-owned serialization remain required for concurrent writes to one tree. Native UUID columns are supported where the database does so, including PostgreSQL and supported MariaDB versions. -- **Validation and review:** Every changed/new test file passed immediately; focused Nested Set and Testing suites, real four-driver integration routes, metadata and suppression audits, `git diff --check`, and the authoritative `composer fix` gate passed. Fresh caller/callee, scope, SQL, lifecycle, public API, documentation, hot-path, retained-memory, stale-code, and overengineering review is complete. Independent review re-read every changed source/test file and re-ran the gates. It found and corrected post-gap subtree rows omitted from persistence, wrong nested existence correlation, ambiguous joined sibling predicates, premature parent cloning during recursive creation, and compound-order handling exposed by the added regression; it also corrected the reasoning behind a rejected repair guard before signing off. +- **Validation and review:** Every changed/new test file passed immediately; focused Nested Set and Testing suites, real four-driver integration routes, metadata and suppression audits, `git diff --check`, and the authoritative `composer fix` gate passed. Fresh caller/callee, scope, SQL, lifecycle, public API, documentation, hot-path, retained-memory, stale-code, and overengineering review is complete. Independent review re-read every changed source/test file and re-ran the gates. It found and corrected post-gap subtree rows omitted from persistence, wrong nested existence correlation, ambiguous joined sibling predicates, premature parent cloning during recursive creation, and compound-order handling exposed by the added regression; it also corrected the reasoning behind a rejected repair guard before signing off. Automated pull-request review later exposed custom date-format mismatch and observable replication during existence-query construction; both were corrected at their owning boundaries. - **Assessment:** Every accepted Nested Set finding is fixed at its lowest owner. The result is scope-correct, coroutine-safe, UUID/ULID-capable, substantially faster and more memory-efficient on large scoped trees, and complete across mutation, relation, repair, and diagnostic paths. It contains no workaround, speculative abstraction, hidden I/O, meaningful unaccounted hot-path regression, unresolved accepted defect, or stale superseded path. diff --git a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md index 058b7fe76..3c6c6669c 100644 --- a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md +++ b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md @@ -437,14 +437,18 @@ $value = enum_value($value); return match (true) { $value === null, is_int($value), is_string($value) => $value, is_bool($value) => (int) $value, - $value instanceof DateTimeInterface => $value->format('Y-m-d H:i:s'), + $value instanceof DateTimeInterface => $value->format( + $this->dateFormat ?: 'Y-m-d H:i:s', + ), $value instanceof Stringable => (string) $value, default => throw new LogicException(/* model and attribute */), }; ``` Place `DateTimeInterface` before `Stringable`: a future date implementation's -`__toString()` must not silently change database identity. Reject floats, +`__toString()` must not silently change database identity. Honor the model's +resolved date-format override while retaining the framework grammar default +without resolving a connection during per-result matching. Reject floats, arrays, resources, and other objects. Float text is precision/INI-dependent and unsuitable as a tree partition key; fail descriptively rather than permit silent bucket collisions or add a float encoder. @@ -540,10 +544,13 @@ descendants: a caller's `descendants()->orderBy(...)` remains authoritative. For ancestor/descendant existence queries, correlate every inner alias scope column with the corresponding outer row column. Correlated scope equality is -portable and null-safe: equal values or both null. Never bind scope from the -blank relation replica. Derive the outer qualifier from the supplied parent -query so nested `whereHas()` levels correlate against the immediately enclosing -alias rather than the outermost table. +portable and null-safe: equal values or both null. Build each existence alias +from a class-default fresh model on the parent's connection so a prior alias +cannot become the `FROM` source. Copy no attributes or relations, dispatch no +model events, and never bind scope from that blank model. Derive the outer +qualifier from the supplied parent query so nested `whereHas()` levels +correlate against the immediately enclosing alias rather than the outermost +table. `whereAncestorOf($node)` and `whereDescendantOf($node)` already own their scope predicate. Remove the duplicate trailing scope predicate from both relation @@ -827,8 +834,9 @@ Hypervel tests while preserving Hypervel-specific regressions. - lazy/eager/existence/count sibling relations, custom parent columns, configured plain foreign keys across sibling/ancestor/descendant relations, qualified relation predicates after joins, null-root correlation, null - scopes, root-to-parent ancestors, nested `whereHas()` alias correlation, and - exactly one scope predicate per relation; + scopes, root-to-parent ancestors, nested `whereHas()` alias correlation, + event-free connection-preserving existence-query construction, and exactly + one scope predicate per relation; - joined root/leaf/has-children/before/after/default-order, `withoutRoot()` / `hasParent()`, and all next/previous node and sibling queries with qualified structural columns; @@ -836,7 +844,8 @@ Hypervel tests while preserving Hypervel-specific regressions. `fixTree()`, and diagnostics, including Aimeos's global-scope fixture proving structural paths ignore visibility scopes; - scope tuple int/string/enum/date/stringable/null/empty/bool/delimiter cases, - plus descriptive float/non-stringable rejection; + model date-format overrides, plus descriptive float/non-stringable + rejection; - one-parent, one-scope, multi-scope, monotonic descendant, custom-order descendant, and ancestor eager matching; - parent serialization, relation-free clones, partial relink, recursive create, @@ -928,5 +937,7 @@ code review. - no broad overlap movement update without new evidence; - no default event-per-descendant deletion; - no iterative rewrite of already-nested rebuild input; +- no `replicateQuietly()` or `newInstance()` existence-alias construction, and + no per-result `fromDateTime()` connection resolution; - no arbitrary dynamic schema type or introspective drop helper; and - no source/test compatibility layer for the old schema or result categories. diff --git a/src/nested-set/src/Eloquent/BaseRelation.php b/src/nested-set/src/Eloquent/BaseRelation.php index 0dd53b1f1..5e3cc1787 100644 --- a/src/nested-set/src/Eloquent/BaseRelation.php +++ b/src/nested-set/src/Eloquent/BaseRelation.php @@ -53,9 +53,14 @@ abstract protected function relationExistenceCondition(string $hash, string $tab */ public function getRelationExistenceQuery(EloquentBuilder $query, EloquentBuilder $parentQuery, mixed $columns = ['*']): EloquentBuilder { - // The relation owns an isolated aliased model; Eloquent applies the - // caller's constraints to the returned builder afterward. - $query = $this->getParent()->replicate()->newQuery(); + $parent = $this->getParent(); + + // Start from the class-default table so a relation alias cannot become + // a FROM source; Eloquent applies caller constraints afterward. + $model = new ($parent::class); + $model->setConnection($parent->getConnectionName()); + + $query = $model->newQuery(); $query->select($columns); $table = $query->getModel()->getTable(); diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index 250ec5013..baa973131 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -534,6 +534,8 @@ protected function patch(array $params): array $columns = []; + // MySQL and MariaDB evaluate assignments in order, so depth must read + // the original left bound before the interval columns are updated. if (($params['depth'] ?? 0) !== 0) { $column = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ $columns[$column] = $this->depthPatch($grammar->wrap($column), $params); diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index 181bcadb0..7d28cb885 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -827,7 +827,11 @@ protected function normalizeNestedSetScopeValue(string $attribute, mixed $value) return match (true) { $value === null, is_int($value), is_string($value) => $value, is_bool($value) => (int) $value, - $value instanceof DateTimeInterface => $value->format('Y-m-d H:i:s'), + // Mirror Grammar::getDateFormat() without resolving a + // connection for each eager result. + $value instanceof DateTimeInterface => $value->format( + $this->dateFormat ?: 'Y-m-d H:i:s', + ), $value instanceof Stringable => (string) $value, default => throw new LogicException(sprintf( 'Nested set model [%s] has unsupported scope value [%s] for attribute [%s].', diff --git a/src/nested-set/src/NestedSet.php b/src/nested-set/src/NestedSet.php index 60300dcad..da4f28c57 100644 --- a/src/nested-set/src/NestedSet.php +++ b/src/nested-set/src/NestedSet.php @@ -11,22 +11,22 @@ class NestedSet /** * The name of default lft column. */ - public const LFT = '_lft'; + public const string LFT = '_lft'; /** * The name of default rgt column. */ - public const RGT = '_rgt'; + public const string RGT = '_rgt'; /** * The name of default parent id column. */ - public const PARENT_ID = 'parent_id'; + public const string PARENT_ID = 'parent_id'; /** * The name of default depth column. */ - public const DEPTH = 'depth'; + public const string DEPTH = 'depth'; /** * Cached node-trait membership by concrete class. diff --git a/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php b/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php index 310c13b17..dce52b782 100644 --- a/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php +++ b/tests/Integration/NestedSet/Database/NestedSetDatabaseTestCase.php @@ -15,9 +15,9 @@ abstract class NestedSetDatabaseTestCase extends DatabaseTestCase { - protected const FIRST_TENANT = '018f3a2b-0000-7000-8000-000000000001'; + protected const string FIRST_TENANT = '018f3a2b-0000-7000-8000-000000000001'; - protected const SECOND_TENANT = '018f3a2b-0000-7000-8000-000000000002'; + protected const string SECOND_TENANT = '018f3a2b-0000-7000-8000-000000000002'; protected function afterRefreshingDatabase(): void { diff --git a/tests/NestedSet/NestedSetTest.php b/tests/NestedSet/NestedSetTest.php index f248714c2..6f5a79384 100644 --- a/tests/NestedSet/NestedSetTest.php +++ b/tests/NestedSet/NestedSetTest.php @@ -127,6 +127,16 @@ public function testScopeValuesAreNormalizedForSqlAndBucketIdentity(): void ], $model->getNestedSetScope()); } + public function testScopeDateValuesHonorTheModelDateFormatWithoutAConnection(): void + { + $date = CarbonImmutable::parse('2026-01-02 03:04:05'); + $model = new NestedSetTestScopeNodeModel; + $model->setDateFormat('U'); + $model->setRawAttributes(['third' => $date]); + + $this->assertSame($date->format('U'), $model->getNestedSetScope()['third']); + } + public function testScopeKeysDistinguishCompositeValuesWithoutSeparatorsColliding(): void { $first = new NestedSetTestScopeNodeModel; diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index 120f72ff6..394534d05 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -1995,6 +1995,25 @@ public function testWhereHasCountQueryForAncestors(): void $this->assertEquals(['nokia', 'samsung', 'galaxy', 'sony', 'lenovo'], $categories); } + public function testExistenceQueriesRetainTheConnectionWithoutReplicatingModels(): void + { + $replicatedModels = []; + + Category::replicating(function (Category $model) use (&$replicatedModels): void { + $replicatedModels[] = $model; + }); + + $parent = (new Category)->setConnection(DB::getDefaultConnection()); + $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( From c675792d3d14322bc5664ea6e06b97588e4da5e9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:24:37 +0000 Subject: [PATCH 10/11] fix(nested-set): reject cross-tree mutation targets Structural model-target mutations previously verified only the nested-set scope, allowing coordinates loaded from another database connection or table to drive writes against the source tree. Replace that guard with the existing full tree identity so append, prepend, before, after, and deferred parent actions require the same resolved connection, table, and scope without adding a query or retained state. Strengthen the surrounding regressions at the same time: cover every direct mutation entry point, pin the canonical cross-scope error, preserve both exact restore cutoffs, prove a rebuild veto rolls back a completed parent change, and make relation connection inheritance use a distinct alias. Update the public guide and implementation plan to state the complete boundary. --- ...t-invariants-performance-and-modern-api.md | 16 +++- src/boost/docs/nested-set.md | 2 +- src/nested-set/src/HasNode.php | 14 ++-- tests/NestedSet/NodeTest.php | 74 +++++++++++++++++-- tests/NestedSet/ScopedNodeTest.php | 2 + 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md index 3c6c6669c..f3a3edbf5 100644 --- a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md +++ b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md @@ -499,6 +499,13 @@ if (($node->getLft() ?? 0) < 1 || ($node->getRgt() ?? 0) < 1) { Do not grow this into a full interval-integrity check. Positive hand-set bounds are a deliberate low-level bypass. +Direct model targets may be hand-positioned or intentionally loaded with +`withTrashed()`, but append/prepend/before/after mutations require the same +resolved connection, table, and concrete scope through `assertSameTree()`. +This deliberately rejects write coordinates from replicas or other +connections, which may lag or address another tree. Scalar parent assignment +continues to resolve an active row from the source tree. + Defer parent-ID lookup until all filled scope attributes are present at the saving action boundary. Preserve the `void` mutator and accept `int|string|null`. Parent lookup is structural and excludes trashed parents. @@ -822,7 +829,8 @@ Hypervel tests while preserving Hypervel-specific regressions. numeric string, UUID, ULID, and model; - parent fill-order, numeric request strings, UUID/ULID parents, missing and trashed parents, key `0`, and cross-scope rejection; -- per-class soft-delete metadata and re-entrant exact-timestamp restore; +- per-class soft-delete metadata and re-entrant exact-timestamp restore, + including a descendant deleted before the nested restore cutoff; - shared-table model aliases, logical connection aliases, tables, connections, coroutine isolation, and repair/rebuild freshness publication; - compatible/incompatible/default custom builders and Model cache cleanup; @@ -830,7 +838,8 @@ Hypervel tests while preserving Hypervel-specific regressions. predicates, scoped scalar and node before/after predicates, boolean grouping, structural coordinate lookups across trashed visibility modes, default/explicit logical connection aliases, and persisted 0/0 target - rejection, plus concrete-scope enforcement for every scalar scoped lookup; + rejection, plus cross-connection mutation-target rejection and + concrete-scope enforcement for every scalar scoped lookup; - lazy/eager/existence/count sibling relations, custom parent columns, configured plain foreign keys across sibling/ancestor/descendant relations, qualified relation predicates after joins, null-root correlation, null @@ -868,7 +877,8 @@ Hypervel tests while preserving Hypervel-specific regressions. complete subtree selection refusal, post-gap persistence of unchanged rows, `rebuildSubtree()` through the shared repair path, model-only roots, key exclusion, delete-missing, iterative traversal, and transaction-backed - rollback when a repair or rebuild save is vetoed; + rollback of ordinary and structural model writes when a repair or rebuild + save is vetoed; - `isNode()` class separation, trait-of-trait detection, non-object/null handling, and framework cleanup registration; and - Blueprint macro use in separate test methods, proving flush/re-registration. diff --git a/src/boost/docs/nested-set.md b/src/boost/docs/nested-set.md index e074ea1de..0e90c5f16 100644 --- a/src/boost/docs/nested-set.md +++ b/src/boost/docs/nested-set.md @@ -757,7 +757,7 @@ MenuItem::scoped(['menu_id' => 1])->fixTree(); Ordinary Eloquent global scopes only control visibility; they do not create separate nested set trees. Use `getScopeAttributes()` for menu IDs or any other value that partitions the stored boundaries. -Node operations also respect scope. Moving a node across scopes will throw a `LogicException`: +Node operations also respect the database connection, table, and scope. Moving a node between trees will throw a `LogicException`: ```php $source = MenuItem::scoped(['menu_id' => 1])->first(); diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index 7d28cb885..d1e512ae4 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -168,7 +168,7 @@ protected function actionAppendToParentId(int|string $parentId): bool $this->assertNodeInTree($parent) ->assertNotDescendant($parent) - ->assertSameScope($parent); + ->assertSameTree($parent); $this->setParent($parent)->dirtyBounds(); @@ -432,7 +432,7 @@ public function appendOrPrependTo(self $parent, bool $prepend = false): static { $this->assertNodeInTree($parent) ->assertNotDescendant($parent) - ->assertSameScope($parent); + ->assertSameTree($parent); $this->setParent($parent)->dirtyBounds(); @@ -462,7 +462,7 @@ public function beforeOrAfterNode(self $node, bool $after = false): static { $this->assertNodeInTree($node) ->assertNotDescendant($node) - ->assertSameScope($node); + ->assertSameTree($node); if (! $this->isSiblingOf($node)) { $this->setParent($node->getRelationValue('parent')); @@ -1339,12 +1339,12 @@ protected function assertNodeInTree(self $node): static } /** - * Assert that a node belongs to the same concrete scope. + * Assert that a node belongs to the same nested set tree. */ - protected function assertSameScope(self $node): static + protected function assertSameTree(self $node): static { - if (! $this->isSameScope($node)) { - throw new LogicException('Nodes must be in the same scope.'); + if (! $this->isSameTree($node)) { + throw new LogicException('Nodes must be in the same tree.'); } return $this; diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index 394534d05..75b745cbc 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -367,6 +367,37 @@ public function testStructuralTargetsMustHavePositiveStoredBounds(): void (new Category(['name' => 'test']))->appendToNode($target); } + #[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->expectExceptionMessage('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 testWithoutRootWorks(): void { $result = Category::withoutRoot()->pluck('name'); @@ -648,6 +679,9 @@ public function testRestoredNodeDoesNotRetainItsPreviousDeletionTimestamp(): voi 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(); @@ -670,7 +704,8 @@ public function testReentrantRestoreUsesEachNodesExactPreviousDeletionTimestamp( Category::withTrashed()->findOrFail(5)->restore(); - $this->assertNotNull($this->findCategory('apple')); + $this->assertNull($this->findCategory('apple')); + $this->assertNotNull(Category::find(4)); $this->assertNotNull($this->findCategory('nokia')); $this->assertNull($this->findCategory('samsung')); } @@ -1828,12 +1863,17 @@ public function testRebuildSubtreeRepairsAnExistingOrphanedBranch(): void public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void { - $before = Category::orderBy('id')->pluck('name', 'id')->all(); + $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 !== 2) { + if (++$saves !== 3) { return null; } @@ -1844,8 +1884,14 @@ public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void try { DB::transaction(fn (): int => Category::rebuildTree([ - ['id' => 1, 'name' => 'updated store'], - ['id' => 11, 'name' => 'updated second store'], + [ + '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) { @@ -1859,7 +1905,14 @@ public function testRebuildTreeVetoRollsBackEarlierModelWrites(): void ); } - $this->assertSame($before, Category::orderBy('id')->pluck('name', 'id')->all()); + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); } public function testRebuildTreeWithDeletion(): void @@ -1998,12 +2051,19 @@ public function testWhereHasCountQueryForAncestors(): void public function testExistenceQueriesRetainTheConnectionWithoutReplicatingModels(): 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(DB::getDefaultConnection()); + $parent = (new Category)->setConnection('nested_set_relation'); $relation = $parent->descendants(); $query = $relation->getRelationExistenceQuery( $relation->getQuery(), diff --git a/tests/NestedSet/ScopedNodeTest.php b/tests/NestedSet/ScopedNodeTest.php index aa072f7b3..7749c2065 100644 --- a/tests/NestedSet/ScopedNodeTest.php +++ b/tests/NestedSet/ScopedNodeTest.php @@ -414,6 +414,7 @@ protected function assertOtherScopeNotAffected(): void public function testAppendingToAnotherScopeFails(): void { $this->expectException(LogicException::class); + $this->expectExceptionMessage('Nodes must be in the same tree.'); $foo = MenuItem::find(1); $bar = MenuItem::find(3); @@ -424,6 +425,7 @@ public function testAppendingToAnotherScopeFails(): void public function testInsertingBeforeAnotherScopeFails(): void { $this->expectException(LogicException::class); + $this->expectExceptionMessage('Nodes must be in the same tree.'); $foo = MenuItem::find(1); $bar = MenuItem::find(3); From b2355d72da45ea5b37301b21dd1b62b26d5c7e22 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:33:49 +0000 Subject: [PATCH 11/11] fix(nested-set): guard partial structural mutations Reload persisted nodes when structural columns were omitted so move, force-delete, and soft-delete cascades cannot calculate intervals from null state. Preserve an omitted parent depth for hand-positioned unsaved targets and let the indexed position lookup derive the correct stored depth. Reject incomplete low-level movement data and require subtree repair or rebuild roots to be persisted, keyed, scope-complete, and loaded with bounds and depth before any structural write. Keep repair and rebuild diagnostics distinct and name missing scope attributes. Add regressions for partial sources and targets, present-null builder data, soft and force deletion, incomplete repair and rebuild roots, unpersisted and keyless roots, scope projection, and the inherited insertion-before fixture. Update the implementation plan and audit ledger to record the completed boundaries without adding a duplicate finding. --- ...-coroutine-state-lifecycle-audit-ledger.md | 6 +- ...t-invariants-performance-and-modern-api.md | 56 +++--- src/nested-set/src/Eloquent/QueryBuilder.php | 44 +++- src/nested-set/src/HasNode.php | 13 +- tests/NestedSet/NodeTest.php | 188 +++++++++++++++++- tests/NestedSet/ScopedNodeTest.php | 34 +++- 6 files changed, 297 insertions(+), 44 deletions(-) diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 90b89fde3..c6c47d49c 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1382,19 +1382,19 @@ Append package entries in checklist order. Keep each entry compact but complete | `nested-set-06` | Relation defect | Major | High | Sibling access is not a real relation; ancestor/descendant existence queries duplicate scope or correlate bounds to the wrong outer table; and existence aliases copy model state and fire replication events on reads | Add a scope-aware `SiblingsRelation`, correct foreign keys/aliases and immediate-outer scope correlation, and build aliases from event-free class-default models on the parent's connection | | `nested-set-07` | Performance defect | Major | High | Existing eager matching becomes quadratic across large parent/result sets, while Aimeos's global index retains excessive memory and is slower for scoped workloads | Use four bounded operation-local paths with scope buckets and descendant binary search, preserving query order and releasing all indexes after matching | | `nested-set-08` | Collection and serialization defect | Major | High | Truthy root inference, recursive linking, and parent objects carrying child relations lose valid keys or create stale/cyclic graphs | Distinguish inferred from explicit roots, use iterative flat linking, clear stale relations, and share relation-free parent clones | -| `nested-set-09` | Structural mutation defect | Major | High | Moves, inserts, gap changes, and partial selects can persist wrong depth, stale bounds, stale relations, or invalid zero-height SQL | Maintain depth in the structural statement, derive omitted target depth, publish freshness before interval writes, and make zero-height gaps no-ops | +| `nested-set-09` | Structural mutation defect | Major | High | Moves, inserts, gap changes, and partial selects can persist wrong depth or intervals, stale bounds, stale relations, or invalid zero-height SQL | Maintain depth in the structural statement, derive omitted target depth, reload omitted source structural values, validate repair roots before writes, publish freshness before interval writes, and make zero-height gaps no-ops | | `nested-set-10` | Deletion behavior and scalability | Major | High | Bulk deletion is scalable but cannot offer descendant events, while naïve evented deletion would recurse through package gap handling | Keep set-based deletion as the default and provide protected Laravel-shaped evented deletion hooks with keyset chunks and exact veto handling | | `nested-set-11` | Integrity defect | Major | High | Pairwise or PHP tree checks are slow, scoped diagnostics can inspect the wrong tree, and prior categories are incomplete or misleading | Use one portable endpoint-window query, exact named categories, concrete-scope enforcement, and cheap-first brokenness checks | | `nested-set-12` | Repair and rebuild defect | Critical | High | Orphans, cycles, vetoed saves, post-gap snapshots, or parentage outside a subtree's bounds can silently misreport success or corrupt coordinates | Repair iteratively with checked saves, exact original-snapshot reconciliation, complete-set boundary validation, maintained depth/scope, and transaction-visible exceptions | | `nested-set-13` | Worker metadata and test lifecycle improvement | Minor | High | Repeated trait discovery wastes CPU and obsolete relation cleanup no longer resets the actual package cache | Cache trait membership per concrete class, expose `NestedSet::flushState()`, and make Testing own its one authoritative reset | | `nested-set-14` | Metadata, documentation, and maintenance defect | Minor | High | Package dependencies, provenance, public guidance, native types, and PHPStan suppressions lag the supported design | Declare exact split metadata, name Aimeos as the maintained reference, document the final contracts/tradeoffs, and retain only identifier-scoped suppressions | -| `nested-set-15` | Upstream test defect | Minor | High | Two Aimeos tests call nonexistent methods; `Model::__call()` forwards them to the builder, so unrelated runtime errors satisfy broad exception expectations instead of testing the named guards | Call the real `prependToNode()` / `appendToNode()` APIs and assert the exact guard messages | +| `nested-set-15` | Upstream test defect | Minor | High | Two Aimeos tests call nonexistent methods and another test named for insertion-before calls insertion-after; broad exception expectations hide all three mismatches | Call the real mutation API named by each test and assert the exact guard messages | - **Approved owner gates and intentional differences:** The owner approved mandatory stored depth, bigint/integer/native-UUID-or-compatible/ULID schema helpers, the three-index layout, explicit concrete scopes for structural diagnostics and scalar lookups, stored named integrity categories, bounded worker-static trait metadata, protected evented descendant deletion, and the measured write/storage cost required for much faster reads and better scope isolation. Existing useful Laravel/Eloquent-shaped APIs remain; Aimeos is the ongoing source reference but not a parity constraint. - **Important rejected concerns:** Do not add a package lock, retry/backoff, timeout, schema-introspection cache, optional-depth fallback, default depth index, widened bounds, self-FK, arbitrary ID-type selector, scope registry, restore stack, worker-retained tree index, global result sort/re-sort, pairwise crossing join, whole-tree PHP scanner, scalar-ID encoder, unconditional movement refresh, implicit relation cache, default per-descendant events, or compatibility layer for the former schema/results. These either duplicate framework/database ownership, regress measured write or memory behavior, or address no supported failure. - **Implementation:** Schema macros and split discovery now create typed parents, stored depth, and exact scoped indexes. Model actions, builders, relations, collections, repair/rebuild, and diagnostics use one scope/key/structural-query model; structural writes maintain bounds, parentage, and depth together. Real sibling relations, adaptive eager matching, iterative collection linking, exact restore/freshness publication, evented-deletion hooks, and bounded trait metadata replace stale or incomplete paths. Dead properties, overrides, constants, helpers, imports, comments, fixture resets, integrity categories, and obsolete suppressions are removed. - **Cross-package revalidation:** `database-10` is complete for Nested Set: schema helpers and all structural paths use the pooled Database connection/builder lifecycle without retaining connection objects or bypassing supported query construction. `nested-set-13` updates Testing's authoritative static-state subscriber and its regression; the later full Testing audit must preserve this cleanup registration. Eloquent's existing soft-delete and custom-builder metadata remain the single owners instead of gaining package caches. -- **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes, model date-format normalization without connection resolution, logical connection/table freshness, custom builders, event-free connection-preserving existence aliases, qualified/nested relation queries, sibling relations, adaptive eager matching, collection linking, every movement/deletion mode, stored depth, integrity categories, repair/rebuild boundary/veto/snapshot behavior, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scope isolation, portable diagnostics, and persisted subtree depth. +- **Regression tests:** Unit and SQLite/Testbench coverage proves all scalar/model key shapes, exact scopes, model date-format normalization without connection resolution, logical connection/table freshness, custom builders, event-free connection-preserving existence aliases, qualified/nested relation queries, sibling relations, adaptive eager matching, collection linking, every movement/deletion mode including partial structural projections, stored depth, integrity categories, repair/rebuild root and boundary refusal before writes, veto/snapshot behavior, trait caching, cleanup, and schema macro re-registration. Driver-routed MySQL, MariaDB, PostgreSQL, and SQLite integration coverage proves exact parent types/index order, integer and UUID trees, scope isolation, portable diagnostics, and persisted subtree depth. - **Performance and complexity:** Benchmarks selected operation-local scope indexes and the three database indexes over Aimeos's retained global index and the former quadratic matcher. Against the former Hypervel matcher, large eager matches fall from hundreds or thousands of milliseconds to tens. Against Aimeos's global index, the final design retains about 6 MiB instead of roughly 37–45 MiB. Representative scoped reads improve materially on every supported driver; structural index writes cost about 1.6x on MySQL and 1.3x on PostgreSQL in the measured worst cases, with MariaDB/SQLite neutral or faster. Ordinary model checks gain one bounded static lookup; no network call, lock, retry, worker-retained tree, or hidden serialization is introduced. - **Public result:** Nested Set remains Eloquent-shaped and adds modern schema, typed-key, sibling-relation, stored-depth, repair, rebuild, and diagnostic APIs. Bulk deletion remains the scalable default. Transactions and application-owned serialization remain required for concurrent writes to one tree. Native UUID columns are supported where the database does so, including PostgreSQL and supported MariaDB versions. - **Validation and review:** Every changed/new test file passed immediately; focused Nested Set and Testing suites, real four-driver integration routes, metadata and suppression audits, `git diff --check`, and the authoritative `composer fix` gate passed. Fresh caller/callee, scope, SQL, lifecycle, public API, documentation, hot-path, retained-memory, stale-code, and overengineering review is complete. Independent review re-read every changed source/test file and re-ran the gates. It found and corrected post-gap subtree rows omitted from persistence, wrong nested existence correlation, ambiguous joined sibling predicates, premature parent cloning during recursive creation, and compound-order handling exposed by the added regression; it also corrected the reasoning behind a rejected repair guard before signing off. Automated pull-request review later exposed custom date-format mismatch and observable replication during existence-query construction; both were corrected at their owning boundaries. diff --git a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md index f3a3edbf5..be0bf184e 100644 --- a/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md +++ b/docs/plans/2026-07-29-1711-nested-set-invariants-performance-and-modern-api.md @@ -308,9 +308,9 @@ works for scoped and trashed rows. `getNodeData()` returns named left, right, and depth values; `getPlainNodeData()` still extracts only `[left, right]` for range consumers. -Internal model actions pass an explicit target depth when the source model has -loaded it. A partial-select before/after action and direct low-level callers -may omit it; the public builder derives the persisted target depth: +Internal model actions pass an explicit target depth only when it is loaded. +Partial model targets and direct low-level callers preserve an omitted depth; +the public builder derives the persisted value: ```php public function moveNode( @@ -622,11 +622,12 @@ containing ancestors in no-op updates and widens locks. Use known source/target bounds and depth in movement SQL. Refresh the source only when the coroutine freshness marker proves another structural operation -could have made it stale. Publish freshness after that check and immediately -before the movement update; publish new-node insertion immediately before its -gap update. Refresh `insertAfterNode()`'s target after success, matching -`insertBeforeNode()`, and invalidate structural relations on refreshed or -moved models. +could have made it stale or its bounds/depth were not selected. Reject direct +builder node data with absent or null structural values. Publish freshness +after that check and immediately before the movement update; publish new-node +insertion immediately before its gap update. Refresh `insertAfterNode()`'s +target after success, matching `insertBeforeNode()`, and invalidate structural +relations on refreshed or moved models. Publication remains before the builder call when a requested movement has zero distance. That may cause one conservative later refresh, but moving it after @@ -722,12 +723,14 @@ tree in PHP or use a quadratic crossing join. `fixTree(?Model $root = null, array $extraColumns = [])` and `fixSubtree(Model $root, array $extraColumns = [])` select only structural columns plus explicit observer-required fields and use the structural builder. -Before subtree repair/rebuild writes, one `exists` query with a `whereIn` -subquery rejects a parentage edge that leaves the supplied root's stored -interval. This proves the range-selected repair set is complete; otherwise the -operation fails descriptively rather than reporting success over rows it could -not see. Rebuild performs this check before creating any temporary zero-bound -nodes. +Before subtree repair/rebuild writes, require a persisted root with a key, +loaded bounds/depth, and every concrete scope attribute. Scope errors retain +the `scoped([...])` guidance and name the absent attribute. A separate +`exists` query with a `whereIn` subquery then rejects a parentage edge that +leaves the supplied root's stored interval. This proves the range-selected +repair set is complete; otherwise the operation fails descriptively rather +than reporting success over rows it could not see. Rebuild performs both +checks before creating any temporary zero-bound nodes. Repair maintains depth with iterative traversal over a separate ordered roots list and plain non-null parent buckets. Whole-tree unresolved components become database roots; subtree unresolved components become direct children of the @@ -859,26 +862,27 @@ Hypervel tests while preserving Hypervel-specific regressions. descendant, and ancestor eager matching; - parent serialization, relation-free clones, partial relink, recursive create, multiple roots, wide/deep iterative flattening, and no cycles; -- every insertion/move direction, root/sibling/child depth, omitted public - target depth, root-position derivation, source/target truthfulness, relation - invalidation, append/prepend returning with the caller's parent `_rgt` - refreshed in memory, no redundant first-operation source refresh, - replication depth exclusion, and zero-height gap with no query; +- every insertion/move direction, root/sibling/child depth, partially selected + targets and sources through both insert and move paths, root-position + derivation, source/target truthfulness, relation invalidation, + append/prepend returning with the caller's parent `_rgt` refreshed in + memory, no redundant first-operation source refresh, replication depth + exclusion, and zero-height gap with no query; - `defaultOrder()` replacing raw and union ordering without leaving stale bindings; -- bulk and evented deletion query/chunk behavior, order, veto, soft/force - paths, independently deleted descendants, and exact restoration; +- bulk and evented deletion query/chunk behavior, order, veto, partial-source + soft/force paths, independently deleted descendants, and exact restoration; - every integrity category, scoped refusal, conditional crossing under duplicates, short-circuit behavior, and healthy empty/deep/scoped trees; replace the existing `oddness`/`duplicates` assertions with the final named category contract; - repair/rebuild depth, scopes, extra observer columns, whole-tree orphans, subtree missing/outside/null parents, cycles, healthy named diagnostics, - complete subtree selection refusal, post-gap persistence of unchanged rows, - `rebuildSubtree()` through the shared repair path, model-only roots, key - exclusion, delete-missing, iterative traversal, and transaction-backed - rollback of ordinary and structural model writes when a repair or rebuild - save is vetoed; + incomplete root and complete subtree selection refusal before writes, + post-gap persistence of unchanged rows, `rebuildSubtree()` through the + shared repair path, model-only roots, key exclusion, delete-missing, + iterative traversal, and transaction-backed rollback of ordinary and + structural model writes when a repair or rebuild save is vetoed; - `isNode()` class separation, trait-of-trait detection, non-object/null handling, and framework cleanup registration; and - Blueprint macro use in separate test methods, proving flush/re-registration. diff --git a/src/nested-set/src/Eloquent/QueryBuilder.php b/src/nested-set/src/Eloquent/QueryBuilder.php index baa973131..bbe2f8610 100644 --- a/src/nested-set/src/Eloquent/QueryBuilder.php +++ b/src/nested-set/src/Eloquent/QueryBuilder.php @@ -441,7 +441,7 @@ public function moveNode( $depthName = $this->model->getDepthName(); /* @phpstan-ignore method.notFound */ foreach ([$lftName, $rgtName, $depthName] as $column) { - if (! array_key_exists($column, $data)) { + if (! isset($data[$column])) { throw new LogicException(sprintf( 'Node data for [%s] must contain [%s], [%s], and [%s].', $this->model::class, @@ -936,21 +936,49 @@ protected function addScopeColumnComparisons( /** * Assert that every nested set scope attribute is selected. */ - protected function assertConcreteNestedSetScope(string $operation = 'diagnostics'): void - { - $attributes = $this->model->getAttributes(); + protected function assertConcreteNestedSetScope( + string $operation = 'diagnostics', + ?Model $model = null, + ): void { + $model ??= $this->model; + $attributes = $model->getAttributes(); - foreach (array_keys($this->model->getNestedSetScope()) as $attribute) { /* @phpstan-ignore method.notFound */ + foreach (array_keys($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.', + 'Nested set %s for [%s] requires a concrete scoped([...]) selection because attribute [%s] was not selected.', $operation, - $this->model::class, + $model::class, + $attribute, )); } } } + /** + * Assert that a subtree repair root has complete persisted structural data. + */ + protected function assertRepairRootIsComplete(Model $root, string $operation): void + { + $this->assertConcreteNestedSetScope($operation, $root); + + if ($root->exists + && $root->getKey() !== null + && $root->getLft() !== null /* @phpstan-ignore method.notFound */ + && $root->getRgt() !== null /* @phpstan-ignore method.notFound */ + && $root->getDepth() !== null /* @phpstan-ignore method.notFound */ + ) { + return; + } + + throw new LogicException(sprintf( + 'Nested set %s root [%s] with key [%s] must be persisted with loaded bounds and depth.', + $operation, + $root::class, + $root->getKey() ?? 'null', + )); + } + /** * Assert that stored bounds contain every parent-linked subtree node. */ @@ -1022,6 +1050,7 @@ public function fixTree(?Model $root = null, array $extraColumns = []): int if ($root === null) { $this->assertConcreteNestedSetScope('repair'); } else { + $this->assertRepairRootIsComplete($root, 'subtree repair'); $this->assertSubtreeSelectionComplete($root); } @@ -1323,6 +1352,7 @@ public function rebuildTree(array $data, bool $delete = false, ?Model $root = nu $this->assertConcreteNestedSetScope('rebuild'); } else { // Temporary rebuild nodes use zero bounds, so validate first. + $this->assertRepairRootIsComplete($root, 'subtree rebuild'); $this->assertSubtreeSelectionComplete($root); } diff --git a/src/nested-set/src/HasNode.php b/src/nested-set/src/HasNode.php index d1e512ae4..70a02d64c 100644 --- a/src/nested-set/src/HasNode.php +++ b/src/nested-set/src/HasNode.php @@ -209,7 +209,8 @@ protected function actionAppendOrPrepend(self $parent, bool $prepend = false): b { $parent->refreshNode(); $cut = $prepend ? $parent->getLft() + 1 : $parent->getRgt(); - $targetDepth = $parent->getDepth() + 1; + $parentDepth = $parent->getDepth(); + $targetDepth = $parentDepth === null ? null : $parentDepth + 1; if (! $this->insertAt($cut, $targetDepth)) { return false; @@ -249,7 +250,15 @@ protected function actionBeforeOrAfter(self $node, bool $after = false): bool */ public function refreshNode(): void { - if (! $this->exists || ! NodeContext::hasPerformed($this)) { + if (! $this->exists) { + return; + } + + if (! NodeContext::hasPerformed($this) + && $this->getLft() !== null + && $this->getRgt() !== null + && $this->getDepth() !== null + ) { return; } diff --git a/tests/NestedSet/NodeTest.php b/tests/NestedSet/NodeTest.php index 75b745cbc..681e80652 100644 --- a/tests/NestedSet/NodeTest.php +++ b/tests/NestedSet/NodeTest.php @@ -155,17 +155,23 @@ public function testLowLevelMoveDerivesDepthWhenTheCallerOmitsIt(): void $this->assertSame(4, Category::findOrFail(3)->getDepth()); } - public function testLowLevelMoveRejectsIncompleteNodeData(): void + #[DataProvider('invalidLowLevelNodeData')] + public function testLowLevelMoveRejectsInvalidNodeData(array $nodeData): void { $this->expectException(LogicException::class); $this->expectExceptionMessage( 'Node data for [Hypervel\Tests\NestedSet\Models\Category] must contain [_lft], [_rgt], and [depth].', ); - Category::query()->moveNode(2, 12, nodeData: [ - '_lft' => 2, - '_rgt' => 7, - ]); + Category::query()->moveNode(2, 12, nodeData: $nodeData); + } + + public static function invalidLowLevelNodeData(): array + { + return [ + 'missing depth' => [['_lft' => 2, '_rgt' => 7]], + 'null left bound' => [['_lft' => null, '_rgt' => 7, 'depth' => 1]], + ]; } public function testFirstMoveDoesNotRefreshAFreshSourceNode(): void @@ -326,6 +332,54 @@ public function testBeforeNodeDerivesDepthWhenTheTargetDepthWasNotSelected(): vo $this->assertTreeNotBroken(); } + public function testAppendNewNodeDerivesDepthFromHandPositionedParentWithoutLoadedDepth(): 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(); + } + + public function testAppendExistingSubtreeRefreshesParentWithoutSelectedDepth(): void + { + $node = $this->findCategory('notebooks'); + $parent = Category::query() + ->select(['id', '_lft', '_rgt', 'parent_id']) + ->findOrFail(7); + + $node->appendToNode($parent)->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 testMovingPartiallySelectedSourceRefreshesItsStructuralData(): void + { + $node = Category::query() + ->select(['id', 'parent_id']) + ->findOrFail(2); + + $node->appendToNode($this->findCategory('samsung'))->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 testFailsToInsertIntoChild(): void { $this->expectException(LogicException::class); @@ -629,6 +683,19 @@ public function testNodeIsDeletedWithDescendants(): void $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 testNodeIsSoftDeleted(): void { CarbonImmutable::setTestNow('2025-07-03 12:00:00'); @@ -661,6 +728,21 @@ public function testNodeIsSoftDeleted(): void $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 testRestoredNodeDoesNotRetainItsPreviousDeletionTimestamp(): void { $node = $this->findCategory('mobile'); @@ -1652,6 +1734,102 @@ public function testFixSubtreeRejectsParentageOutsideItsStoredBounds(): void Category::fixSubtree(Category::findOrFail(1)); } + #[DataProvider('subtreeOperations')] + public function testSubtreeOperationsRejectIncompleteRootBeforeWriting(string $operation): void + { + $operationName = $operation === 'fix' ? 'subtree repair' : 'subtree rebuild'; + $columns = ['id', 'name', '_lft', '_rgt', 'parent_id', 'depth']; + $before = DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(); + $root = Category::query() + ->select(['id', 'parent_id']) + ->findOrFail(5); + + try { + if ($operation === 'fix') { + Category::fixSubtree($root); + } else { + Category::rebuildSubtree($root, []); + } + + $this->fail('Expected the incomplete subtree root to be rejected.'); + } catch (LogicException $exception) { + $this->assertSame( + "Nested set {$operationName} root [Hypervel\\Tests\\NestedSet\\Models\\Category] with key [5] must be persisted with loaded bounds and depth.", + $exception->getMessage(), + ); + } + + $this->assertSame( + $before, + DB::table('categories') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + + public static function subtreeOperations(): array + { + return [ + 'repair' => ['fix'], + 'rebuild' => ['rebuild'], + ]; + } + + #[DataProvider('invalidRepairRoots')] + public function testFixSubtreeRejectsUnpersistedOrKeylessRootBeforeWriting(string $rootState): 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()); + $key = '5'; + } else { + $root = Category::query() + ->select(['name', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail(5); + $key = 'null'; + } + + try { + Category::fixSubtree($root); + $this->fail('Expected the invalid subtree root to be rejected.'); + } catch (LogicException $exception) { + $this->assertSame( + "Nested set subtree repair root [Hypervel\\Tests\\NestedSet\\Models\\Category] with key [{$key}] must be persisted with loaded bounds and depth.", + $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'], + 'keyless root' => ['keyless'], + ]; + } + public function testSubtreeIsFixed(): void { Category::where('id', '=', 8)->update(['_lft' => 11]); diff --git a/tests/NestedSet/ScopedNodeTest.php b/tests/NestedSet/ScopedNodeTest.php index 7749c2065..5a8e48f1a 100644 --- a/tests/NestedSet/ScopedNodeTest.php +++ b/tests/NestedSet/ScopedNodeTest.php @@ -94,6 +94,38 @@ public function testRepairAndRebuildRequireAConcreteScopeSelection(): void } } + public function testSubtreeOperationRejectsRootWithoutScopeBeforeWriting(): void + { + $columns = ['id', 'menu_id', '_lft', '_rgt', 'parent_id', 'depth', 'title']; + $before = DB::table('menu_items') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(); + $root = MenuItem::query() + ->select(['id', '_lft', '_rgt', 'parent_id', 'depth']) + ->findOrFail(2); + + try { + MenuItem::fixSubtree($root); + $this->fail('Expected the incomplete subtree scope to be rejected.'); + } catch (LogicException $exception) { + $this->assertSame( + 'Nested set subtree repair for [Hypervel\Tests\NestedSet\Models\MenuItem] requires a concrete scoped([...]) selection because attribute [menu_id] was not selected.', + $exception->getMessage(), + ); + } + + $this->assertSame( + $before, + DB::table('menu_items') + ->orderBy('id') + ->get($columns) + ->map(fn (object $row): array => (array) $row) + ->all(), + ); + } + public function testScalarLookupsRequireAConcreteScopeSelection(): void { $operations = [ @@ -430,7 +462,7 @@ public function testInsertingBeforeAnotherScopeFails(): void $foo = MenuItem::find(1); $bar = MenuItem::find(3); - $foo->insertAfterNode($bar); + $foo->insertBeforeNode($bar); } public function testEagerLoadingAncestorsWithScope(): void