diff --git a/CHANGELOG.md b/CHANGELOG.md index e151cb9a..5582f3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All Notable changes to `Csv` will be documented in this file -## [Next](https://github.com/thephpleague/csv/compare/9.15.0...master) - TBD +## [9.16.0](https://github.com/thephpleague/csv/compare/9.15.0...9.16.0) - 2024-05-24 ### Added @@ -15,9 +15,13 @@ All Notable changes to `Csv` will be documented in this file - `Statement::orWhere` - `Statement::xorWhere` - `Statement::andWhereColumn` -- `Statement::whereColumnNot` +- `Statement::whereNotColumn` - `Statement::orWhereColumn` - `Statement::xorWhereColumn` +- `Statement::andWhereOffset` +- `Statement::whereNotOffset` +- `Statement::orWhereOffset` +- `Statement::xorWhereOffset` - `Query` feature to allow easier filtering, ordering and querying tabular data ### Deprecated diff --git a/docs/9.0/reader/statement.md b/docs/9.0/reader/statement.md index 3c745ac8..cc67c1f5 100644 --- a/docs/9.0/reader/statement.md +++ b/docs/9.0/reader/statement.md @@ -193,8 +193,8 @@ $filteredData = array_filter($data, $criteria, ARRAY_FILTER_USE_BOTH)); //Filtering an array using the XOR logical operator ``` -As shown in the example the `Criteria` class also combines `Closure` conditions, which means that -you can use a callable whose signature matches the one use for the `where` method. +As shown in the example the `Criteria` class also combines `Closure` conditions, which means +that you can use a callable whose signature matches the one use for the `where` method. ### Ordering @@ -302,8 +302,8 @@ $records = Statement::create() // $records is a League\Csv\ResultSet instance with only 3 fields ``` -While we explain each method separately it is understood that you could use them all together to query your -CSV document as you want like in the following example. +While we explain each method separately it is understood that you could use them all together +to query your CSV document as you want like in the following example. ```php use League\Csv\Reader; diff --git a/src/FragmentFinder.php b/src/FragmentFinder.php index afb071b0..3a597f09 100644 --- a/src/FragmentFinder.php +++ b/src/FragmentFinder.php @@ -129,9 +129,9 @@ private function find(array $parsedExpression, TabularDataReader $tabularDataRea return array_map( fn (array $selection) => Statement::create() - ->select(...$selection['columns']) ->offset($selection['start']) ->limit($selection['length']) + ->select(...$selection['columns']) ->process($tabularDataReader), $selections ); diff --git a/src/Query/Constraint/Column.php b/src/Query/Constraint/Column.php index 9fc00e8f..a6ce39e1 100644 --- a/src/Query/Constraint/Column.php +++ b/src/Query/Constraint/Column.php @@ -15,6 +15,7 @@ use ArrayIterator; use CallbackFilterIterator; +use Closure; use Iterator; use IteratorIterator; use League\Csv\Query; @@ -34,10 +35,12 @@ final class Column implements Query\Predicate */ private function __construct( public readonly string|int $column, - public readonly Comparison $operator, + public readonly Comparison|Closure $operator, public readonly mixed $value, ) { - $this->operator->accept($this->value); + if (!$this->operator instanceof Closure) { + $this->operator->accept($this->value); + } } /** @@ -45,12 +48,16 @@ private function __construct( */ public static function filterOn( string|int $column, - Comparison|string $operator, - mixed $value, + Comparison|Closure|string $operator, + mixed $value = null, ): self { + if ($operator instanceof Closure) { + return new self($column, $operator, null); + } + return new self( $column, - !$operator instanceof Comparison ? Comparison::fromOperator($operator) : $operator, + is_string($operator) ? Comparison::fromOperator($operator) : $operator, $value ); } @@ -61,7 +68,12 @@ public static function filterOn( */ public function __invoke(mixed $value, int|string $key): bool { - return $this->operator->compare(Query\Row::from($value)->value($this->column), $this->value); + $subject = Query\Row::from($value)->value($this->column); + if ($this->operator instanceof Closure) { + return ($this->operator)($subject); + } + + return $this->operator->compare($subject, $this->value); } public function filter(iterable $value): Iterator diff --git a/src/Query/Constraint/ColumnTest.php b/src/Query/Constraint/ColumnTest.php index 80aa1123..9e226fdd 100644 --- a/src/Query/Constraint/ColumnTest.php +++ b/src/Query/Constraint/ColumnTest.php @@ -54,4 +54,13 @@ public function it_will_throw_if_the_column_does_not_exist(): void [...$this->stmt->where($predicate)->process($this->document)]; } + + #[Test] + public function it_can_filter_the_tabular_data_based_on_the_column_value_and_a_callback(): void + { + $predicate = Column::filterOn('Country', fn (string $value): bool => 'UK' === $value); + $result = $this->stmt->where($predicate)->process($this->document); + + self::assertCount(1, $result); + } } diff --git a/src/Query/Constraint/Offset.php b/src/Query/Constraint/Offset.php new file mode 100644 index 00000000..6d9a4e33 --- /dev/null +++ b/src/Query/Constraint/Offset.php @@ -0,0 +1,72 @@ +operator instanceof Closure) { + $this->operator->accept($this->value); + } + } + + /** + * @throws Query\QueryException + */ + public static function filterOn( + Comparison|Closure|string $operator, + mixed $value = null, + ): self { + if ($operator instanceof Closure) { + return new self($operator, null); + } + + return new self( + is_string($operator) ? Comparison::fromOperator($operator) : $operator, + $value + ); + } + + /** + * @throws Query\QueryException + */ + public function __invoke(mixed $value, int|string $key): bool + { + if ($this->operator instanceof Closure) { + return ($this->operator)($key); + } + + return $this->operator->compare($key, $this->value); + } + + public function filter(iterable $value): Iterator + { + return new CallbackFilterIterator(match (true) { + $value instanceof Iterator => $value, + $value instanceof Traversable => new IteratorIterator($value), + default => new ArrayIterator($value), + }, $this); + } +} diff --git a/src/Query/Constraint/OffsetTest.php b/src/Query/Constraint/OffsetTest.php new file mode 100644 index 00000000..eff3ce25 --- /dev/null +++ b/src/Query/Constraint/OffsetTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Csv\Query\Constraint; + +use League\Csv\Query\QueryException; +use League\Csv\Query\QueryTestCase; +use PHPUnit\Framework\Attributes\Test; + +final class OffsetTest extends QueryTestCase +{ + #[Test] + public function it_can_filter_the_tabular_data_based_on_the_offset_value(): void + { + $predicate = Offset::filterOn('<', 2); + $result = $this->stmt->where($predicate)->process($this->document); + + self::assertCount(1, $result); + } + + #[Test] + public function it_can_filter_the_tabular_data_based_on_the_offset_value_and_a_callback(): void + { + $predicate = Offset::filterOn(fn (int $key): bool => $key % 2 === 0); + $result = $this->stmt->where($predicate)->process($this->document); + + self::assertCount(2, $result); + } + + #[Test] + public function it_will_throw_if_the_offset_values_are_invalidf(): void + { + $this->expectException(QueryException::class); + + Offset::filterOn('NOT IN', 'Dakar'); + } +} diff --git a/src/Query/Constraint/TwoColumns.php b/src/Query/Constraint/TwoColumns.php index cdd4ef81..165be35b 100644 --- a/src/Query/Constraint/TwoColumns.php +++ b/src/Query/Constraint/TwoColumns.php @@ -15,6 +15,7 @@ use ArrayIterator; use CallbackFilterIterator; +use Closure; use Iterator; use IteratorIterator; use League\Csv\Query\Predicate; @@ -43,13 +44,17 @@ final class TwoColumns implements Predicate */ private function __construct( public readonly string|int $first, - public readonly Comparison $operator, + public readonly Comparison|Closure $operator, public readonly array|string|int $second, ) { + if ($this->operator instanceof Closure && is_array($this->second)) { + throw new QueryException('The second column must be a string if the operator is a callback.'); + } + if (is_array($this->second)) { $res = array_filter($this->second, fn (mixed $value): bool => !is_string($value) && !is_int($value)); if ([] !== $res) { - throw new QueryException('The second column must be a string, an integer or a list of strings and/or integer.'); + throw new QueryException('The second column must be a string, an integer or a list of strings and/or integer when the operator is not a callback.'); } } } @@ -59,10 +64,10 @@ private function __construct( */ public static function filterOn( string|int $firstColumn, - Comparison|string $operator, + Comparison|Closure|string $operator, array|string|int $secondColumn ): self { - if (!$operator instanceof Comparison) { + if (is_string($operator)) { $operator = Comparison::fromOperator($operator); } @@ -80,6 +85,10 @@ public function __invoke(mixed $value, int|string $key): bool default => Row::from($value)->value($this->second), }; + if ($this->operator instanceof Closure) { + return ($this->operator)(Row::from($value)->value($this->first), $val); + } + return Column::filterOn($this->first, $this->operator, $val)($value, $key); } diff --git a/src/Statement.php b/src/Statement.php index d5f5c0be..3f9354ec 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -132,50 +132,70 @@ final protected static function wrapSingleArgumentCallable(callable $where): cal }; } - public function andWhere(string|int $column, Query\Constraint\Comparison|string $operator, mixed $value): self + public function andWhere(string|int $column, Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self { - return $this->appendCondition('and', Query\Constraint\Column::filterOn($column, $operator, $value)); + return $this->appendWhere('and', Query\Constraint\Column::filterOn($column, $operator, $value)); } - public function orWhere(string|int $column, Query\Constraint\Comparison|string $operator, mixed $value): self + public function orWhere(string|int $column, Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self { - return $this->appendCondition('or', Query\Constraint\Column::filterOn($column, $operator, $value)); + return $this->appendWhere('or', Query\Constraint\Column::filterOn($column, $operator, $value)); } - public function whereNot(string|int $column, Query\Constraint\Comparison|string $operator, mixed $value): self + public function whereNot(string|int $column, Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self { - return $this->appendCondition('not', Query\Constraint\Column::filterOn($column, $operator, $value)); + return $this->appendWhere('not', Query\Constraint\Column::filterOn($column, $operator, $value)); } - public function xorWhere(string|int $column, Query\Constraint\Comparison|string $operator, mixed $value): self + public function xorWhere(string|int $column, Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self { - return $this->appendCondition('xor', Query\Constraint\Column::filterOn($column, $operator, $value)); + return $this->appendWhere('xor', Query\Constraint\Column::filterOn($column, $operator, $value)); } public function andWhereColumn(string|int $first, Query\Constraint\Comparison|string $operator, array|int|string $second): self { - return $this->appendCondition('and', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); + return $this->appendWhere('and', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); } public function orWhereColumn(string|int $first, Query\Constraint\Comparison|string $operator, array|int|string $second): self { - return $this->appendCondition('or', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); + return $this->appendWhere('or', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); } public function xorWhereColumn(string|int $first, Query\Constraint\Comparison|string $operator, array|int|string $second): self { - return $this->appendCondition('xor', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); + return $this->appendWhere('xor', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); } public function whereNotColumn(string|int $first, Query\Constraint\Comparison|string $operator, array|int|string $second): self { - return $this->appendCondition('not', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); + return $this->appendWhere('not', Query\Constraint\TwoColumns::filterOn($first, $operator, $second)); + } + + public function andWhereOffset(Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self + { + return $this->appendWhere('and', Query\Constraint\Offset::filterOn($operator, $value)); + } + + public function orWhereOffset(Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self + { + return $this->appendWhere('or', Query\Constraint\Offset::filterOn($operator, $value)); + } + + public function xorWhereOffset(Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self + { + return $this->appendWhere('xor', Query\Constraint\Offset::filterOn($operator, $value)); + } + + public function whereNotOffset(Query\Constraint\Comparison|Closure|string $operator, mixed $value = null): self + { + return $this->appendWhere('not', Query\Constraint\Offset::filterOn($operator, $value)); } /** * @param 'and'|'not'|'or'|'xor' $joiner */ - final protected function appendCondition(string $joiner, Query\Predicate $predicate): self + final protected function appendWhere(string $joiner, Query\Predicate $predicate): self { if ([] === $this->where) { return $this->where(match ($joiner) { diff --git a/src/TabularDataReader.php b/src/TabularDataReader.php index 5b35a9c3..d2508e01 100644 --- a/src/TabularDataReader.php +++ b/src/TabularDataReader.php @@ -114,7 +114,7 @@ public function getRecords(array $header = []): Iterator; public function fetchPairs($offset_index = 0, $value_index = 1): Iterator; /** - * DEPRECATION WARNING! This class will be removed in the next major point release. + * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated since version 9.9.0 * @@ -129,7 +129,7 @@ public function fetchPairs($offset_index = 0, $value_index = 1): Iterator; public function fetchOne(int $nth_record = 0): array; /** - * DEPRECATION WARNING! This class will be removed in the next major point release. + * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated since version 9.8.0 *