Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Nestedset Laravel Package

vusys/laravel-nestedset

View on GitHub
Deep Wiki
Context7
v0.24.4

Patch release: one aggregate-correctness fix.

Fixed

  • The delta-maintained AVG display column no longer drifts by one unit in the last decimal on MySQL 9. The column was written as 1.0 * sum / NULLIF(count, 0); the scale-1 multiplier gave the quotient scale 1 + div_precision_increment, which was then rounded a second time when stored into the DECIMAL(_, 4) column. The two roundings disagree at a 4th-decimal half-boundary — MySQL 8 rounded half-down (matching a fresh AVG() read) but MySQL 9 rounds half-up, so e.g. 1160/11 stored as 105.4546 while a fresh single-rounded AVG() returned 105.4545, reported as aggregate drift. The coercion multiplier is now scale 6 so the single final round into the column matches the fresh value on every backend. Weighted-avg is unchanged. Surfaced by the non-gating MySQL 9 ceiling lane. (#224)

Full Changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.3...v0.24.4

v0.24.3

Patch: three tree-correctness fixes, all surfaced by the new Runabout journey suite (seeded, shuffled, shrinking property tests for order-dependent invariants) — two TreeDiff::apply() corruptions on add/remove diffs, and a soft-delete restore() that could leave a live child under a trashed parent.

Changed

  • restore() now rejects a node whose parent is still soft-deleted, throwing the new TrashedAncestorException. The restore cascade only walks down (it brings back the anchor plus its same-stamp descendants but never restores ancestors), so restoring a mid-subtree node left a live child parented under a trashed one — the exact "live child under a trashed parent" state the insert / factory path already refuses with TrashedTargetException. The guard keeps that invariant total in both directions: restore outward-in (parent before child). Restoring from the top of a trashed subtree (parent live, or a root) is unchanged, and a trashed child under a live parent is still fine. The check is a single indexed lookup on the parent's deleted_at and runs before any write, so a rejected restore leaves the tree and its aggregates untouched. Closes #218; surfaced by the soft-delete Runabout journey.

Fixed

  • The former force-delete-after-partial-restore aggregate drift is now unreachable. Force-deleting a trashed parent that had an individually-restored live child used to leave the ancestor chain counting a destroyed row. Its precondition — a live child under a trashed parent — can no longer be constructed now that the mid-sequence restore() throws, so the downstream drift can't occur.
  • TreeDiff::apply() corrupted the tree when a diff's removed set contained both a node and one of its descendants, on a hard-delete model. TreeDiffApplier::doRemoves() loaded every removed row up front, then deleted each using stale in-memory bounds. Deleting an ancestor cascade-removes its descendants and closes their gaps, so the loop then re-deleted an already-gone descendant with stale bounds, double-closing an ancestor's rgt. Each removed row is now reloaded against the live tree immediately before its delete: rows a prior cascade already took are skipped, and survivors close their gap with current bounds. Soft-delete models were unaffected.
  • TreeDiff::apply() threw moveToSiblingPosition(): position must be in [0, N] when a diff added a node at a sibling position that a later move would fill. apply() runs its phases add → move → remove → modify, so when a parent's final children come from a mix of added and moved-in nodes, an added node's recorded position could exceed the child count present during the adds phase. The applier now clamps the add-time position to the current tail and lets the moves phase settle the final sibling order.
v0.24.2

Patch: stop a bounds-stale instance from drifting aggregates on a source-column update.

Fixed

  • Updating an aggregate source column on a held instance whose lft/rgt had gone stale banded the delta onto the wrong ancestor chain. A source-column save rewrites only the dirty column, never the structural bounds, so an instance shifted by an earlier move/append (but not refreshed) still carried its old bounds while the DB row held the current ones. The saved-hook delta pass keyed its ancestor UPDATE off those stale bounds — missing the node itself and spilling the delta onto whatever now occupied the old position, producing permanent silent drift. The delta pass now re-reads lft/rgt/depth from the database before banding (locking the row when a transaction is open), matching the discipline already used on the delete, move and restore paths. Found by the scheduled aggregate stale-instance fuzzer.

Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.1...v0.24.2

v0.24.1

Patch: fail loud on a misconfigured NodeTrait model instead of silently corrupting the tree.

Added

  • MisconfiguredNodeException, plus a [@phpstan-require-implements](https://github.com/phpstan-require-implements) MaintainsTreeAggregates constraint on NodeTrait. A model that composes the trait but omits implements MaintainsTreeAggregates is now caught both statically (composer analyse) and at runtime on the first save().

Fixed

  • A NodeTrait model missing implements MaintainsTreeAggregates used to insert rows with lft = rgt = 0 and no error. Every lifecycle listener gated on that interface, so saveAsRoot() / appendToNode()->save() placed nothing while appearing to succeed (bulkInsertTree() masked it by writing the bounds attributes directly). The saving listener now throws MisconfiguredNodeException instead of silently producing an invalid_bounds row.

Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.24.0...v0.24.1

v0.24.0

Audit follow-ups on v0.23.0: cross-backend correctness (reserved-word columns, MariaDB fresh-reads, MySQL/MariaDB index-name limits), soft-delete structural semantics, concurrency hardening, and new coherence fuzzers. Validated across SQLite, MySQL, MariaDB and PostgreSQL.

Added

  • Secondary [scope…, parent_id] index in the nestedSet() Blueprint macro (decision #6) — serves children(), whereIsRoot() and fixTree()'s parent walk on PostgreSQL/SQLite (MySQL got one free off its FK). Dropped by dropNestedSet().
  • TrashedTargetException — placing a live node relative to a soft-deleted anchor now throws.
  • Materialised-path coherence fuzzer and a stale-instance pool in the aggregate fuzzer.

Changed

  • append/prepend/insertBefore/insertAfterNode onto a soft-deleted target now throw TrashedTargetException (was silently allowed).
  • reorderChildrenBy() now reasons over the raw sibling set (live + trashed), matching reorderChildren().
  • nestedSet() index names bounded to 64 chars (MySQL/MariaDB cap) via boundedIndexName(); existing schemas keep Laravel's native name.
  • TreeDiff orders flat-snapshot siblings by lft (not input order), eliminating phantom Moved entries.

Fixed

  • Structural columns grammar-quoted in the mutation engine, repair path, and even_bounds_width check — reserved-word column names (left/order) no longer break mutations/repair on PG/MySQL/MariaDB.
  • MariaDB: withFreshAggregates() + ->limit()/->offset() no longer hard-errors (1235).
  • Soft-delete cascade now works under the immutable_datetime cast (was silently no-opping).
  • Subtree materialised-path rewrite: forward-order scope binding + mb_strlen() offset (multibyte-safe).
  • Soft-delete deleting hook re-reads bounds under FOR UPDATE.

Documentation

  • PostgreSQL READ COMMITTED recompute window + crossing-move deadlock-ordering documented.
  • Force-delete-after-restore aggregate drift limitation pinned + workaround documented.

Full changelog: https://github.com/vusys/laravel-nestedset/compare/v0.23.0...v0.24.0

v0.23.0

A correctness, concurrency and pre-1.0 hardening pass (#202). Validated across SQLite and MySQL — full suite + every seeded fuzzer.

⚠️ Breaking changes (pre-1.0)

  • moveToSiblingPosition() is now 0-based (was 1-based) — matches moveTo(), TreeDiff, and the factory.
  • Path exceptions renamed to the Exception suffix: DuplicatePathSegment, EmptyPathSegment, InvalidPathSegment, NonDeterministicPathSegment, PathTooLong*Exception.
  • toTree() / toFlatTree() now return a forest on partial fetches (parent-absent nodes become top-level) instead of silently dropping disconnected nodes.
  • Chaining two placement calls before save() throws instead of dropping the first; changing a scope column on an existing node throws ScopeViolationException.
  • Cloning into a colliding materialised path throws DuplicatePathSegmentException (was a silent duplicate).
  • Inspection predicates return false on never-placed nodes instead of throwing; freshAggregate() now returns a value cast to the column type.

Added

  • NestedSetException marker interface on every package exception.
  • countErrors() gains overlapping_bounds and even_bounds_width checks.
  • Scoped root seeding for bulkInsertTree() / fromJsonTree(), and working includeKeys (explicit primary keys).
  • PostgreSQL advisory lock for concurrent empty-scope makeRoot(); fork-based move/reorder/makeRoot concurrency tests.

Fixed

  • Per-model column-name overrides honoured across the read layer; scoped children() eager-loading.
  • Stale in-memory aggregate state on move/delete/restore (silent drift); filter-predicate cast asymmetry; JsonAgg order-insensitive drift detection.
  • Concurrent moves lock the mover's own bounds (FOR UPDATE); reorderChildren() reads inside the locked transaction; deferred maintenance repairs from the tree root; anchored fixTree() rebuilds paths against fresh bounds.

Full detail in CHANGELOG.md.

v0.22.0

Fixed

  • Soft-delete cascade marker format diverged from the anchor row (anchor …12:00:00 vs descendants …12:00:00.000000), so restoring an outer ancestor could restore part of an interleaved cascade and strand a trashed node under a live parent (diverging across backends). The cascade now stamps descendants with the anchor's exact seconds-precision value.
  • Anchored fixTree() hung / OOM'd on parent_id cycles — the documented cycle-recovery tool now terminates (visited-set guards in the subtree walk).
  • MIN/MAX aggregates drifted on NULL ↔ value source updates and listener-contribution transitions (the SUM-style NULL → 0 coercion leaked into the MIN/MAX update paths).
  • Stale in-memory values on the move path: insertAfterNode/insertBeforeNode could stamp a stale parent_id, and the before-move aggregate hook used stale bounds. Both now read fresh.
  • A saving/creating listener returning false committed structural SQL (gap/move) without the row write; the cancel now rolls back.
  • delete()/forceDelete() were not wrapped in the auto-transaction, so a throw mid-pipeline left a permanent hole — now wrapped like save().
  • Hard-deleting an unplaced row shifted every placed row in scope; saveQuietly() silently dropped queued placements — both now guarded.
  • bulkInsertTree() trusted the anchor's stale in-memory bounds — now reads + locks the anchor row inside its transaction.
  • withDeferredAggregateMaintenance() swallowed repair failures on the success path — the failure now propagates.
  • TreeDiff::apply() discarded recorded sibling positions and deleted retained children before moving them; fromJsonTree() returned the wrong nodes (DFS pre-order slice bug).
  • isDescendantOf()/isAncestorOf() ignored scope (cross-tree false positives); relations had no ORDER BY; #[NestedSetScope] was not inherited by subclasses; withDepth()/whereIsLeaf() didn't wrap identifiers; JSON-import collision keys dropped UUID/string PKs.

Added

  • countErrors() detects three more corruption categories: parent_bounds_mismatch, depth_mismatch, bounds_out_of_range.
  • fixTree($anchor) rejects an unplaced anchor and reports the rows it actually walked in TreeFixResult::nodesUpdated.
  • Documentation for the Tree-diff subsystem.

Removed

  • Dead bitwise-delta maintenance paths (all bitwise aggregates already maintain via chain recompute); the docs now reflect this.
v0.21.0

v0.21.0 — Top-K, lazy and listener-hardened aggregates, change-feed event

Pre-1.0 release: four new aggregate-subsystem features land plus correctness fixes and a small breaking-change polish pass ahead of 1.0.

Features

  • Top-K aggregate (#165). New topK aggregate kind stores the K rows with the largest by value anywhere in a node's subtree as a JSON array of [source_value, by_value] pairs.

    #[NestedSetAggregate(column: 'top_revenue_products', topK: 'product_id', k: 5, by: 'revenue')]
    

    Composes with filter / filterNotNull / filterRaw and is available inside withFreshAggregates() for ad-hoc reads. Recompute-only — a single deletion can promote a row the stored list never tracked, so no signed delta exists; every contributing mutation triggers a full subtree recompute over the ancestor chain (same path the collection aggregates distinctCount / stringAgg / jsonAgg / jsonObjectAgg already use). Per-backend dispatch — PG JSON_AGG(JSON_BUILD_ARRAY(_src, _by) ORDER BY _by DESC, _src DESC), MySQL/MariaDB JSON_ARRAYAGG(JSON_ARRAY(_src, _by)) over an ORDER BY ... LIMIT k derived table, SQLite the same with JSON_GROUP_ARRAY. Tie-break by source DESC and NULL-by exclusion keep results deterministic across all four. Migration via $table->nestedSetAggregate('top_revenue_products', type: 'top_k') — nullable jsonb on PG, JSON on MySQL/MariaDB, TEXT on SQLite. K must be a compile-time constant; the stored list holds exactly K entries with no runner-up safeguard (fine for the recompute-only shape).

  • Lazy aggregates with TTL (#167). New lazy maintenance shape for precalculated aggregate columns. Mutations invalidate (value = NULL, <column>_computed_at = NULL) on every affected ancestor instead of recomputing eagerly; the first read past the invalidation runs freshAggregate() and stamps the companion. Optional ttl (seconds) sets a wall-clock freshness window — useful when listener aggregates have expensive PHP contributions and reads are rarer than mutations.

    #[NestedSetAggregate(column: 'revenue_total', sum: 'amount', lazy: true, ttl: 60)]
    #[NestedSetAggregateListener(column: 'boosted', listener: Boost::class, lazy: true)]
    
    Aggregate::sum('amount')->lazy(60)->into('revenue_total');
    ListenerAggregate::sum(Boost::class)->lazy()->into('boosted');
    
    $table->nestedSetAggregate('revenue_total', lazy: true);
    

    Wired into every lifecycle hook — captureAggregateDeltas / applyAggregateDeltas, applyAggregateOnCreate, applyAggregateOnDelete, applyAggregateBeforeMove / applyAggregateAfterMove, applyAggregateOnRestore — lazy defs short-circuit the eager paths and route through one LazyInvalidation::apply() UPDATE per save. Read accessor getAttribute() override checks the stamp companion, computes via freshAggregate() on miss, and writes value + NOW() back; a re-entry guard prevents recursion through listener contribution() callbacks. fixAggregates() closes with a stamp pass over the repaired subset so post-fix reads don't immediately re-recompute. Registry skips lazy candidates when picking companions for companion-derived display kinds (Avg / Variance / Stddev / WeightedAvg / Bool* / GeoMean / HarmMean) — a lazy SUM sharing a source with an AVG can't be silently adopted as its numerator. Validation throws at definition-build time for lazy: true on companion-derived display functions, fresh-read-only kinds (Median / Percentile), internal auto-promoted companions, ttl without lazy, and ttl <= 0. Known race: a read → mutation → write-back window can re-stamp a stale value; the next mutation or TTL expiry re-stales (the simpler shape was preferred over row-locking on every refresh).

  • Aggregate change-feed event (#166). New opt-in NestedSetAggregateChanged event fires once for every (ancestor row, aggregate column) pair whose stored value moved during a maintenance pass — lets consumers mirror aggregate values to Redis / Kafka / Reverb / search indexes without polling.

    final readonly class NestedSetAggregateChanged
    {
        public function __construct(
            public string $modelClass,
            public int|string $nodeId,
            public string $column,
            public int|float|bool|string|null $oldValue,
            public int|float|bool|string|null $newValue,
            public array $ancestorChain,
            public string $stage,  // on_create | on_update | on_delete | move | on_restore
        ) {}
    }
    

    Opt-in by listener presence — the firing site short-circuits via EventDispatcher::hasListeners() when nobody is subscribed, so the package's hot path stays at its existing cost. When a listener is attached, each maintenance pass issues one extra SELECT over the targeted ancestor chain before and after the UPDATE to capture old/new values. Wired into all five aggregate-maintenance entrypoints: on_create, on_update (source-column changes), on_delete, move (separate passes for old + new chain — ancestorChain carries the relevant chain per event), and on_restore. Internal companion columns (__sum / __count auto-promotions behind AVG, Variance, WeightedAvg, etc.) are excluded — only user-declared aggregate columns produce events.

  • Listener aggregate hardening (#170). Three independent improvements to the #[NestedSetAggregateListener] surface, shipped together because the docs rewrite spans all three.

    • fixAggregates() is now O(N), not O(N²), for listener columns. Replaces the outer × inner containment scan in ListenerMaintenance::fixListenerAggregatesPhp() and aggregateErrorsForListeners() with a single stack-based DFS over a lft-sorted node list, one pass per definition. New ListenerAccumulator holds per-operation running state. Repair time on million-row forests drops from quadratic to linear.
    • filter: / filterNotNull: on the listener attribute. Mirrors the SQL aggregate parameter shape; the fluent ListenerAggregate builder gains the matching chained methods. Filter watch columns join the listener's own watchColumns() so filter-column mutations re-trigger maintenance. Auto-promoted companions inherit the parent filter. filterRaw: is deliberately omitted — listener mode has no SQL evaluation path.
    • Listener Variance, Stddev, GeometricMean, HarmonicMean. ListenerAggregateDefinition gains a sourceTransform field; registry auto-promotion creates companion definitions with the right transform per CompanionSpec (Square for __sum_sq, Ln for __sum_log / __count, Recip for __sum_recip / __count). Display values are computed in PHP via the same textbook formulas as the SQL form, with matching domain handling for the means.

    Documentation updated accordingly — docs/aggregates/listeners.md gains a Filters section and a Listener variance, stddev, geometric mean, harmonic mean section, and the fixAggregates() is O(N²) / Filters are encoded in the listener itself caveats are gone.

Fixes

  • Type-preserve MIN/MAX reads (#171). Five reads of MIN/MAX columns and their source attributes in HasNestedSetAggregates used Numeric::asIntOrZero, truncating decimal-cast values before they were fed into the recompute filter or pushed up as an extreme. A decimal(10,2) source storing 9.99 became 9; the cheap-skip WHERE stored = 9 then matched no ancestor and the recompute silently no-op'd, leaving ancestors stuck at the deleted holder's value. Sister-listener paths already used asNumericOrZero — same change applied across the delete cheap-skip, move cheap-skip, Max/Min update-path source reads, and the insert-path source capture.

  • Guard unplaced mutation targets (#171). HasTreeMutation::callPendingAction now rejects mutations whose target row exists on disk with lft = rgt = 0 (raw insert, fixture seeder, recovery from prior corruption). Previously those zero bounds fed into makeGap(0, 2) — shifting every row in the scope up by 2 and leaving the unplaced target at (2,2) with the new node at (0,1), outside its purported parent. Mirrors the existing HasSubtreeClone::guardCloneDestination check so all destination-style APIs treat unplaced targets consistently. Restricted to $target->exists so the existing "no primary key" rejection for unsaved targets stays in place.

Breaking changes

Two small surface changes from the 1.0 polish pass (#168):

  • Removed getNodeHeight() (HasNodeInspection). Was [@deprecated](https://github.com/deprecated) with the comment "Will be removed before 1.0". Callers should use getSubtreeSize().
  • moveToSiblingPosition() throws LogicException, not OutOfRangeException, for out-of-range positions. Unifies with every other sibling-mutation primitive, which already throws LogicException for the same shape of programmer error.

Docs

  • New pages for subtree cloning (docs/tree-operations/cloning.md) and lazy aggregates (docs/aggregates/lazy.md); event catalogue gains SubtreeCloned; exporters page gains a fromJsonTree() section; config reference gains the materialised_path block; small worked examples land across 15 pages via the ns-tree widget.
  • Stale exception names in the config reference (MaterialisedPathTooLongException etc.) replaced with the real class names (PathTooLong, InvalidPathSegment, DuplicatePathSegment) — users following the old docs would have hit class-not-found when catching.

Mutation testing

Cross-backend mutation gaps in the aggregates strategy selector and NestedSetScopeResolver closed via 25+ verified kills (#175). Includes a new CountArea fixture covering Count(col)->filter for both SQL and listener paths, and exception-message pinning across all 40 Concat / ConcatOperandRemoval mutants in NestedSetAggregate.php. Test-only — no src/ changes.

Compatibility

  • PHP 8.3 / 8.4 / 8.5
  • Laravel 11.0 / 12.0 / 13.0
  • sqlite / mysql / mariadb / pgsql
v0.20.0

v0.20.0 — Materialised path columns

Pre-1.0 release: an opt-in materialised-path feature lands on every NodeTrait model. A model declares one or more denormalised path columns via #[NestedSetMaterialisedPath]; the package keeps each column coherent with the tree on every mutation — insert, update, move, rename, bulk insert, subtree clone, soft-delete restore, and fixTree. Reorder leaves paths untouched by construction. No breaking changes from v0.19.x — attribute-only models that don't declare any path are unaffected; the registry returns [] and the listener short-circuits.

Features

  • Materialised path columns (#164). Each declaration names a column, a segment source (key / attribute / slug / a closure via the materialisedPaths() method form), and per-column formatting (separator, wrap, maxLength, rejectSeparatorInSegment, uniquePerParent). A single model can carry several paths at once — e.g. url_path = '/electronics/laptops/' for URLs alongside crumb_path = 'Electronics > Laptops' for breadcrumbs — each maintained independently so a rename touching one source attribute writes only the columns whose value actually changes. Querying is pure Eloquent (where('url_path', 'like', $prefix.'%')); the column is just a string.

    Defaults flow through a five-layer resolution chain — per-path explicit → #[NestedSetMaterialisedPathDefaults] class attribute → config('nestedset.materialised_path.class_defaults.'.$class) (exact FQCN, no is_a walk) → config('nestedset.materialised_path.defaults') → package fallback. MaterialisedPathRegistry::for($class) resolves and caches per FQCN; forgetCache() exists for test isolation.

    Lifecycle hooks the existing saving / saved events. Per-row INSERTs write inline; renames and subtree moves issue one subtree-rewrite UPDATE per changed column — MySQL/MariaDB CONCAT() + SUBSTRING(col, n), PostgreSQL/SQLite || + SUBSTR(col, n). (SQLite uses || because CONCAT() only shipped in 3.44; PG uses SUBSTR instead of SUBSTRING ... FROM ? because positional ? inside SUBSTRING FROM defeats PG's parameter-type inference and silently NULLs the column.) Key-dependent paths (key: true or closure with ->dependsOnKey()) on autoincrement rows wait for saved and write via one targeted UPDATE — the autoincrement key isn't known pre-INSERT.

    Six new typed exceptions — EmptyPathSegment, InvalidPathSegment, DuplicatePathSegment, PathTooLong, NonDeterministicPathSegment, MaterialisedPathConfigurationException — each carrying the column name and model class so multi-path failures are diagnosable. Per-parent uniqueness uses an indexed equality check on the declared column; collation semantics live in the segment builder (case-insensitive callers lowercase inside MaterialisedPath::from(...)). A determinism guard gated on APP_DEBUG double-calls the segment builder per save and throws if the two calls disagree — catches request() / auth() / now() in builders before the divergent values reach disk.

    Repair surface: Model::fixMaterialisedPaths(?column, ?anchor) walks parent_id in PHP and rebuilds every declared column (or just one) via one batched UPDATE per column. fixTree() runs the path rebuild as its final step so a single call repairs structure + aggregates + paths; TreeFixResult now carries a $materialisedPathsRepaired map of column => row-count.

    Bypass: Model::withoutMaterialisedPathMaintenance(Closure) short-circuits both listeners with reentrant depth counting. The supported pattern for very large bulk renames is bypass + a follow-up fixMaterialisedPaths(); no async-by-default job ships (the user can dispatch the repair to a queue themselves).

    Integration with previously-landed features: reorder (#161) leaves all declared columns bit-identical because the CASE-WHEN UPDATE bypasses Eloquent saves; tree-diff apply (#162) dispatches through normal save() / appendToNode() so path maintenance runs naturally; clone (#163) extends the $transform reserved-column rejection list to include path columns and explicitly recomputes paths over the cloned subtree after the bulk insert commits (the clone path uses Model::withoutEvents() so the saving listener never fires for cloned rows).

    53 new tests across tests/Unit/MaterialisedPath/ and tests/Feature/MaterialisedPath/ covering value-object semantics, attribute/method-form resolution, the five-layer defaults chain, registry caching, insertion, multi-path independence, move/rename subtree rewrite, validation (empty / separator-in-segment / length / duplicate), key-dependent two-phase writes, closure source, bypass + repair round-trip, clone, scoped isolation, reorder no-op, determinism guard (throw and pass paths), and the parent-relation eager-load short-circuit. New docs page at docs/tree-operations/materialised-paths.md; corruption taxonomy row at docs/maintenance/corruption.md.

Compatibility

  • PHP 8.3 / 8.4 / 8.5
  • Laravel 11.0 / 12.0 / 13.0
  • sqlite / mysql / mariadb / pgsql
v0.19.0

v0.19.0 — Sibling reorder primitive, subtree cloning, tree diff + JSON import

Pre-1.0 release: three opt-in mutation features land on every NodeTrait model — an atomic CASE-WHEN sibling-reorder primitive, a single-transaction subtree clone, and a typed tree-diff value object paired with a JSON-tree importer. No breaking changes from v0.18.2.

Features

  • Sibling reorder primitive (#161). HasTreeMutation gains a reorderChildren(array $idsInOrder) primitive so drag-and-drop UIs can re-shuffle a sibling group in a single atomic CASE-WHEN UPDATE instead of N insertAfterNode() calls. The existing lft column remains the authoritative sibling sequence — no new column, no second source of truth.

    • $parent->reorderChildren($ids) — strict ID set; missing, extra, or duplicate keys throw InvalidSiblingOrderException. Accepts IDs or models via a normaliser.
    • $parent->reorderChildrenBy(string|Closure $key) — sugar that sorts siblings by a column name or callback.
    • $child->moveToSiblingPosition(int $position) — 1-indexed, matches up() / down().
    • Model::reorderSiblings($parent, $ids) — static wrapper. New event SiblingsReordered { parent, idsInOrder, rowsAffected }.

    One CASE-WHEN UPDATE per reorder: for every row strictly between parent.lft and parent.rgt, add the containing sibling's delta (new_start − old_start) to both lft and rgt. depth and parent_id are unchanged because siblings stay at the same depth. Identity reorders are a no-op — supplying the current order skips the UPDATE entirely. Aggregates are stable by construction — reordering doesn't change ancestry, so the raw UPDATE goes through TreeMutationBuilder and the aggregate listener never fires. Scoped trees — the UPDATE predicate includes the parent's full scope tuple; other partitions are untouched. ReorderScopedTest::test_multi_scope_reorder_does_not_leak_into_other_partitions builds 4 partitions across tenant_id × site_id and asserts every other partition is bit-identical after a reorder; this closes the known .= → = scope-loop mutation-escape gap. New docs page at docs/tree-operations/reordering.md; ReorderFuzzerTest runs 1000 assertions over random permutations.

  • Subtree cloning (#163). New HasSubtreeClone concern adds cloneSubtreeTo($parent, $position, $transform, $includeTrashed), cloneSubtreeAsRoot(...), and a static Model::cloneSubtree(...) convenience. Builds the same nested-array payload bulkInsertTree already accepts — so it inherits autoincrement/UUID parent-id reconciliation, scope copy, and one deferred aggregate recompute. One transaction; rollback on any failure leaves no half-cloned state and needs no fixTree follow-up. New SubtreeCloned event (single end-of-clone signal — per-row Eloquent events are suppressed via withoutEvents) and InvalidCloneTargetException for "destination is in source's own subtree" failures.

    Column-by-column behaviour: primary key regenerated; structural columns (lft / rgt / depth / parent_id) regenerated and $transform setting them throws; scope columns inherited from destination parent (or source for asRoot) and $transform setting them throws; timestamps refreshed to now (overridable); soft-deleted rows always land live, even with includeTrashed: true; aggregate columns zeroed then filled by deferred recompute; everything else verbatim from $source->getAttributes(). Scoped-model cloneSubtreeAsRoot seeds under the source row then immediately makeRoot-s, kept inside the clone's outer transaction so atomicity holds. 26 new tests under tests/Feature/Clone/ cover shape, depth math, parent-id wiring, UUID + autoincrement keys, $transform (incl. structural-column rejection and exception propagation), includeTrashed true/false, scope inheritance + cross-scope rejection, self-clone + own-subtree rejection, aggregate recompute, replicate parity on a leaf, event emission, per-row event suppression, and transaction rollback.

  • Tree diff + JSON tree import (#162). New Vusys\NestedSet\Diff\TreeDiff value object with TreeDiff::between($before, $after) and TreeDiff::apply() — remove → add → move → modify under one transaction, deferred aggregate maintenance, cycle detection, dry-run mode, and invert(). Sibling reorder surfaces as Moved with fromParent === toParent. Paired with a fromJsonTree() static on HasTreeExport (nested + flat shape autodetection, strict/lax modes, per-row $transform, includeKeys with collision detection, aggregate columns recomputed on import). Six new exceptions — DuplicateNodeIdentity, DanglingParent, MissingParent, CyclicMove, InvalidJsonTree, JsonImportKeyCollision. 43 new tests across tests/Unit/{Diff,Import} and tests/Feature/{Diff,Import}. The HasNestedSet contract intentionally omits the trait's mutation surface (bulkInsertTree, withDeferredAggregateMaintenance, appendToNode, makeRoot); the applier and importer dispatch through is_callable([class, method]) to stay compatible with that decision. apply() currently inserts adds per-row via appendToNode — lifting that to bulkInsertTree is a follow-up.

Compatibility

  • PHP 8.3 / 8.4 / 8.5
  • Laravel 11.0 / 12.0 / 13.0
  • sqlite / mysql / mariadb / pgsql
v0.18.2

Docs-only patch release. No src/ changes — README and docs/index.md were workshopped so the headline preview is more honest about what the package does. All v0.18.0/v0.18.1 application behavior is preserved.

What changed (#160)

  • Running example switches from Category/products to BudgetItem/cost so each declared aggregate maps to a natural business question (`cost_total` = total spend, `item_count` = line items, `avg_cost` = avg line cost, `biggest_item` = max, `recurring_total` = filtered SUM). "Average product count per category" felt forced; "average cost per line item" doesn't.

  • Hand-drawn ASCII tree comment replaced with a real BudgetItem::toAsciiTreeForest(new AsciiOptions(label: ...)) call. The rendered ASCII below the call is what the exporter would actually print — readers see the API surface they'd use, not a fiction.

  • Demo tree grows from 2 to 3 levels (Engineering → People → Salaries/Bonuses, Engineering → Tools → SaaS/Hardware, Operations → Office). The update mutation now cascades through two ancestor levels (Bonuses → People → Engineering); the move re-parents a whole 3-node subtree atomically. The tree renders before AND after so the move's effect on both old and new ancestors is visually obvious.

  • Inlined node lookups as BudgetItem::query()->where('name', '=', '...')->first() at each call site, instead of conjuring undefined \$bonuses / \$tools / etc. The snippet now stands alone without imagined prior context.

  • getSubtreeSize() example replaced with getDescendantCount(). The previous example was confusing: getSubtreeSize() returns the lft/rgt slot count (rgt - lft + 1 = 2N), not the node count, so a 2-node tree returned 4. Swapped for the descendant count most readers actually want, with a comment that spells out the +1-for-self adjustment.

  • Docs link cleanup: dropped the dead vs. kalnoy/nestedset entry (docs/reference/comparison.md doesn't exist), added the Glossary page from v0.18.1, added a new Internals line linking to Architecture Overview.

  • docs/index.md ns-tree interactive widget now uses the same budget tree as the code snippet that follows it ({cost=NNN} annotations roll up automatically). The interactive widget, the prose, and the code snippet are now one consistent dataset — three lenses on the same tree.

v0.18.1

Patch release. No src/ changes — docs build improvements, a glossary page, sub-heading restructure, plus a round of CI workflow improvements and a composer infection developer-tooling script. All v0.18.0 application behavior is preserved.

Documentation

  • Auto-numbered TOC + structural sub-heading pass (#154). The docs-site build now walks <h2><h4> and prefixes section numbers (1., 1.1, 1.1.1) automatically, so every page gets a sidebar TOC for free instead of curating one per page. Adds a Glossary page covering acronyms, encoding, traversal, aggregates, and repair. Structural sub-heading pass across maintenance / exporters / walking / inserting / bulk-insertion / soft-deletes docs hoists limitations / edge-cases / pitfalls sections out of bullet lists into real H3/H4s.

Developer experience

  • Mutation testing runs locally with composer infection (#158). Infection is now a real require-dev dependency (infection/infection: ^0.33) instead of an ad-hoc workflow install. A new composer infection script runs it with --threads=max against whatever DB_CONNECTION is set, so the binary CI runs is the same binary contributors run locally against any backend. Dropping the workflow's Laravel-12 pin is the side effect of the bump — 0.33.x supports Laravel 13's symfony/console ^8, so the mutation matrix now runs against the same Laravel version as the rest of CI.

CI

  • Shard rebalance + walker coverage (#155, #156). The full Infection job's rest shard had grown past the runner's working budget; split into 7 LOC-balanced shards (concerns / query / agg-core / agg-registry / walker-export / testing / rest, each 1.3k-5.4k LOC). Includes a previously-uncovered shard for src/Walker/ added in v0.18.0.
  • Disk pressure removed (#157). Adds jlumbroso/free-disk-space to free ~25GB of unused preinstalled tooling at the top of the full job (Android SDK, .NET, Haskell). Tool-cache and large-packages are kept off — both break setup-php and are not worth their disk savings.
  • Memory pressure diagnosed and contained (#159). An in-step background [MON] sampler streams free -h / top-RSS to stdout every 5s during the infection run, so the data survives even when the runner agent is host-SIGTERMed (an if: always() post-mortem step does not — the runner-shutdown cascade marks subsequent steps as cancelled). The sampler caught a single PHP process climbing to 14.5 GiB on testing/pgsql: a runaway mutant of BuildsNestedSetTrees. Three layered fixes land together — the testing shard caps at --threads=2, (testing, pgsql) caps further at --threads=1 via a per-cell matrix override, and PHP's memory_limit=2G is enforced via setup-php's ini-values so any runaway mutant fails cleanly via PHP OutOfMemoryError (infection records it as killed-by-error — exactly the right outcome) instead of taking down the runner.
v0.18.0

v0.18.0 — In-memory subtree walker, factory tree builder, docs audit, mutation gap closure

Pre-1.0 release: two opt-in public-API features land on every NodeTrait model — an in-memory subtree walker and a Laravel-factory mixin for building real fixture trees in one call — alongside a 4-round docs/code coherence audit and a second mutation-testing gap-closure pass against the v0.17.0 union report. No breaking changes from v0.17.0.

Features

  • In-memory subtree walker (#153). HasTreeWalk adds walk() / dfs() / dfsPostOrder() / bfs() / flattenedSubtree() to every NodeTrait model — a generator-based traversal over an already-loaded subtree with no nested-Collection allocation. The walker is a pure consumer of in-memory data: with no $subtree argument it reads $this->descendants; with neither available it throws UnloadedSubtreeException (a feature test asserts DB::listen fires zero queries on walk() over a loaded relation). The implementation lives in four value objects under src/Walker/:

    • SubtreeWalker — builds byKey: id => Model and childrenByParentKey: parentId => list<id> indexes in O(N) on construction, sorts children by lft defensively so callers passing reordered collections still get deterministic walks, and uses an explicit task stack for DFS so deep trees do not blow PHP's call stack. Implements Countable; maxDepth() and leafCount() companions share a single cached index pass.
    • WalkContext — readonly value object passed as the visitor's second argument: depth (relative to the walk root, not the absolute depth column), parent, siblingIndex, siblingCount, derived isFirstSibling / isLastSibling, and a lazy pathToRoot() that walks up via the index on first call. Future affordances become new fields/methods, not new positional args.
    • WalkFilterfinal readonly (maxDepth, visitable, includeRoot) triple with depth() / where() / compose() named constructors and an andThen() instance combinator. The same filter plugs into every exporter's option object (AsciiOptions, MermaidOptions, DotOptions, JsonOptions); AsciiOptions::maxDepth composes into the filter so the existing knob and the new one prune identically. TreeExporter resolves the filter to a visible-key set once per render and applies it uniformly across all four formats.
    • WalkSignal — two-case enum (SkipSubtree, Stop) returned by visitor closures. Skip is honoured by pre-order DFS and BFS, ignored by post-order. Returning null (or void) continues.

    HasTreeRepair::fixTree (walkAssignPositions) and HasBulkInsert::bulkInsertPlan were deliberately not refactored onto the walker — their walks operate on parent_id graph maps and nested-array payloads respectively, not loaded Model collections, so they don't share SubtreeWalker's data shape. The risk on the critical maintenance path outweighed the DRY win.

  • Factory tree builder trait for tests + seeders (#152). Vusys\NestedSet\Testing\BuildsNestedSetTrees is a Laravel factory mixin that returns a real tree in one call, backed by bulkInsertTree — depth-3 branching-5 (156 nodes) costs 3 statements instead of 156. Two shapes cover the common cases:

    • tree() — uniform / per-depth-array / closure branching (tree(depth: 3, branching: 5), or branching: [3, 4, 2], or branching: fn ($depth, $parent) => ...).
    • treeFromShape() — explicit nested-array form.
    • previewTree() — returns the resolved payload without persisting (round-trips through treeFromShape).

    The per-row hook receives (depth, siblingIndex, parentAttrs) for tenant inheritance, depth-keyed weights, etc. Upfront pre-checks reject trashed parents, cross-scope grafts, and missing label columns with diagnostic messages rather than letting DB constraints fire downstream. make() is rejected on tree shapes (no persistence ⇒ no lft/rgt); afterCreating fires per-row in DFS pre-order with an opt-out for million-row seeders. Aggregates recompute correctly via the existing withDeferredAggregateMaintenance flow inside bulkInsertTree. Sequence callbacks cycle in DFS order. New docs page at docs/reference/factories.md; covered by 11 pure-logic tests over TreeBuilderShape plus 30 DB-backed feature tests across four fixture factories.

Tests

  • Close further mutation-testing gaps from the v0.17.0 union report (#151). Six follow-up gaps from the 26664389389 union run, triaged from escapedEverywhere / uncovered data and verified-killed locally by mutate → run → restore before each test was added. The source diff is a single additive extraction (FreshAggregateProjector::mysqlVersionSupportsLateral() pulled out for testability); everything else is fixtures + tests.

    • Multi-scope SQL builders — a real correctness gap. No fixture had >1 scope column, so the $sql .= " AND {$col} = ?" accumulator loops at AggregateSqlFragments:567 (correlated raw-filter read path) and AggregateDiffer:465 (chain-shape detector) iterated once and looked identical to a single assignment. New MultiScopedBranch fixture (tenant_id + site_id) pins both sites; production impact if broken would have been cross-tenant aggregate leakage on multi-scope models.
    • SQLite bitwise UDA pathBitwiseMaintenanceTest only covered the PHP-delta path, so the BIT_OR / BIT_AND / BIT_XOR PDO UDA registrations were never end-to-end exercised on SQLite. New BitwiseFreshReadTest drives withFreshAggregates and aggregateErrors against BitwiseArea, forcing SqliteBitwiseAggregates::ensureInstalled() and the three UDA folds.
    • MySQL LATERAL version-parse — CI's mysql:8.0 service only exercised one branch of the 8.0.14 cutoff (18 uncovered mutants). New pure-unit test with 20 seeded version strings (null, malformed, MariaDB family, MySQL either side of 8.0.14).
    • Exclusive listener bounds — no listener fixture was exclusive, so the strict-bounds arm of the inclusive/exclusive ternary in ListenerMaintenance was never executed. Added descendant_fire_count to Monster + tests covering fresh read, fixAggregates, and aggregateErrors. The >>= GreaterThan mutants are equivalent under the nested-set unique-lft invariant; the new tests still pin the exclusive arm's behaviour.
    • Listener unsupported-ops match arms — the match in freshListenerAggregate() and applyListenerOperation() each have 12 shared cases; only Variance + Median were covered. UnsupportedOpMonster now declares one column per unsupported op; the rewritten test drives freshAggregate() per row with arm-specific expectExceptionMessageMatches patterns, pinning MatchArmRemoval and exception-message Concat mutants on both sites.
    • ChainFoldAccumulator cast escapes(float) / (int) cast removals on string-typed numeric sources survived because every existing test fed native ints/floats. Stringy-numeric data-provider rows for sum/min/max/bit_or/bit_and/bit_xor/weighted_avg/variance/geometric_mean make Min/Max/Bit* cast removals throw TypeError via downstream strict return types. Sum/WeightedAvg cast removals remain equivalent (PHP coerces in arithmetic).

Documentation

  • Docs/code correctness & coherence audit, 4 rounds (#150). Cross-checked every claim in the README, docs/, and source-level docblocks against the code. Where they diverged, the docs won — except a few cases where the code's own docblock was wrong:

    • TreeAggregateListener::contribution() docblock said "null is treated as 0 for Sum". Value-equivalent for Sum itself, but for Count / Avg / Min / Max listeners null excludes the row entirely while 0 still counts. Rewritten; docs/aggregates/recipes.md corrected to match.
    • AggregateMaintenanceFailed::$stage docblock listed 'recompute' as a valid value, but NodeTrait::runAggregateHook() only dispatches 'capture' | 'apply' | 'on_create' | 'on_delete' | 'on_restore'. Updated and back-linked to the dispatcher.
    • docs/aggregates/quantiles.md filterRaw example used a ? placeholder + watches array. The package inlines filterRaw SQL verbatim with no parameter binding (FilterPredicate::raw) — copy-pasting that example would have produced a SQL syntax error. Replaced with a literal-value form and cross-linked to the security note in filtered.md.
    • docs/maintenance/corruption.md §7 diagnostic SQL — correct for unscoped, hard-delete-only tables but missing two adjustments: the orphan query needs scope-equality on the JOIN (otherwise a child whose parent_id matches a row in another scope joins successfully and masks the orphan — what orphanQuery() guards against internally), and the aggregate-drift query needs WHERE d.deleted_at IS NULL on the inner side for SoftDeletes tables (otherwise it over-counts trashed descendants and reports false drift).
    • docs/reference/events.md — five aggregate-maintenance event rows had inconsistent anchorId coverage in their payload columns; made it consistent across the table.
    • docs/reference/production.md — the MariaDB optimizer_switch note listed ->cursor() as covered by runSelect(). Verified against vendored Laravel: Builder::cursor() calls Connection::cursor() directly, bypassing runSelect(). Updated to list actually-covered methods (get / first / paginate / chunk — the last two go through get()), group cursor() with the bypass methods, and suggest chunk() as the workaround for streaming reads on MariaDB.
    • CLAUDE.md — three structural inaccuracies in the Query-layer section fixed: TreeBaseQueryBuilder is not the parent of the others (different hierarchy — extends Laravel's Query\Builder); phantom TreeAggregateBuilder removed (no such file post-§6.5 split); TreeExpression is a 27-line Expression wrapper, not a backend-dispatching SQL generator (per-driver dispatch lives in AggregateSqlFragments.php / FreshAggregateProjector.php). NodeTrait concern count corrected (8, not 7 — HasTreeExport was missing).
    • docs/tree-operations/inserting.md "refresh footgun" reframed — the trait freshens the target's bounds via freshBoundsOf() so the mutation is safe; the real failure mode is subsequent reads off the stale in-memory model.
    • stubs/Blueprint.php deleted — orphan file, not referenced from phpstan.neon, composer.json, or any IDE config; redeclared Illuminate\Database\Schema\Blueprint in the real namespace so loading it at runtime would collide; listed only 2 of 4 package macros. phpstan-bootstrap.php already covers all four via Blueprint::macro() for static analysis.
v0.17.0

v0.17.0 — Mutation matrix CI + gap closure + Internals docs + site widget

Pre-1.0 release: mutation testing now runs across all four database backends, the gaps that surfaced are closed with test-only changes, and the docs site gains a contributor-grade Internals walkthrough plus an interactive nested-set tree widget. No breaking changes from v0.16.0.

CI / infrastructure

  • Run the Infection mutation matrix across all four DB backends (#97). The previous mutation run was SQLite-only, so backend-specific code paths (LATERAL on PG/MySQL, derived shape on MariaDB, correlated fallback on SQLite) showed as "not covered" and any backend-specific assertion gap went unmeasured. The full job is now a 16-cell shard × db matrix — four LOC-balanced non-overlapping src/ shards (concerns, query, aggregates, rest) × four backends — unioned by .github/scripts/merge_infection.py. A mutant counts as escaped only if it survived in every backend that covered it; killed if any backend caught it. The aggregate job also flags backend-divergent mutants (killed on some, escaped on others — a backend-specific assertion gap), publishes the union MSI to Stryker, and uploads the per-mutant report as an artifact. Per-cell distillation reduces reports (which embed full source per mutant) to compact status records before upload. Wall-clock stays roughly one slow shard instead of 4× the SQLite baseline.

Tests

  • Close 27 real mutation gaps across four files, no source changes (#148). Test-only — every mutant was verified killed locally via mutate → run → restore. Coverage map:

    File What the new tests pin Mutants
    ChainFoldAccumulator Float casts, harmonic-mean null, bool coercion 9
    Aggregate stringAgg/jsonAgg/jsonObjectAgg limit boundaries, orderBy coalesce precedence, array-source inclusive flag 9
    AggregateRegistry Listener AVG companion auto-promotion (internal flag), user-declared-companion dedup, method-override resolver returns-all 4
    FreshAggregateProjector Scope predicate (scalar + correlated + quantile subqueries), soft-delete predicate, filter predicate, listener-only early return 5

    Notable correctness detail: the scope-predicate tests build two tenants whose lft/rgt ranges deliberately overlap (each saveAsRoot() starts a fresh 1..N range per partition), so dropping the scope clause makes a fresh read bleed across tenants — that's what makes the predicate observable and the mutants killable. Also hardened a real test-helper weakness in ChainFoldAccumulatorTest: assertEqualsWithDelta($expected, null) silently passes because null coerces to 0 in the subtraction, so a number→null mutation escaped — added an explicit assertNotNull guard.

Documentation

  • Add an Internals section to the docs (#149) — a seven-page, contributor-grade source walkthrough anchored to v0.13.0-26-gca9b1fb:

    • internals/architecture.md — 8-concern NodeTrait composition, layer map, bootNodeTrait() lifecycle hooks, Blueprint macros, load-bearing design decisions.
    • internals/nested-set-model.mdlft/rgt/depth/parent_id invariants, the NodeBounds value object, why parent_id is canonical.
    • internals/mutation-engine.md — queue-then-dispatch (PendingOperation), makeGap / closeGap, the moveNode CASE WHEN arithmetic with a worked numeric example, positionAt and the aggregate seams.
    • internals/query-engine.md — the BETWEEN scope table, custom DescendantsRelation / AncestorsRelation, TreeExpression, and the MariaDB optimiser hook on TreeBaseQueryBuilder.
    • internals/aggregate-maintenance.md — delta vs recompute vs companion-derived strategies, lifecycle hook ordering, the asNumericOrZero vs asIntOrZero type-preservation footgun, soft-delete snapshot semantics, deferred maintenance.
    • internals/repair.mdcountErrors corruption queries, the iterative-DFS rebuild (walkAssignPositions), chunked bulk CASE WHEN writes, subtree-size delta shift, scope guard.
    • internals/concurrency.md — auto-transactions, FOR UPDATE row locks (incl. the PostgreSQL makeRoot aggregate-lock quirk), aggregate_locking and the recompute race, EventDispatcher gating.
  • Interactive nested-set tree widget + site design pass (#147). Embeddable, dependency-free tree widget for the docs site:

    • Author a tree as an indented ```ns-tree fenced block — indentation is the hierarchy.
    • Computes lft/rgt/depth with the same DFS the package uses.
    • A numeric brace annotation ({products=37}) becomes an aggregate source value and rolls up every ancestor as a SUM.
    • Selecting a node highlights its subtree + ancestors and shows the live WHERE lft BETWEEN … AND … query (and matching SUM when a metric is present).
    • Split into a source-agnostic renderer + JS authoring adapter on window.NestedTree, with a flat TreeData shape ({ id, parentId, name, lft, rgt, depth, value, rollup, chips }) — so a future package-driven Laravel demo app can produce the identical shape from Model::defaultOrder()->get() and reuse the same renderer. Partial-subtree fetches render correctly (a node whose parentId is absent is treated as a root).
    • Degrades to the plain indented source when JS is off; themes via the site's CSS variables (dark mode works).

    Site design pass adds: landing hero on the intro page, GitHub-style callouts (> [!NOTE] / TIP / IMPORTANT / WARNING / CAUTION) expanded in build.php with the body rendered as Markdown, code-block copy buttons + language labels, and a nested "On this page" TOC with rAF-throttled scroll spy.

v0.16.0

⚠️ Breaking change — namespace re-shape (#141)

The flat Vusys\NestedSet\Aggregates\* and Vusys\NestedSet\Events\* namespaces are split by responsibility, and two user-implementable interfaces are promoted to Contracts\ (Laravel convention). No class_alias shimsuse statements and Event::listen(::class) calls must be updated. Pre-1.0 polish-phase window.

Aggregates — most-likely-imported renames (full mapping in the PR #141 body):

Before After
Aggregates\AggregateDefinition Aggregates\Definitions\AggregateDefinition
Aggregates\ListenerAggregateDefinition Aggregates\Definitions\ListenerAggregateDefinition
Aggregates\CompanionSpec Aggregates\Definitions\CompanionSpec
Aggregates\CompanionSourceOrigin / CompanionSourceTransform Aggregates\Definitions\…
Aggregates\FilterPredicate / FilterPredicateKind / FilterValueQuoter Aggregates\Filters\…
Aggregates\AggregateRegistry Aggregates\Registry\AggregateRegistry
Aggregates\AggregateSqlEmitter / DerivedAggregateFragments / VarianceSqlFragments / SqliteBitwiseAggregates Aggregates\Sql\…
Aggregates\TreeAggregateListener Contracts\TreeAggregateListener
Aggregates\AggregateDefinitionContract Contracts\AggregateDefinitionContract

Public API roots — Aggregate, ListenerAggregate, AggregateFunction, AggregateFixResult — stay at Vusys\NestedSet\Aggregates\.

Events — by lifecycle phase:

Sub-namespace Events
Events\Mutation\ NodeMoved, NodePromotedToRoot, NodesSwapped
Events\Subtree\ SubtreeMoving, SubtreeMoved, SubtreeForceDeleting, SubtreeForceDeleted
Events\SoftDelete\ SubtreeSoftDeleting, SubtreeSoftDeleted, SubtreeRestoring, SubtreeRestored, SoftDeleteMarkerCaptured
Events\BulkInsert\ BulkInsertTreeStarting, BulkInsertTreePlanned, BulkInsertNodeSaved, BulkInsertTreeSaved, BulkInsertTreeCompleted
Events\Aggregates\ NodeAggregatesRecomputed, AggregateDriftDetected, AggregateMaintenanceFailed, DeferredMaintenanceStarting, DeferredAggregateMaintenanceCompleted, FixAggregatesChunkCompleted, FixAggregatesCompleted, FixAggregatesJobDispatched
Events\Repair\ FixTreeCompleted, TreeIntegrityChecked
Events\Diagnostics\ ScopeViolationDetected

Events\EventDispatcher stays at the namespace root.

Upgrade: ripgrep use Vusys\\NestedSet\\Aggregates\\ and use Vusys\\NestedSet\\Events\\ across your app and rewrite to the new FQCNs. The new locations are searchable in the docs site under §6.1 and §6.2.

v0.16.0 — Internal refactor, namespace re-shape, test/doc round-3/4/5

Pre-1.0 polish release: the round-3/4/5 sweeps. One BC-affecting namespace reshape (callout above), three internal-only refactors that split large files into focused classes by responsibility, a doc-coherence pass covering the M3–M5 features and the NodeTrait surface, and a test-reshuffle that mirrors tests/ to src/ and fills genuinely-reachable coverage gaps.

Refactor

  • Split Aggregates and Events into sub-namespaces (§6.1, §6.2) (#141). BC — see callout above.
  • Round-5 §7 code-smell consolidations (#142): AggregateRegistry *CompanionsFor() dedupe → shared findFirstCompanion(); TreeAggregateBuilder chain-fold tower → ChainFoldAccumulator value object; HasNestedSetAggregates numeric helpers → Vusys\NestedSet\Aggregates\Numeric; HasTreeRelations::requireTreeBuilder() helper.
  • Split TreeAggregateBuilder (2 710 LoC) into four focused classes under Query\Aggregates\ (§6.5) (#143) — AggregateValueComparator, AggregateSqlFragments, AggregateDiffer, FreshAggregateProjector. Pure motion; the original class is deleted (internal-only).
  • Extract listener PHP fix path to Vusys\NestedSet\Aggregates\Listeners\ListenerMaintenance (§6.4) (#144) — HasNestedSetAggregates trait drops 341 LoC; mutation hooks, public maintenance API, anchor/scope plumbing stay in the trait and delegate.

Tests

  • Round-4 coverage gaps + broken-assertion fixes + marathon-test splits (#140). Replaces bare addToAssertionCount(1) placeholders with real before/after snapshots; pins the documented countErrors() cycle-detection gap so it fails loudly if ever fixed. New coverage for cross-scope parent_id corruption (ScopedCorruptionRecoveryTest), Median/Percentile × fixAggregates interaction, MeanArea structural moves, WeightedAvg edge cases (weight→0, negative weight, zero-total NULL), FixAggregatesJob serialisation + failure, bulkInsertTree × withDeferredAggregateMaintenance nesting, and parallel appendToNode on the same parent (AppendToNodeConcurrencyTest, multi-writer-CI only). Three marathon scripted-scenario suites broken into focused regression-class names sharing seed helpers.
  • Namespace tests/ to mirror src/ (#145). 100+ files reorganised across three independently-green commits: Unit/ re-nested into Aggregates/{Definitions,Filters,Registry,Sql}, Attributes/, Events/, Query/; Feature/ gains Mutation/, SoftDelete/, BulkInsert/, Events/, Scoping/, Fuzzers/; Feature/Aggregates/ further split into Maintenance/, Integrity/, Functions/, Listeners/, Repair/, Collections/. The EdgeCasesTest catch-all decomposed so each concern owns its edge cases. PSR-4 path→namespace rewrite verified as a no-op on the pre-refactor tree before any moves landed.
  • ChainFoldAccumulator + Aggregate coverage + data-provider adoption (#146). SQLite line coverage 86.55% → 90.01% (1 321 → 1 454 tests). New ChainFoldAccumulatorTest covers the PHP chain-fold fast path for every reachable kind (Sum/Count/Min/Max/Bit*/Avg/Variance/Stddev/WeightedAvg/BoolOr/BoolAnd/geometric+harmonic mean) plus companion source-transforms; Aggregate factory coverage 76% → 99.7%; DerivedAggregateFragments 62% → 100% via snapshot tests; NestedSetAggregate attribute validation paths driven by a DataProvider; new ScopedArea fixture (the only one combining partitioned trees with maintained aggregates) lifts the NestedSetScopeResolver + scope-predicate paths.

Documentation

  • Cover M3–M5 + rewrite setup / maintenance / deferred sections (#138). New docs/aggregates/means.md (geometric / harmonic, positivity & non-zero constraints, allowNonPositive(), the EXP(Σ LN(x) / n) precision caveat). New docs/aggregates/quantiles.md (median / percentile / percentiles([...]) / quartiles() — fresh-read-only constraint, linear-interpolation semantics, per-backend SQL shape table). Replaces the previous SUM/COUNT-only caption in setup.md with a complete 14-row type: reference (display column shape, null/default convention, auto-allocated companions, dropNestedSetAggregate('col', type: …) requirement). maintenance.md cost matrix replaced with families table covering delta-maintainable / companion-derived / extremum-recompute / chain-recompute / bitwise mixed / fresh-read-only. fix-aggregates.md expands withDeferredAggregateMaintenance with the explicit signature, scoped-model anchor requirement, re-entrancy counter, failure-safety, and observability events.

  • Round-3 coherence pass — pitfalls callout, soft-deletes rewrite, exception taxonomy (#139). 15 docs-only fixes folded into one PR. NodeTrait surface in model-setup.md updated to include bulkInsertTree, moveTo/moveBefore/moveAfter, isSiblingOf, prevSibling/nextSibling, and the HasTreeExport concern. New "Refresh after mutating a child" callout — the package only refreshes the target of a mutation, the parent reference you held goes stale (the #1 footgun). Exception taxonomy split into LogicException (programmer error) vs RuntimeException (data-state); CorruptTreeException and AggregateSourceConstraintViolationException added (were missing). soft-deletes.md rewritten from a 15-line stub to cover cascade lifecycles, microsecond timestamp matching (DATETIME(6) caveat), aggregate semantics on cascade, and scope interaction. Listener AVG companion migration example now shows the float-returning case as a decimal column (bigint default silently truncated). queries.md ordered-output examples now call defaultOrder(). bulk-insertion.md notes the silent scope-column override.

v0.15.0

v0.15.0 — Mathematical and bitwise aggregates

Pre-1.0 release expanding the aggregate vocabulary with statistical, weighted, boolean, bitwise, and quantile families on top of new companion-column infrastructure. No breaking changes from v0.14.0 — existing aggregate kinds, attribute syntax, and maintenance semantics are unchanged.

Features

  • Companion-column infrastructure (M0) (#125). AVG's Sum + Count companion-promotion logic generalised into a per-function CompanionSpec facility so the new maths kinds ride on shared machinery. AggregateFunction::companionSet(): list<CompanionSpec>; AggregateRegistry::autoPromoteCompanions() replaces the AVG-only path; the nestedSetAggregate('foo', type: 'avg') Blueprint macro now allocates the display column plus companions in one call. No new aggregate kinds in M0 itself — pure infrastructure.

  • Variance and standard deviation (M1) (#135). Aggregate::variance(source, sample: false) / Aggregate::stddev(source, sample: false) (both population and sample variants), mirrored as variance: / stddev: / sample: on #[NestedSetAggregate]. Backed by a Sum/SumSq/Count triple — a new CompanionSourceTransform enum lets the SumSq companion sum source * source. The in-UPDATE SET clause uses the textbook E[X²] − E[X]² form (new VarianceSqlFragments helper), with a CASE-zero clamp around SQRT so tightly-clustered data doesn't break Postgres on floating-point cancellation. New Blueprint type families: 'variance', 'stddev'.

  • Weighted average + boolean rollups (M3) (#136). Aggregate::weightedAvg(value, weight), Aggregate::boolOr(source), Aggregate::boolAnd(source) — three new delta-maintainable kinds. WeightedAvg promotes two Sum companions (Σ(weight·value), Σ(weight)); BoolOr / BoolAnd share a single Sum(bool AS INT) + Count pair. Storage: nullable decimal for weighted average, native boolean (BOOLEAN / TINYINT(1) / INTEGER) for the bool kinds. Portable TRUE/FALSE literals + 1.0 * decimal-coercion path verified across all four backends.

  • Geometric and harmonic mean (M4) (#130). Aggregate::geometricMean(source) / Aggregate::harmonicMean(source). Geometric rides on __sum_log (Σ LN(x) over positive values) + __count; harmonic rides on __sum_recip (Σ 1/x over non-zero values) + __count. Two new CompanionSourceTransform cases (Ln, Recip) let companion Sums apply a source expression rather than a raw column. Delta SET clauses emit display-column-first (MySQL left-to-right evaluation): EXP((sum_log + Δ) / NULLIF(count + Δ, 0)) for geometric, NULLIF(count + Δ, 0) / NULLIF(sum_recip + Δ, 0) for harmonic. Source-constraint validation: AggregateSourceConstraintViolationException at save time on a non-positive geometricMean value or a zero harmonicMean value; ->allowNonPositive() opts into silent-skip.

    Bug fixed en route: applyAggregateOnDelete, collectMoveSubtreeContribution, and applyAggregateOnRestore used numeric() (returns int, truncates via (int) cast) for the SQL Sum branch, silently zeroing the delta for any float companion (LN(x), 1/x, decimal-sum). Switched to numericPreserveType() to match the listener branches already in the same methods.

  • Median and percentile — fresh-read-only quantiles (M5) (#131). Aggregate::median(source), Aggregate::percentile(source, p), plus Aggregate::percentiles(source, ['alias' => p, …]) and Aggregate::quartiles(source) spread-helpers for withFreshAggregates() only — no stored column, no maintenance lifecycle. PostgreSQL emits PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY col) with optional FILTER; MySQL / MariaDB / SQLite emit a window-function linear-interpolation correlated subquery (ROW_NUMBER() OVER + COUNT(*) OVER) matching PERCENTILE_CONT's semantics (R's type = 7). #[NestedSetAggregate] rejects median: / percentile: with a clear error pointing to withFreshAggregates().

  • Bitwise rollups — bitOr / bitAnd / bitXor (#127). Feature-flag rollup, capability intersection, and an order-independent subtree fingerprint. bitXor is the only non-Sum-family aggregate with a delta path on both insert and delete (XOR is self-inverse — adding and removing a contribution are the same op). MySQL / MariaDB / PostgreSQL 14+ have all three natively; SQLite gets user-defined aggregates registered on the PDO connection at service-provider boot, so the same SQL works on all four backends.

    #[NestedSetAggregate(column: 'features_or',  bitOr:  'feature_bits')]
    #[NestedSetAggregate(column: 'features_and', bitAnd: 'feature_bits')]
    #[NestedSetAggregate(column: 'features_xor', bitXor: 'feature_bits')]
    class Module extends Model implements HasNestedSet { use NodeTrait; }
    

Documentation

  • Fix headline examples + event-ordering / queue-safety contracts (#137). getSubtreeSize() returns slot count (rgt - lft + 1), not node count — README's headline example said 2 for a root+child tree; corrected to 4. Added the missing $root->refresh() before $root->descendants()->get() (stale in-memory bounds returned an empty collection). BulkInsertTreeSaved fires after the closing fixAggregates pass, not before — events.md, the class docblock, and the ordering paragraph all corrected; stored aggregate columns ARE rolled up by the time listeners see the event. NodeMoved.operation enum gained the missing 'sibling-displaced' variant (emitted for the second participant in up() / down() swaps — switch consumers need it or every swap silently misses half its events). AggregateMaintenanceFailed removed from production.md's "safe for ShouldQueue" list (it carries a Throwable). TreeFixResult.nodesUpdated clarified as scoped row count, not repair count. > [!WARNING] callouts on withFreshAggregates() no-arg form (overwrites $original, breaks subsequent save() deltas).

CI / dependencies

  • Bump bencherdev/bencher to the May-2026 pinned SHA (#134).
  • Bump github/codeql-action 3.35.5 → 4.36.0 (#133).
  • Bump ossf/scorecard-action 2.4.0 → 2.4.3 (#132).
v0.14.0

v0.14.0 — Ergonomics, exports, collection aggregates, model events

Pre-1.0 release with four user-facing additions on top of the v0.13.0 surface: tree exporters for debugging / docs / frontend handoff, four new collection-aggregate kinds, ergonomic move wrappers, and a set of model-carrying events for cache / index / audit listeners. No breaking changes — primitives, attribute API, and NodeMoved semantics from v0.13.0 carry through.

Features

  • Tree exporters — Mermaid / DOT / ASCII / JSON (#122). Read-only formatters that render a node or whole forest. Subtree exports run one lft-ordered query; *Forest walks every root; *Scope filters a multi-tree model to one tree by scope value. Each format ships with a readonly options object (label closure, withTrashed, format-specific knobs). toJsonTree (not toJson) avoids colliding with Eloquent's Model::toJson(int $options). Cycles raise CorruptTreeException eagerly.

    $root->toAsciiTree();                              // debugging
    $root->toMermaid();                                // docs markdown
    $root->toJsonTree(new JsonOptions(childrenKey: 'children'));  // frontend handoff
    Category::toMermaidForest();                       // whole table
    MenuItem::toDotScope($tenantId);                   // one tree of a scoped model
    
  • Collection aggregates — DistinctCount, StringAgg, JsonAgg, JsonObjectAgg (#124). Four new aggregate kinds on top of SUM/COUNT/AVG/MIN/MAX:

    • Aggregate::distinctCount(source) — cardinality of a column across the subtree.
    • Aggregate::stringAgg(source, separator, limit, orderBy) + ->distinct() — concatenated, optionally truncated text.
    • Aggregate::jsonAgg(source) — JSON array (scalar, list of columns, or assoc key => column form).
    • Aggregate::jsonObjectAgg(key, value) — JSON {key: value, …} lookup map.

    All four route through the recompute strategy (no delta fast path). AggregateSqlEmitter centralises per-driver SQL for PG / MySQL / MariaDB / SQLite. The drift comparator (aggregateValuesEqual) is definition-aware: JSON kinds decode + structurally compare so PG jsonb key reordering doesn't produce spurious drift; distinct stringAgg compares as a sorted set of segments. New Blueprint type families: distinct_count, string_agg, json (Laravel's $table->json(...) routes per-driver automatically — jsonb on PG, json on MySQL/MariaDB, text on SQLite).

  • moveTo / moveBefore / moveAfter ergonomic wrappers (#123). moveTo($parent, $position) picks between appendToNode, prependToNode, and insertBeforeNode based on a position arg ('last' default, 'first', or an int index). Self-excludes $this from the sibling lookup so "position N" means "end up at final index N", not "skip N siblings" — collapses the off-by-one bookkeeping callers used to do. moveBefore / moveAfter are thin aliases over insertBeforeNode / insertAfterNode. Same-position no-op moves still emit a zero-delta NodeMoved (intentional, locked in by test).

  • Model-carrying events for application-side decoration (#120). 18 new events grouped into four concerns: bulk-insert lifecycle (BulkInsertTreeStarting/Planned/NodeSaved/TreeSaved/Completed), cascade pairs (SubtreeSoftDeleted / SubtreeRestored / SubtreeForceDeleted now carry the full descendant-id set — per-row Eloquent deleted/restored never fire for cascaded descendants), subtree movement (SubtreeMoved lists the descendants the SQL UPDATE renumbered, which NodeMoved alone can't surface), and observability (deferred-maintenance phases, scope-violation diagnostic). Hot paths stay clean for callers that don't subscribe: the descendant-id SELECT only runs when Event::hasListeners(EventClass) returns true. New docs/reference/events.md page documents the full catalogue, payload tables, queue-safety guidance, and ordering guarantees.

Documentation

  • Wire collection aggregates into the docs sidebar; fix the broken security callout in docs/aggregates/filtered.md (a single-line > ## Title body was rendering as one giant <h2> and polluting the right-sidebar TOC) (#128).
v0.13.0

Hardening release — one scope-related bug fix, extensive mutation-testing-driven test hardening, and new CI signals (Infection, OpenSSF Scorecard, Bencher, PHP 8.5).

Bug fixes

  • Scope-aware isSiblingOf (#94) — siblings are now correctly required to share scope. Cross-scope nodes that happened to share a parent_id value were previously reported as siblings.
  • Fix broken Bencher badge in README (#93).

CI / infrastructure

  • Wire up Infection (mutation testing), OpenSSF Scorecard, and Bencher (perf tracking) (#90).
  • Add PHP 8.5 to the test matrix (#98).
  • Pin GitHub Actions to immutable commit SHAs (#118).
  • Scope workflow write permissions to the jobs that need them (#96).
  • Add SECURITY.md (#95).

Tests

Mutation-testing-driven hardening across the aggregate, scope, repair, and assertion-helper paths — escaped mutants killed in:

  • NodeBounds::contains() boundary (#99)
  • Scope-resolver null-vs-falsy (#100)
  • Bulk-insert scoped anchor (#101)
  • NodeCollection root inference (#102)
  • AggregateMaintenanceFailed event anchorId (#103, #111)
  • fixTree(anchor) narrowing (#104)
  • Column-resolution ternaries (#105)
  • FixAggregatesJob::displayName (#106)
  • array_keys() unwrap in aggregate error message (#107)
  • fixAggregates(anchor) narrowing (#108)
  • queueFixAggregates JobDispatched anchorId (#109)
  • array_keys assertion tightening (#110)
  • Custom-message paths of every InteractsWithTrees assertion (#112)
  • Count / Min / Max arms of inlineRawFilterExpression (#113)
  • Max-listener arm of the chain-inclusion check (#114)
  • Every filtersMatch positive arm in AVG companion adoption (#115)
  • FixAggregatesJob chunkSize=0 boundary (#116)
  • Drop defensive table-existence guards in custom-PK tests (#117)

Documentation

  • README correctness pass + aggregate showcase (#91).
  • Fix unplaced-save snippet, expand aggregate example (#119).
  • Unwrap hard-wrapped paragraphs to natural width (#121).
v0.12.0

v0.12.0 — concurrency-tested locking, listener-aggregate streaming, subtree-rebuild fix

A pre-1.0 release with three correctness/perf themes plus docs polish. No breaking changes — all PK-type, contract, and event shapes from v0.11.0 carry through.

Bug fixes

  • rebuildSubtree shifts surroundings when subtree size changes (#83). TreeRepairBuilder::rebuildSubtree($rootId) previously assumed the post-rebuild subtree fit inside the existing lft/rgt band. If descendants had been added via parent_id without a matching makeGap, or removed without closeGap, the rebuild overlapped the next sibling or left a dead gap. Now computes delta = 2 * subtreeCount - (rgt - lft + 1) and makeGap / closeGap around the rebuilt subtree inside the same transaction. Closes F7 in the CORRECTNESS tracker.

Performance

  • Listener aggregates stream fresh-reads via cursor() (#85). freshListenerAggregate, applyListenerChainRecompute, fixListenerAggregatesPhp, and aggregateErrorsForListeners now stream scalar meta (key, bounds, contribution, stored value) instead of hydrating Eloquent models. Peak memory is O(1) regardless of subtree size — roughly 15–20× memory reduction on listener repairs over large subtrees. SELECT count, contribution-call count, and aggregate values are byte-identical to the pre-refactor path.

Tests

  • Real-contention concurrency harness (#82). New ConcurrencyHarness trait built on pcntl_fork exercises lock acquisition against real DB drivers (single-process PHPUnit serialises statements on one connection, so locking is never exercised end-to-end without fork()):

    • MakeRootConcurrencyTest — 8 workers × 3 saveAsRoot() calls per scope; pins v0.10.0's FOR UPDATE predicate including the scoped variant.
    • AggregateLockingConcurrencyTest — drives the lost-holder MAX recompute under 4 concurrent workers, under both aggregate_locking='auto' and 'always'. Workers retry on SQLSTATE 40001 / 40P01 (deadlock victim).
    • SQLite cells skip cleanly (no row locking; in-memory DB doesn't survive fork()).
  • Public-API fuzzer widening (#86). SoftBranchFuzzerTest, ScopeIsolationFuzzerTest, and TreeStructureFuzzerTest now cover prependToNode, insertBeforeNode, insertAfterNode, makeRoot, up, down, and bulkInsertTree in addition to the previous narrow slice. Default-seed runs are now ~65k assertions (+14% over the 56,682 baseline); random-seed exploration runs cleanly past 1.9M assertions.

Documentation

  • Mass-assignment guard scope clarified (#84). docs/aggregates/setup.md now explains that the AggregateConfigurationException mass-assignment check is build-time reflection over $fillable/$guarded. Calls out the runtime bypasses it doesn't catch (overridden isFillable()/isGuarded() and global Model::unguard()) and recommends Model::unguarded(fn () => …) over the global toggle. No code changes.

  • Live README badges (#87). Four shields.io endpoint badges driven by .github/workflows/badges.yml: tests, assertions, test:src LOC ratio, and CI matrix cell count. The workflow publishes JSON to an orphan badges branch; matrix size is derived from tests.yml at run-time so the count auto-tracks the matrix.

  • Primary Keys split into its own page (#88). The README's ## Primary keys section moved to docs/getting-started/primary-keys.md (accepted column types + the monotonicity rule for chunked aggregate repair). The migration doc's "Non-integer primary keys" subsection is now a brief pointer to the new page.

Test plan

  • CI matrix on each constituent PR — all 24 cells green
  • PHPStan level 9, no baseline, no ignore comments
  • Pint + Rector clean
  • Full test suite — 893 tests, 16,456 assertions (sqlite cell)
  • Concurrency tests verified locally on MySQL — fork-based harness exercises the lock end-to-end
v0.11.0

v0.11.0 — UUID/string primary-key support + correctness-tracker test sweep

A pre-1.0 release with two themes: end-to-end support for non-integer primary keys, and a sweep of test-coverage gaps surfaced by the correctness audit.

Primary-key widening

  • HasNestedSet::getParentId() now returns int|string|null. UUIDv7, ULID, time-ordered string keys, and any monotonically-ordered identifier flow through every mutation, repair, aggregate-maintenance, queued-job, and lifecycle-event surface without narrowing to int.
  • New parentIdType: argument on the nestedSet() Blueprint macro picks the parent_id column shape. Accepts 'bigint' (default), 'uuid', 'ulid', 'string', or a closure for custom column types (nanoid, fixed-width char, FK constraints).
Schema::create('categories', function (Blueprint $table): void {
    $table->uuid('id')->primary();
    $table->string('name');
    $table->nestedSet(parentIdType: 'uuid');
});
  • The intKey() rejection in HasTreeMutation is gone; a new keyOf() helper preserves the key's declared type and throws only when the model is unsaved.
  • The Testing helper InteractsWithTrees no longer rejects string PKs — assertIsChildOf, assertHasChildren, and the rest accept any saved key.

Footguns removed

  • fixTree($anchor) and fixAggregates($anchor) now reject an unsaved anchor with a clear InvalidArgumentException. Previously a null PK silently collapsed to a whole-table walk — almost never what the caller intended. Read paths (isBroken, countErrors, aggregateErrors) stay permissive so the existing "stub anchor as scope carrier" pattern keeps working.
  • fixAggregatesChunked's blanket 1,000,000-iteration safety bound is replaced with a non-progress (stuck-cursor) detector. The previous bound falsely tripped on legitimate small-chunkSize runs over large tables; the new check fires only when nextAfterId actually fails to advance.
  • fixListenerAggregatesPhp no longer narrows outerKey to int before the chunk-membership check. UUID-keyed models had their listener rows silently skipped during chunked repair.

Documentation

  • New "Primary keys" section in README and docs/getting-started/migration.md covering supported PK types, the parentIdType: macro argument, and the monotonic-cursor caveat for chunked repair (UUIDv7/ULID/bigint OK; UUIDv4 and default-nanoid use the unchunked fixAggregates($anchor) instead).

Test coverage

  • New UuidPrimaryKeyTest fixture pair (UuidTag unscoped + UuidMenu/UuidMenuItem scoped with UUID scope column) — 15 tests covering mutation, repair, aggregate maintenance, chunked listener repair, bulk insert under a UUID anchor, job serialisation roundtrip, scoped isolation, stuck-cursor detection, and unsaved-anchor rejection.
  • Ten correctness-tracker test gaps closed (no production-code changes): deferred soft-then-force inside one window, scoped makeRoot() no-op, unsaved-anchor rejection on single-node placement, five aggregatesEqual tolerance boundaries, deferred-inside-DB::transaction rollback recovery, chain-shape detector edge cases, bulk-insert stale-anchor footgun pinning, NaN/Inf drift detection, internal AVG companion column leak, and scoped + depth-bounded eager loads.

Breaking changes (pre-1.0)

  • HasNestedSet::getParentId(): ?intint|string|null. Hand-rolled implementations of the contract need a one-line return-type widening; models using the default NodeTrait get the new behaviour automatically.
  • Eight events (NodeMoved::$nodeId, FixAggregatesCompleted::$anchorId, FixAggregatesChunkCompleted::$cursorAfter, etc.) widen their PK-typed properties to int|string / int|string|null. Listeners typed against int $nodeId need their parameter type widened.
  • FixAggregatesJob's $anchorId and $cursorAfterId widen to int|string|null. Existing serialised job payloads still deserialise cleanly.
  • fixTree($anchor) and fixAggregates($anchor) reject unsaved anchors. If you were passing a stub model with getKey() === null to scope a repair, fetch a real anchor (or omit the anchor for a whole-table walk).
  • Internal helpers HasTreeMutation::intKey() removed and InteractsWithTrees::keyAsInt() renamed to keyOf() returning int|string. Affects code that subclassed either.

CI

All 24 backend cells (PHP 8.3/8.4 × Laravel 11/12/13 × sqlite/mysql/mariadb/pgsql) green on the release commit. PHPStan level 9 with no baseline, Pint + Rector clean, 875 tests / 16,421 assertions.

v0.10.0

v0.10.0 — correctness sweep

A pre-1.0 correctness pass driven by a deep review of the public-API behaviour. Focus is on bug fixes and footgun removal rather than new features.

Bug fixes — structural correctness

  • prevSibling() / nextSibling() on roots now scope by the model's declared scope columns, so two scopes with overlapping lft/rgt ranges can't return each other's roots.
  • fixTree() / fixAggregates() / countErrors() / bulkInsertTree() reject anchors that aren't an instance of the model class — previously a wrong-class anchor either silently no-op'd or repaired the wrong subtree.
  • TreeRepairBuilder walkers are iterative; deeply chained trees no longer hit the PHP recursion ceiling on fixTree().
  • Concurrent makeRoot() calls in the same scope serialise on the max-rgt read (FOR UPDATE), eliminating a duplicate-lft/rgt corruption window.

Bug fixes — aggregate maintenance

  • Raw-filter aggregates require an explicit watches list when the SQL references columns; an empty watches list with column references is now rejected at registry build time rather than producing silent drift.
  • FilterPredicate equality evaluation uses strict comparison (!==) so the PHP capture path agrees with SQL's = NULL → unknown → false semantic. Filters touching nullable columns no longer drift between captured deltas and computed-fresh values.
  • Filter value quoting routes through PDO::quote, so backslash-bearing values are escaped correctly on MySQL/MariaDB (the previous naive quote rewrote 'foo\bar' to 'foobar' under default sql_mode).
  • Boolean filter values render as TRUE/FALSE SQL literals instead of 1/0, fixing PostgreSQL filters against real BOOLEAN columns.
  • withFreshAggregates() on a withTrashed() / onlyTrashed() outer query now includes trashed descendants in the recompute so the fresh value matches the rowset the outer query yields. freshAggregate() gains an opt-in withTrashed: true parameter for the single-node path.

Footguns removed

  • forceDelete() on an interior node now cascades through descendants in the same scope (raw DELETE) and closes the entire subtree gap. Previously left orphans inside a vanished range.
  • Saving a new model without a placement call (appendToNode / prependToNode / insertBeforeNode / insertAfterNode / makeRoot) throws UnplacedNodeException. Catches Model::create([...]) without placement and replicate()->save() without a target — both previously wrote a lft = rgt = 0 corruption.
  • The auto-transaction now wraps the entire save() (gap, INSERT, aggregate hooks), not just the structural-SQL block. A throw in a created / saved listener after the INSERT now rolls back the gap.

Docs and tests

  • withFreshAggregates() documented as read-only: aliased fresh values overlay the stored column, and saving an overlaid model can persist drift back to the store. Recommended pattern uses a distinct alias for side-by-side stored/fresh reads.
  • Ad-hoc aliases pinned as in-memory only — save() doesn't persist them, refresh() drops them.
  • Corruption taxonomy updated to reflect the cascading delete() / forceDelete() paths; orphans only reachable via raw DELETE.
  • Test coverage expanded across the review gaps plus direct unit tests for FilterValueQuoter and TreeBaseQueryBuilder.

Migration notes

Pre-1.0; behaviour changes are intentional. Two paths can break callers if they were relying on prior behaviour:

  • Model::create([...]) without a placement call now throws UnplacedNodeException. If you used this pattern to seed unplaced rows, add an explicit makeRoot() or appendToNode() call.
  • forceDelete() on an interior node previously left descendants in place; it now cascades. If you were relying on the orphan behaviour (e.g. to re-parent children manually afterwards), capture the descendants before the delete.
v0.9.0

v0.9.0 — pre-1.0 polish

Final pre-1.0 polish pass across the public API.

Public-API behaviour

  • Custom primary keys honoured across mutation, repair, aggregate, and SQL paths (no more hard-coded id assumption).
  • Mass-assignable aggregate columns rejected at registry build time (prevents silent drift on next mutation).
  • Raw-SQL filter predicates normalised for companion matching so equivalent predicates share storage.
  • Scope filtering applied to ancestors / descendants relations.
  • NodeMoved telemetry emitted for both participants of an up() / down() swap.
  • replicate() clears structural columns so clones are unplaced until you appendToNode() or makeRoot().

Robustness

  • aggregatesEqual uses relative tolerance for large magnitudes.
  • Soft-delete cascade markers use microsecond precision.
  • assertSameScope tolerates numeric-type variants (string "1" matches int 1, etc.).
  • AggregateRegistry cache flushed in TestCase::setUp.
  • bulkInsertTree plan walker is iterative (no recursion depth limit).
  • getNodeHeightgetSubtreeSize (deprecated alias kept).

Tests and docs

Tests for auto_transaction config, TreeQueryBuilder / relation surfaces, interior forceDelete recovery, raw filter value quoting + trashed-descendant moves. Docs for filter SQL-inlining warning and relation-aggregate usage.

v0.8.0

v0.8.0 — bug-hunt + fuzzers + docs site

Focused bug-hunt pass on the aggregate + soft-delete + replicate surface area.

Bug fixes

  • bulkInsertTree refreshes aggregates above the anchor.
  • forceDelete on a soft-deleted row no longer double-decrements.
  • Soft-delete cascade now runs before the aggregate hook.
  • insertBeforeNode / insertAfterNode on self throws.
  • replicate() resets AVG listener columns, clears deleted_at, and clears structural columns so the clone is unplaced.
  • AVG companion matching requires filter equivalence.
  • Snapshot semantics for soft-deleted nodes across SQL aggregate recompute / repair paths.
  • Soft-delete column resolved dynamically across all SQL paths.
  • applyAggregateBeforeMove honours exclusive chain recompute.

Fuzzer harness

Gated behind a PHPUnit group (#[Group('fuzzer')]) and configurable via FUZZER_SEEDS / FUZZER_STEPS / FUZZER_RUNS env vars. Covers bulkInsertTree, TreeQueryBuilder, soft-delete cascade, and SoftBranch (SQL aggregates + SoftDeletes).

Documentation site

README split into a 29-page docs site under docs/ (markdown-driven nav, build + live-preview workflow, GitHub Pages deploy on master). Kalnoy references replaced with this package's actual API.

CodeRabbit and dependabot configs added; CLAUDE.md guidance for future Claude Code sessions.

v0.7.0

v0.7.0 — filtered + listener aggregates

Headline pre-1.0 feature: custom aggregate columns.

Filtered aggregates

Declarative filters on #[NestedSetAggregate]:

  • filter('column', '=', $value) for simple predicates.
  • filterNotNull('column') for presence checks.
  • filterRaw('column > 0') for arbitrary SQL — auto-correlated, no placeholder ceremony.

Delta maintenance for arithmetic aggregates; chain-recompute for raw-filter columns where filter-equivalence can't be proven.

Listener aggregates

Declare #[NestedSetAggregateListener] for PHP contribution(Model $node) returning mixed:

  • SUM / COUNT / MIN / MAX / AVG all supported.
  • AVG auto-promotes to a Sum + Count companion pair; display value is derived on read.
  • Maintained on insert / update / delete / soft-delete restore; fixAggregates / aggregateErrors / freshAggregate all handle them.

Other

  • Exclusive aggregates (over a node's own row only, not its subtree) maintained incrementally — no longer require fixAggregates.
  • Hard-delete of a leaf now compacts the bounds gap.
  • makeRoot() honours model scope when picking the next root position.
  • Targeted perf: 70× faster raw-filter fixAggregates on MySQL via renamed-outer derived; batched listener Min/Max recompute reads.
v0.6.0

v0.6.0 — bulk insert, telemetry, test helpers

Features

  • bulkInsertTree() — build a subtree from a plan array in one makeGap + N saves + one deferred fixAggregates; full Eloquent semantics (events, casts, observers, etc.).
  • Telemetry eventsNodeMoved, NodeInserted, etc. emitted on every structural mutation; wire them up to logging / observability.
  • InteractsWithTrees test-time assertion helpers — assertIsRoot, assertIsLeaf, assertIsChildOf, assertTreeIsIntact, assertAggregatesAreIntact.

Cleanup

  • vusys prefix dropped from private/protected identifiers.
  • README audit fixes stale claims and documents previously undocumented public APIs.

Performance

  • Leaf fast-path for withFreshAggregates (10–80× on SQLite / MariaDB).
  • Chain-shape fast-path for fixAggregates (70–250× on long chains).
  • MySQL STRAIGHT_JOIN in fixAggregates inner derived (40×).
v0.5.0

v0.5.0 — maintenance ergonomics

Operational maturity pass on the maintenance surface area.

Maintenance API

  • queueFixAggregates() + FixAggregatesJob — drop a job onto your configured queue instead of blocking the request thread.
  • Chunked synchronous fixAggregates with onChunk progress callback for monitoring and cancellation.
  • withDeferredAggregateMaintenance(Closure, ?anchor) — wrap a multi-mutation block so aggregate maintenance runs once at the end instead of per-mutation.
  • Chunked CASE-WHEN UPDATE in TreeRepairBuilder — 10× faster fixTree() on large trees.
  • MariaDB withFreshAggregates picks up the same split_materialized treatment fixAggregates already had.

Docs and CI

  • Tree corruption taxonomy + recovery + prevention docs (docs/CORRUPTION.md).
  • Pathological-shape benchmarks (opt-in via PATHOLOGICAL=1 / run-pathological label).
  • Minimum-scope CI permissions.
v0.4.0

v0.4.0 — withFreshAggregates performance

Collapses K correlated sub-queries into one LEFT JOIN LATERAL per inclusivity group on PostgreSQL and MySQL 8 (backends that support the SQL LATERAL keyword). On MariaDB, where LATERAL is not available, an equivalent derived-table shape keeps ordering and row-multiplication semantics consistent.

Same fresh-read query shape on every backend; orders-of-magnitude fewer query plans per call.

Consolidates the in-development v0.7.0–v0.8.0 perf checkpoints.

v0.3.0

v0.3.0 — fixAggregates performance rework

Replaces correlated subqueries with a JOIN + GROUP BY shape behind a covering index on (scope, lft, rgt, parent_id) and switches the bulk write to a chunked CASE-WHEN UPDATE. The SELECT side is then wrapped in a derived sub-query with BETWEEN-on-lft join predicate so MySQL and SQLite planners pick hash-join + filter + aggregate. MariaDB's split_materialized optimisation is disabled per-statement so its planner stops re-executing the derived once per outer row.

Also establishes the opt-in performance harness: Performance is a separate PHPUnit suite (vendor/bin/phpunit testsuite Performance) and runs on workflow_dispatch / run-perf PR label only.

Cross-backend fixAggregates @ N=10K, intact tree

Backend Baseline v0.3.0 Speedup
SQLite 17,726 ms 56 ms 316×
PostgreSQL 33,824 ms 1,109 ms 30×
MySQL 130,606 ms 4,072 ms 32×
MariaDB 97,040 ms 6,925 ms 14×

Every backend now sits inside the flat-to-50K target window.

Consolidates the in-development v0.3.0–v0.6.0 perf checkpoints.

v0.2.0

v0.2.0 — precalculated aggregate columns

Adds declarative aggregate columns to nested-set models: SUM, COUNT, AVG, MIN, MAX. Inserts, source-column updates, deletes, moves, and soft-delete restores all keep stored aggregate values in sync.

Two access modes

  • Stored$model->tickets_total, single-column read.
  • Fresh$model->freshAggregate('tickets_total') / Model::query()->withFreshAggregates() for audit and drift detection.

Integrity tooling

aggregateErrors(), aggregatesAreBroken(), fixAggregates(). fixTree() runs fixAggregates() as a final step.

v0.1.0

v0.1.0 — pre-aggregates baseline

Initial public-ready state of the package: nested-set algorithm, all four supported backends, scoping, soft-delete cascade, tree repair, strict types throughout, Larastan level 9.

Aggregate-columns feature lands separately in v0.2.0.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
calmfox/watch-sylius
damienfern/grpc-symfony-bundle
atoolo/index-bundle
atoolo/genai-bundle
coprotoai/laravel-ticket
davidjln/llm-carbon-bundle
cryonighter/valid-request-bundle
coolms/taxonomy-bundle
coolms/field-bundle
articulate-orm/symfony
aaix/laravel-tall-architect
ephoto/akeneo-connector
emmanuelballery/eb-plantumlbundle
emielburgman/symfony-visitor-beacon
emielburgman/symfony-visit-storage
emielburgman/symfony-security-headers
emielburgman/symfony-log-viewer
emarref/xdebug-bundle
emarref/pubnub-bundle
elriseio/finance-money-bundle