diff --git a/README.md b/README.md index 0729069..f24d58b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A high-performance PHP library for querying CSV/TSV files with streaming, dynami ## Features - **Memory-Efficient Streaming**: O(1) memory complexity - processes records one-at-a-time -- **Eight Aggregate Functions**: `first`, `last`, `min`, `max`, `count`, `sum`, `avg`, `all` +- **Eight Aggregate Functions**: `first`, `last`, `min`, `max`, `count`, `sum`, `avg`, `all` — enumerable at runtime, see [Aggregates](#aggregates) - **Explicit Filter API**: `ValueFilter` and `RangeFilter` for clear, self-documenting code - **Range-Based Banding**: Support for scenarios like tax brackets, premium tiers, shipping rates - **Dynamic Filter Resolution**: Use nested lookups and symbols as filter values @@ -56,6 +56,36 @@ $program = (new Expression($lookup, dialect: $dialect))->compile()->unwrap(); $result = $program(); // Result, Throwable> ``` +## Aggregates + +`LookupSource::$aggregate` is one of the names `AggregateKind` defines, and that enum is the only place the list lives. Ask it rather than restating the list: + +```php +use Superscript\Axiom\Lookup\Support\Aggregates\AggregateKind; + +AggregateKind::names(); +// ['first', 'last', 'count', 'sum', 'avg', 'min', 'max', 'all'] + +AggregateKind::Sum->requiresColumn(); // true +AggregateKind::Count->requiresColumn(); // false +``` + +`requiresColumn()` is the difference between the aggregates that read whole records and those that read one column's values. `first`, `last`, `count` and `all` count matching records or extract the requested columns from them, so they need no `aggregateColumn`. `sum`, `avg`, `min` and `max` need one — there is no sum of a whole record — and refuse without it: + +```php +$lookup = new LookupSource(path: 'products.csv', aggregate: 'sum'); +$program = (new Expression($lookup, dialect: $dialect))->compile()->unwrap(); +$program(); +// Err(RuntimeException: aggregateColumn is required when using 'sum' aggregate) +// — raised by the first matching record, so a lookup that matches nothing +// still returns None. Check the kind up front to catch it either way. + +new LookupSource(path: 'products.csv', aggregate: 'sum', aggregateColumn: 'price'); +// ✓ +``` + +So a caller validating a lookup before running it, or offering a column picker only where a column means something, reads both facts from the kind instead of keeping its own copy in step with this package. Given an aggregate state, `$aggregate->kind()` gets back to the same answers. + ## Using Different Storage Backends The library uses [Flysystem](https://flysystem.thephpleague.com/) for filesystem abstraction, enabling you to read CSV files from various storage backends. The filesystem operator is passed to the `LookupExtension`, so you choose the right adapter once when you compose the dialect — every `LookupSource` compiled with it reads through that filesystem. diff --git a/src/Support/Aggregates/Aggregate.php b/src/Support/Aggregates/Aggregate.php index 548dfdd..3ea40d8 100644 --- a/src/Support/Aggregates/Aggregate.php +++ b/src/Support/Aggregates/Aggregate.php @@ -11,8 +11,21 @@ */ interface Aggregate { + /** + * Which aggregation this state belongs to. The kind answers what the + * aggregation is called and what it needs, so no implementation restates + * either: `$aggregate->kind()->requiresColumn()` gives the same answer as + * `AggregateKind::Sum->requiresColumn()` does to a caller holding only a + * name. + */ + public function kind(): AggregateKind; + /** * Process a matching record + * + * Throws when the kind {@see AggregateKind::requiresColumn()} and + * $aggregateColumn is null — there is no sum or minimum of a whole + * record. Ask the kind to avoid the throw. */ public function process(CsvRecord $record, string|int|null $aggregateColumn): self; diff --git a/src/Support/Aggregates/AggregateFactory.php b/src/Support/Aggregates/AggregateFactory.php index 4c97ed8..fd519bf 100644 --- a/src/Support/Aggregates/AggregateFactory.php +++ b/src/Support/Aggregates/AggregateFactory.php @@ -6,20 +6,22 @@ use RuntimeException; +/** + * The door from a persisted aggregate name to the state that aggregates it. + * {@see AggregateKind} is the vocabulary — ask it which names exist and what + * each one needs; this turns one of those names into a starting state, and + * refuses anything that is not one of them. + */ final readonly class AggregateFactory { public static function for(string $aggregate): Aggregate { - return match ($aggregate) { - 'first' => First::initial(), - 'last' => Last::initial(), - 'count' => Count::initial(), - 'sum' => Sum::initial(), - 'avg' => Avg::initial(), - 'min' => Min::initial(), - 'max' => Max::initial(), - 'all' => All::initial(), - default => throw new RuntimeException("Unknown aggregate: $aggregate"), - }; + $kind = AggregateKind::tryFrom($aggregate); + + if ($kind === null) { + throw new RuntimeException("Unknown aggregate: $aggregate"); + } + + return $kind->initial(); } } diff --git a/src/Support/Aggregates/AggregateKind.php b/src/Support/Aggregates/AggregateKind.php new file mode 100644 index 0000000..3451d72 --- /dev/null +++ b/src/Support/Aggregates/AggregateKind.php @@ -0,0 +1,81 @@ +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 + * ``` + * + * Two aggregations read whole records and two read one column's values. + * `first`, `last`, `count` and `all` need no `aggregateColumn`: they count + * matching records or extract the requested columns from them. `sum`, `avg`, + * `min` and `max` need one, because there is no such thing as the sum of a + * record — {@see requiresColumn()} is that distinction, and the aggregate + * states enforce it (see {@see RequiresAggregateColumn}) by asking their own + * kind rather than restating the rule. + */ +enum AggregateKind: string +{ + case First = 'first'; + case Last = 'last'; + case Count = 'count'; + case Sum = 'sum'; + case Avg = 'avg'; + case Min = 'min'; + case Max = 'max'; + case All = 'all'; + + /** + * Every aggregate name a lookup may use, in declaration order. + * + * @return list + */ + public static function names(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Does an aggregation of this kind need an `aggregateColumn` naming the + * values it reads? Answerable without an aggregate in hand, so a caller + * can decide whether to ask for a column before there is anything to + * aggregate. + */ + public function requiresColumn(): bool + { + return match ($this) { + self::Sum, self::Avg, self::Min, self::Max => true, + self::First, self::Last, self::Count, self::All => false, + }; + } + + /** The empty state an aggregation of this kind folds its matching records into. */ + public function initial(): Aggregate + { + return match ($this) { + self::First => First::initial(), + self::Last => Last::initial(), + self::Count => Count::initial(), + self::Sum => Sum::initial(), + self::Avg => Avg::initial(), + self::Min => Min::initial(), + self::Max => Max::initial(), + self::All => All::initial(), + }; + } +} diff --git a/src/Support/Aggregates/All.php b/src/Support/Aggregates/All.php index 8e9d9ce..5d5bcef 100644 --- a/src/Support/Aggregates/All.php +++ b/src/Support/Aggregates/All.php @@ -22,6 +22,11 @@ public static function initial(): self return new self([]); } + public function kind(): AggregateKind + { + return AggregateKind::All; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { return new self([ diff --git a/src/Support/Aggregates/Avg.php b/src/Support/Aggregates/Avg.php index 8f07266..ff85246 100644 --- a/src/Support/Aggregates/Avg.php +++ b/src/Support/Aggregates/Avg.php @@ -4,11 +4,12 @@ namespace Superscript\Axiom\Lookup\Support\Aggregates; -use RuntimeException; use Superscript\Axiom\Lookup\CsvRecord; final readonly class Avg implements Aggregate { + use RequiresAggregateColumn; + private function __construct( private float $sum, private int $count, @@ -19,13 +20,16 @@ public static function initial(): self return new self(0.0, 0); } + public function kind(): AggregateKind + { + return AggregateKind::Avg; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { - if ($aggregateColumn === null) { - throw new RuntimeException("aggregateColumn is required when using 'avg' aggregate"); - } + $column = $this->requireColumn($aggregateColumn); - $value = $record->getNumeric($aggregateColumn); + $value = $record->getNumeric($column); if ($value !== null) { return new self($this->sum + $value, $this->count + 1); } diff --git a/src/Support/Aggregates/Count.php b/src/Support/Aggregates/Count.php index a4cd8de..bc742fa 100644 --- a/src/Support/Aggregates/Count.php +++ b/src/Support/Aggregates/Count.php @@ -17,6 +17,11 @@ public static function initial(): self return new self(0); } + public function kind(): AggregateKind + { + return AggregateKind::Count; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { return new self($this->count + 1); diff --git a/src/Support/Aggregates/First.php b/src/Support/Aggregates/First.php index 602cba0..fb61d5c 100644 --- a/src/Support/Aggregates/First.php +++ b/src/Support/Aggregates/First.php @@ -17,6 +17,11 @@ public static function initial(): self return new self(null); } + public function kind(): AggregateKind + { + return AggregateKind::First; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { // Keep the first record, ignore subsequent ones diff --git a/src/Support/Aggregates/Last.php b/src/Support/Aggregates/Last.php index f47574f..d30a0d8 100644 --- a/src/Support/Aggregates/Last.php +++ b/src/Support/Aggregates/Last.php @@ -17,6 +17,11 @@ public static function initial(): self return new self(null); } + public function kind(): AggregateKind + { + return AggregateKind::Last; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { // Always keep the latest record diff --git a/src/Support/Aggregates/Max.php b/src/Support/Aggregates/Max.php index 8e03ef7..dbdf507 100644 --- a/src/Support/Aggregates/Max.php +++ b/src/Support/Aggregates/Max.php @@ -4,11 +4,12 @@ namespace Superscript\Axiom\Lookup\Support\Aggregates; -use RuntimeException; use Superscript\Axiom\Lookup\CsvRecord; final readonly class Max implements Aggregate { + use RequiresAggregateColumn; + /** * @param mixed $maxValue */ @@ -22,13 +23,16 @@ public static function initial(): self return new self(null, null); } + public function kind(): AggregateKind + { + return AggregateKind::Max; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { - if ($aggregateColumn === null) { - throw new RuntimeException("aggregateColumn is required when using 'max' aggregate"); - } + $column = $this->requireColumn($aggregateColumn); - $value = $record->get($aggregateColumn); + $value = $record->get($column); if ($value !== null && ($this->maxValue === null || $value > $this->maxValue)) { return new self($record, $value); diff --git a/src/Support/Aggregates/Min.php b/src/Support/Aggregates/Min.php index 25963db..c017af1 100644 --- a/src/Support/Aggregates/Min.php +++ b/src/Support/Aggregates/Min.php @@ -4,11 +4,12 @@ namespace Superscript\Axiom\Lookup\Support\Aggregates; -use RuntimeException; use Superscript\Axiom\Lookup\CsvRecord; final readonly class Min implements Aggregate { + use RequiresAggregateColumn; + /** * @param mixed $minValue */ @@ -22,13 +23,16 @@ public static function initial(): self return new self(null, null); } + public function kind(): AggregateKind + { + return AggregateKind::Min; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { - if ($aggregateColumn === null) { - throw new RuntimeException("aggregateColumn is required when using 'min' aggregate"); - } + $column = $this->requireColumn($aggregateColumn); - $value = $record->get($aggregateColumn); + $value = $record->get($column); if ($value !== null && ($this->minValue === null || $value < $this->minValue)) { return new self($record, $value); diff --git a/src/Support/Aggregates/RequiresAggregateColumn.php b/src/Support/Aggregates/RequiresAggregateColumn.php new file mode 100644 index 0000000..1f031e2 --- /dev/null +++ b/src/Support/Aggregates/RequiresAggregateColumn.php @@ -0,0 +1,31 @@ +kind()->value, + )); + } + + return $aggregateColumn; + } +} diff --git a/src/Support/Aggregates/Sum.php b/src/Support/Aggregates/Sum.php index a7a99d4..5936a4f 100644 --- a/src/Support/Aggregates/Sum.php +++ b/src/Support/Aggregates/Sum.php @@ -4,11 +4,12 @@ namespace Superscript\Axiom\Lookup\Support\Aggregates; -use RuntimeException; use Superscript\Axiom\Lookup\CsvRecord; final readonly class Sum implements Aggregate { + use RequiresAggregateColumn; + private function __construct( private float $sum, private bool $hasValues, @@ -19,13 +20,16 @@ public static function initial(): self return new self(0.0, false); } + public function kind(): AggregateKind + { + return AggregateKind::Sum; + } + public function process(CsvRecord $record, string|int|null $aggregateColumn): self { - if ($aggregateColumn === null) { - throw new RuntimeException("aggregateColumn is required when using 'sum' aggregate"); - } + $column = $this->requireColumn($aggregateColumn); - $value = $record->getNumeric($aggregateColumn); + $value = $record->getNumeric($column); if ($value !== null) { return new self($this->sum + $value, true); } diff --git a/tests/Fixtures/below_minimum.csv b/tests/Fixtures/below_minimum.csv new file mode 100644 index 0000000..10fd955 --- /dev/null +++ b/tests/Fixtures/below_minimum.csv @@ -0,0 +1,2 @@ +minimum,maximum +100,200 diff --git a/tests/Fixtures/missing_min.csv b/tests/Fixtures/missing_min.csv new file mode 100644 index 0000000..d6eb54b --- /dev/null +++ b/tests/Fixtures/missing_min.csv @@ -0,0 +1 @@ +200,Product diff --git a/tests/Fixtures/raw_strings.csv b/tests/Fixtures/raw_strings.csv new file mode 100644 index 0000000..b237b5c --- /dev/null +++ b/tests/Fixtures/raw_strings.csv @@ -0,0 +1,3 @@ +key,label +,blank +null,literal-null diff --git a/tests/Fixtures/regional_bands.csv b/tests/Fixtures/regional_bands.csv new file mode 100644 index 0000000..d0ed01b --- /dev/null +++ b/tests/Fixtures/regional_bands.csv @@ -0,0 +1,5 @@ +region,min_value,max_value,rate +North,0,100,5 +North,100,200,10 +South,0,100,7 +South,100,200,12 diff --git a/tests/LookupResolver/AggregateFactoryTest.php b/tests/LookupResolver/AggregateFactoryTest.php index 82ae6af..ca9e645 100644 --- a/tests/LookupResolver/AggregateFactoryTest.php +++ b/tests/LookupResolver/AggregateFactoryTest.php @@ -10,7 +10,9 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use RuntimeException; +use Superscript\Axiom\Lookup\Support\Aggregates\Aggregate; use Superscript\Axiom\Lookup\Support\Aggregates\AggregateFactory; +use Superscript\Axiom\Lookup\Support\Aggregates\AggregateKind; use Superscript\Axiom\Lookup\Support\Aggregates\All; use Superscript\Axiom\Lookup\Support\Aggregates\Avg; use Superscript\Axiom\Lookup\Support\Aggregates\Count; @@ -21,6 +23,7 @@ use Superscript\Axiom\Lookup\Support\Aggregates\Sum; #[CoversClass(AggregateFactory::class)] +#[UsesClass(AggregateKind::class)] #[UsesClass(First::class)] #[UsesClass(Last::class)] #[UsesClass(Count::class)] @@ -50,6 +53,14 @@ public function it_creates_aggregate(string $name, string $class): void self::assertInstanceOf($class, AggregateFactory::for($name)); } + #[Test] + public function every_name_the_vocabulary_lists_is_a_name_the_factory_accepts(): void + { + foreach (AggregateKind::names() as $name) { + self::assertInstanceOf(Aggregate::class, AggregateFactory::for($name)); + } + } + #[Test] public function it_throws_for_unknown_aggregate(): void { diff --git a/tests/LookupResolver/AggregateTest.php b/tests/LookupResolver/AggregateTest.php index 9b254f6..734fe2c 100644 --- a/tests/LookupResolver/AggregateTest.php +++ b/tests/LookupResolver/AggregateTest.php @@ -5,11 +5,14 @@ namespace Superscript\Axiom\Lookup\Tests\LookupResolver; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use RuntimeException; use Superscript\Axiom\Lookup\CsvRecord; +use Superscript\Axiom\Lookup\Support\Aggregates\AggregateKind; use Superscript\Axiom\Lookup\Support\Aggregates\All; use Superscript\Axiom\Lookup\Support\Aggregates\Avg; use Superscript\Axiom\Lookup\Support\Aggregates\Count; @@ -27,6 +30,8 @@ #[CoversClass(Min::class)] #[CoversClass(Max::class)] #[CoversClass(All::class)] +#[CoversClass(AggregateKind::class)] +#[CoversTrait(\Superscript\Axiom\Lookup\Support\Aggregates\RequiresAggregateColumn::class)] #[UsesClass(CsvRecord::class)] class AggregateTest extends TestCase { @@ -416,45 +421,51 @@ public function all_aggregate_cannot_early_exit(): void } #[Test] - public function sum_aggregate_requires_aggregate_column(): void + public function the_vocabulary_lists_every_kind_in_declaration_order(): void { - $state = Sum::initial(); - $record = CsvRecord::from(['price' => '10']); + self::assertSame( + ['first', 'last', 'count', 'sum', 'avg', 'min', 'max', 'all'], + AggregateKind::names(), + ); - $this->expectException(RuntimeException::class); - - $state->process($record, null); + // Read off the cases, so a kind added to the enum joins the list + // without anyone remembering to write it down twice. + self::assertSame( + array_map(fn(AggregateKind $kind): string => $kind->value, AggregateKind::cases()), + AggregateKind::names(), + ); } - #[Test] - public function avg_aggregate_requires_aggregate_column(): void + /** + * Every kind, so a kind added without a column verdict fails here. + * + * @return iterable + */ + public static function kinds(): iterable { - $state = Avg::initial(); - $record = CsvRecord::from(['score' => '10']); - - $this->expectException(RuntimeException::class); - - $state->process($record, null); + foreach (AggregateKind::cases() as $kind) { + yield $kind->value => [$kind]; + } } #[Test] - public function min_aggregate_requires_aggregate_column(): void + #[DataProvider('kinds')] + public function what_a_kind_says_about_needing_a_column_is_what_processing_does(AggregateKind $kind): void { - $state = Min::initial(); $record = CsvRecord::from(['price' => '10']); + $state = $kind->initial(); - $this->expectException(RuntimeException::class); + self::assertSame($kind, $state->kind()); - $state->process($record, null); - } + if (!$kind->requiresColumn()) { + $state->process($record, null); + self::assertTrue(true, "{$kind->value} processes a record without an aggregateColumn"); - #[Test] - public function max_aggregate_requires_aggregate_column(): void - { - $state = Max::initial(); - $record = CsvRecord::from(['price' => '10']); + return; + } $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("aggregateColumn is required when using '{$kind->value}' aggregate"); $state->process($record, null); } diff --git a/tests/LookupResolver/ExecutionObserverTest.php b/tests/LookupResolver/ExecutionObserverTest.php index 61e0dbb..d8167b9 100644 --- a/tests/LookupResolver/ExecutionObserverTest.php +++ b/tests/LookupResolver/ExecutionObserverTest.php @@ -30,6 +30,7 @@ #[UsesClass(ResolvedFilter::class)] #[UsesClass(Aggregates\First::class)] #[UsesClass(Aggregates\AggregateFactory::class)] +#[UsesClass(\Superscript\Axiom\Lookup\Support\Aggregates\AggregateKind::class)] final class ExecutionObserverTest extends TestCase { private Filesystem $filesystem; diff --git a/tests/LookupSourceTest.php b/tests/LookupSourceTest.php index 667f6e1..c3360ae 100644 --- a/tests/LookupSourceTest.php +++ b/tests/LookupSourceTest.php @@ -57,6 +57,7 @@ #[UsesClass(Aggregates\Max::class)] #[UsesClass(Aggregates\All::class)] #[UsesClass(Aggregates\AggregateFactory::class)] +#[UsesClass(Aggregates\AggregateKind::class)] class LookupSourceTest extends TestCase { private Filesystem $filesystem;