Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +56,36 @@ $program = (new Expression($lookup, dialect: $dialect))->compile()->unwrap();
$result = $program(); // Result<Option<mixed>, 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.
Expand Down
13 changes: 13 additions & 0 deletions src/Support/Aggregates/Aggregate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
24 changes: 13 additions & 11 deletions src/Support/Aggregates/AggregateFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
81 changes: 81 additions & 0 deletions src/Support/Aggregates/AggregateKind.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Lookup\Support\Aggregates;

/**
* The aggregate vocabulary: which aggregations a lookup can ask for, and
* what each one needs to run. A `LookupSource` stores its aggregate as a
* persisted string (`'sum'`), so this enum is where that string becomes a
* kind — and the only place the list of kinds is written down.
*
* It exists so callers need not restate the list. A caller validating a
* lookup before running it, or offering a choice of aggregations, reads it
* from here:
*
* ```php
* AggregateKind::names(); // ['first', 'last', 'count', 'sum', ...]
* 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
* ```
*
* 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<string>
*/
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(),
};
}
}
5 changes: 5 additions & 0 deletions src/Support/Aggregates/All.php
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
14 changes: 9 additions & 5 deletions src/Support/Aggregates/Avg.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions src/Support/Aggregates/Count.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/Support/Aggregates/First.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/Support/Aggregates/Last.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/Support/Aggregates/Max.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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);
Expand Down
14 changes: 9 additions & 5 deletions src/Support/Aggregates/Min.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions src/Support/Aggregates/RequiresAggregateColumn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use RuntimeException;

/**
* The column guard shared by the aggregates whose kind requires an
* `aggregateColumn`. It is written once so the requirement
* {@see AggregateKind::requiresColumn()} advertises and the refusal an
* aggregate actually makes cannot drift apart, and so the message names the
* kind without any aggregate spelling its own name.
*/
trait RequiresAggregateColumn
{
abstract public function kind(): AggregateKind;

private function requireColumn(string|int|null $aggregateColumn): string|int
{
if ($aggregateColumn === null) {
throw new RuntimeException(sprintf(
"aggregateColumn is required when using '%s' aggregate",
$this->kind()->value,
));
}

return $aggregateColumn;
}
}
Loading
Loading