Skip to content
Open
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
5 changes: 5 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
"Superscript\\Axiom\\Lookup\\LookupResolver::resolve",
],
},
"PublicVisibility": {
"ignore": [
"Superscript\\Axiom\\Lookup\\LookupException",
],
},
},
"logs": {
"text": "build/infection.log",
Expand Down
32 changes: 18 additions & 14 deletions src/CsvRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Superscript\Axiom\Lookup;

use Brick\Math\BigDecimal;

use function Psl\Iter\first;

/**
Expand All @@ -12,14 +14,14 @@
final readonly class CsvRecord
{
/**
* @param array<string|int, mixed> $data
* @param array<string|int, mixed> $data
*/
private function __construct(
private array $data,
) {}

/**
* @param array<string|int, mixed> $data
* @param array<string|int, mixed> $data
*/
public static function from(array $data): self
{
Expand All @@ -32,26 +34,26 @@ public static function from(array $data): self
public function getString(string|int $column): ?string
{
$value = $this->data[$column] ?? null;

if ($value === null) {
return null;
}

return is_scalar($value) ? (string) $value : null;
}

/**
* Get a value as a float, or null if not present/not numeric
* Get a value as a BigDecimal, or null if not present/not numeric
*/
public function getNumeric(string|int $column): ?float
public function getNumeric(string|int $column): ?BigDecimal
{
$value = $this->data[$column] ?? null;
if ($value === null || !is_numeric($value)) {

if ($value === null || ! is_numeric($value)) {
return null;
}
return (float) $value;

return BigDecimal::of($value);
}

/**
Expand All @@ -72,32 +74,34 @@ public function has(string|int $column): bool

/**
* Extract specific columns
* @param array<string|int>|string|int $columns
*
* @param array<string|int>|string|int $columns
*/
public function extract(array|string|int $columns): mixed
{
if (empty($columns)) {
return $this->data;
}

if (is_string($columns) || is_int($columns)) {
return $this->data[$columns] ?? null;
}

if (count($columns) === 1) {
return $this->data[first($columns)] ?? null;
}

$result = [];
foreach ($columns as $column) {
$result[$column] = $this->data[$column] ?? null;
}

return $result;
}

/**
* Get all data
*
* @return array<string|int, mixed>
*/
public function toArray(): array
Expand Down
35 changes: 35 additions & 0 deletions src/LookupException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Lookup;

use RuntimeException;

final class LookupException extends RuntimeException
{
public static function undefinedAggregateColumn(string $aggregate): self
{
return new self("Undefined aggregate column for '{$aggregate}' aggregate");
}

public static function nonNumericValue(string|int $column, string $aggregate): self
{
return new self("Non-numeric value encountered in column '{$column}' for '{$aggregate}' aggregate");
}

public static function unexpectedRowCount(int $expectedRowCount, int $count): self
{
return new self("Expected exactly {$expectedRowCount} record(s), {$count} record(s) found.");
}

public static function unknownAggregate(string $aggregate): self
{
return new self("Unknown aggregate: {$aggregate}");
}

public static function fileNotFound(string $path): self
{
return new self("Could not open file: {$path}");
}
}
3 changes: 1 addition & 2 deletions src/LookupResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use League\Csv\Reader;
use League\Flysystem\FilesystemOperator;
use RuntimeException;
use Superscript\Axiom\Lookup\Support\Aggregates\AggregateFactory;
use Superscript\Axiom\Lookup\Support\Filters\Filter;
use Superscript\Axiom\Lookup\Support\Filters\ResolvedFilter;
Expand Down Expand Up @@ -56,7 +55,7 @@ public function resolve(Source $source): Result
$stream = $this->filesystem->readStream($source->path);

if ($stream === false) {
throw new RuntimeException("Could not open file: {$source->path}");
throw LookupException::fileNotFound($source->path);
}

// Create CSV reader from stream
Expand Down
4 changes: 2 additions & 2 deletions src/Support/Aggregates/AggregateFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use RuntimeException;
use Superscript\Axiom\Lookup\LookupException;

final readonly class AggregateFactory
{
Expand All @@ -19,7 +19,7 @@ public static function for(string $aggregate): Aggregate
'min' => Min::initial(),
'max' => Max::initial(),
'all' => All::initial(),
default => throw new RuntimeException("Unknown aggregate: $aggregate"),
default => throw LookupException::unknownAggregate($aggregate),
};
}
}
25 changes: 15 additions & 10 deletions src/Support/Aggregates/Avg.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,47 @@

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use RuntimeException;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
use Superscript\Axiom\Lookup\CsvRecord;
use Superscript\Axiom\Lookup\LookupException;

final readonly class Avg implements Aggregate
{
private function __construct(
private float $sum,
private BigDecimal $sum,
private int $count,
) {}

public static function initial(): self
{
return new self(0.0, 0);
return new self(BigDecimal::zero(), 0);
}

public function process(CsvRecord $record, string|int|null $aggregateColumn): self
{
if ($aggregateColumn === null) {
throw new RuntimeException("aggregateColumn is required when using 'avg' aggregate");
throw LookupException::undefinedAggregateColumn('avg');
}

$value = $record->getNumeric($aggregateColumn);
if ($value !== null) {
return new self($this->sum + $value, $this->count + 1);

if ($value === null) {
throw LookupException::nonNumericValue($aggregateColumn, 'avg');
}
return $this;

return new self($this->sum->plus($value), $this->count + 1);
}

public function finalize(array|string|int $columns): mixed
{
if ($this->count === 0) {
return null;
}

return $this->sum / $this->count;

return $this->sum->dividedBy($this->count, roundingMode: RoundingMode::HALF_UP)
->stripTrailingZeros()
->toFloat();
}

public function canEarlyExit(): bool
Expand Down
22 changes: 12 additions & 10 deletions src/Support/Aggregates/Max.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use RuntimeException;
use Brick\Math\BigDecimal;
use Superscript\Axiom\Lookup\CsvRecord;
use Superscript\Axiom\Lookup\LookupException;

final readonly class Max implements Aggregate
{
/**
* @param mixed $maxValue
*/
private function __construct(
private ?CsvRecord $maxRecord,
private mixed $maxValue,
private ?BigDecimal $maxValue,
) {}

public static function initial(): self
Expand All @@ -25,15 +23,19 @@ public static function initial(): self
public function process(CsvRecord $record, string|int|null $aggregateColumn): self
{
if ($aggregateColumn === null) {
throw new RuntimeException("aggregateColumn is required when using 'max' aggregate");
throw LookupException::undefinedAggregateColumn('max');
}

$value = $record->get($aggregateColumn);

if ($value !== null && ($this->maxValue === null || $value > $this->maxValue)) {
$value = $record->getNumeric($aggregateColumn);

if ($value === null) {
throw LookupException::nonNumericValue($aggregateColumn, 'max');
}

if ($this->maxValue === null || $value->isGreaterThan($this->maxValue)) {
return new self($record, $value);
}

return $this;
}

Expand Down
22 changes: 12 additions & 10 deletions src/Support/Aggregates/Min.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use RuntimeException;
use Brick\Math\BigDecimal;
use Superscript\Axiom\Lookup\CsvRecord;
use Superscript\Axiom\Lookup\LookupException;

final readonly class Min implements Aggregate
{
/**
* @param mixed $minValue
*/
private function __construct(
private ?CsvRecord $minRecord,
private mixed $minValue,
private ?BigDecimal $minValue,
) {}

public static function initial(): self
Expand All @@ -25,15 +23,19 @@ public static function initial(): self
public function process(CsvRecord $record, string|int|null $aggregateColumn): self
{
if ($aggregateColumn === null) {
throw new RuntimeException("aggregateColumn is required when using 'min' aggregate");
throw LookupException::undefinedAggregateColumn('min');
}

$value = $record->get($aggregateColumn);

if ($value !== null && ($this->minValue === null || $value < $this->minValue)) {
$value = $record->getNumeric($aggregateColumn);

if ($value === null) {
throw LookupException::nonNumericValue($aggregateColumn, 'min');
}

if ($this->minValue === null || $value->isLessThan($this->minValue)) {
return new self($record, $value);
}

return $this;
}

Expand Down
43 changes: 43 additions & 0 deletions src/Support/Aggregates/Sole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Superscript\Axiom\Lookup\Support\Aggregates;

use Superscript\Axiom\Lookup\CsvRecord;
use Superscript\Axiom\Lookup\LookupException;

final readonly class Sole implements Aggregate
{
private function __construct(
private ?CsvRecord $record,
private int $count,
) {}

public static function initial(): self
{
return new self(null, 0);
}

public function process(CsvRecord $record, string|int|null $aggregateColumn): self
{
return new self(
$this->count === 0 ? $record : $this->record,
$this->count + 1,
);
}

public function finalize(array|string|int $columns): mixed
{
if ($this->count !== 1 || $this->record === null) {
throw LookupException::unexpectedRowCount(1, $this->count);
}

return $this->record->extract($columns);
}

public function canEarlyExit(): bool
{
return $this->count > 1;
}
}
Loading
Loading