Skip to content

Make the aggregate vocabulary and its column rule introspectable - #22

Merged
robertvansteen merged 1 commit into
mainfrom
aggregate-introspection
Jul 27, 2026
Merged

robertvansteen merged 1 commit into
mainfrom
aggregate-introspection

Conversation

@robertvansteen

Copy link
Copy Markdown
Contributor

Two facts about aggregates lived only inside their implementations.

Which aggregates exist was a closed match in AggregateFactory::for() — readable, but not enumerable.

Which ones need an aggregateColumn was stated four times, once per implementation, as a literal in the message it throws when the column is missing:

// src/Support/Aggregates/Sum.php — and the same line, differently spelled, in Avg, Min, Max
throw new RuntimeException("aggregateColumn is required when using 'sum' aggregate");

Nothing in the Aggregate interface said the rule existed. A caller wanting to validate a lookup before running it, or to ask for a column only where one means something, had to write both lists out again and keep them in step with this package by hand.

AggregateKind is now the vocabulary

One place the list is written, answering both questions without an aggregate in hand:

AggregateKind::names();                  // ['first', 'last', 'count', 'sum', 'avg', 'min', 'max', 'all']
AggregateKind::Sum->requiresColumn();    // true  — offer a column picker
AggregateKind::Count->requiresColumn();  // false — a column would mean nothing
AggregateKind::Sum->initial();           // the empty Sum state to fold records into

The requirement sits on the kind rather than the instance because the caller asking has a name, not a state to fold records into — AggregateFactory::for('sum')->requiresColumn() works but reads oddly for a question about the kind. An aggregate reaches the same answers through kind(), now on the interface.

The four column-requiring aggregates share one guard (RequiresAggregateColumn) that reads its name off its own kind, so no aggregate spells its own name and the requirement the enum advertises cannot drift from the refusal an aggregate makes. A census test walks every case asserting the two agree — a kind added without a verdict fails there:

if (!$kind->requiresColumn()) {
    $state->process($record, null);   // must not throw
    return;
}

$this->expectExceptionMessage("aggregateColumn is required when using '{$kind->value}' aggregate");
$state->process($record, null);

Compatibility

AggregateFactory::for(string) is unchanged for callers: still the door from a persisted name to a starting state, still refusing unknown names with Unknown aggregate: x, now delegating the list to the enum. No BC break — LookupSource::$aggregate stays a string.

Not in scope

DatabaseLookupResolver (on another branch) restates the same vocabulary in two more matches and has its own copy of the column rule. It can now read both from AggregateKind; left alone here to keep this change to one concern.

Verification

151 tests, 100% line coverage, 100% MSI, PHPStan clean, Pint clean — all four on PHP 8.4.

Two coverage-metadata adjustments were needed and are worth flagging for review: LookupSourceTest and ExecutionObserverTest gained #[UsesClass(AggregateKind::class)], because a new class on the resolver's path makes every test that walks it risky under beStrictAboutCoverageMetadata; and the vocabulary assertion lives in AggregateTest, which covers the enum, rather than in AggregateFactoryTest, which only uses it — otherwise names() reads as uncovered.

The README gains an Aggregates section documenting both facts.

🤖 Generated with Claude Code

Two facts about aggregates lived only inside their implementations. Which
aggregates exist was a closed match in AggregateFactory::for(), readable
but not enumerable. Which ones need an aggregateColumn was stated four
times, once per implementation, as a literal in the message it throws
when the column is missing:

    throw new RuntimeException("aggregateColumn is required when using 'sum' aggregate");

Nothing in the Aggregate interface said the rule existed. A caller wanting
to validate a lookup before running it, or to ask for a column only where
one means something, had to write both lists out again and keep them in
step with this package by hand.

AggregateKind is now the vocabulary — the one place the list is written —
and answers both questions without an aggregate in hand:

    AggregateKind::names();                 // ['first', 'last', 'count', 'sum', ...]
    AggregateKind::Sum->requiresColumn();   // true
    AggregateKind::Count->requiresColumn(); // false

The requirement sits on the kind rather than the instance because the
caller asking it has a name, not a state to fold records into; an
aggregate reaches the same answers through kind(), now on the interface.
The four column-requiring aggregates share one guard that reads its name
off its own kind, so the requirement the enum advertises and the refusal
an aggregate makes cannot drift apart — and a test walks every case
asserting the two agree, so a kind added without a verdict fails there.

AggregateFactory::for(string) is unchanged for callers: it stays the door
from a persisted name to a starting state, refusing unknown names with
the same message, and now delegates the list to the enum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@robertvansteen
robertvansteen merged commit 33bc5d5 into main Jul 27, 2026
3 checks passed
@robertvansteen
robertvansteen deleted the aggregate-introspection branch July 27, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant