Top-K aggregate
topK stores the K rows with the largest ranking value anywhere in a node's subtree, as a JSON array of [source_value, by_value] pairs. It's the "best sellers per category" / "highest-revenue subordinates per manager" shape — a query that's awkward and expensive to compute on read, but cheap to store as a maintained column.
| Kind | Stored column type | Empty subtree | Maintenance |
|---|---|---|---|
topK |
json (nullable) |
NULL |
Full subtree recompute |
topK is recompute-only: a single deletion or move can promote a row that the stored list never tracked, so no signed delta exists. Every contributing mutation triggers a full subtree recompute over the ancestor chain (same path as the collection aggregates).
1. Quick example
use Vusys\NestedSet\Attributes\NestedSetAggregate;
#[NestedSetAggregate(column: 'top_revenue_products', topK: 'product_id', k: 5, by: 'revenue')]
class Department extends Model implements MaintainsTreeAggregates { use NodeTrait; }
Storage shape on each department row:
[
[481, 19200],
[266, 17850],
[319, 12340],
[104, 9870],
[820, 8430]
]
— the top 5 descendant products by revenue, in descending order.
2. Parameters
topK(attribute) / first argument toAggregate::topK(...)— the column whose value goes in position0of each stored pair. Typically the row'sidor a foreign-key column you want to identify by.k— how many entries to keep. Must be ≥ 1.by— the column used to rank the rows. Defaults to the source column when omitted (topK: 'price', k: 5⇒ "top 5 prices" — the source ranks itself).
3. Method-override form
use Vusys\NestedSet\Aggregates\Aggregate;
protected function nestedSetAggregates(): array
{
return [
Aggregate::topK('product_id', 5, by: 'revenue')->into('top_revenue_products'),
];
}
4. Migration
$table->nestedSetAggregate('top_revenue_products', type: 'top_k');
The top_k type emits a nullable JSON column — jsonb on PostgreSQL, JSON on MySQL/MariaDB, TEXT on SQLite — same shape and per-backend dispatch as jsonAgg.
Add a 'top_revenue_products' => 'array' entry to the model's $casts and Eloquent will decode the JSON to a PHP array on read.
5. Behaviour
- Ordering: by
bycolumn DESC, with the source column DESC as a tiebreaker (so ties resolve deterministically across all four supported backends). - NULL
byvalues: rows withNULLin thebycolumn are excluded — every backend orders NULL unpredictably and silently letting them in would make the stored result non-deterministic. - Empty subtree: NULL (the column is nullable).
- Filters: the standard
filter,filterNotNull, andfilterRawmodifiers all apply — the ranking pool is restricted to matching rows.
#[NestedSetAggregate(
column: 'top_active_products',
topK: 'product_id',
k: 5,
by: 'revenue',
filter: ['status' => 'active'],
)]
6. Fresh-read use
topK also works inside withFreshAggregates() for ad-hoc reads:
Category::query()
->withFreshAggregates(['top3' => Aggregate::topK('id', 3, 'revenue')])
->get();
7. Limitations
- No delta path. Insert / update / delete / move all route through full subtree recompute. For shallow trees this is cheap; for deep trees, factor in the cost when planning aggregate budgets.
- K must be a compile-time constant per declaration. Dynamic K across rows isn't expressible.
- No runner-up tracking. The maintained column holds exactly K entries, not K+1. (The optimisation outlined in
FUTURE_AGGREGATES.mdfor delta-maintained Top-K with a runner-up safeguard is a future direction; the recompute-only shape implemented here doesn't need it.)