Skip to content

Commit

Permalink
Adding support for callback comparison on Column, Offset and TeoColum…
Browse files Browse the repository at this point in the history
…n constraints
  • Loading branch information
nyamsprod committed May 24, 2024
1 parent e201a39 commit 998280c
Show file tree
Hide file tree
Showing 10 changed files with 205 additions and 32 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/9.0/reader/statement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/FragmentFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
24 changes: 18 additions & 6 deletions src/Query/Constraint/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ArrayIterator;
use CallbackFilterIterator;
use Closure;
use Iterator;
use IteratorIterator;
use League\Csv\Query;
Expand All @@ -34,23 +35,29 @@ 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);
}
}

/**
* @throws Query\QueryException
*/
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
);
}
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/Query/Constraint/ColumnTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
72 changes: 72 additions & 0 deletions src/Query/Constraint/Offset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace League\Csv\Query\Constraint;

use ArrayIterator;
use CallbackFilterIterator;
use Closure;
use Iterator;
use IteratorIterator;
use League\Csv\Query;
use Traversable;

/**
* Enable filtering a record based on its offset.
*
* When used with PHP's array_filter with the ARRAY_FILTER_USE_BOTH flag
* the record value WILL NOT BE taken into account
*/
final class Offset implements Query\Predicate
{
/**
* @throws Query\QueryException
*/
private function __construct(
public readonly Comparison|Closure $operator,
public readonly mixed $value,
) {
if (!$this->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);
}
}
47 changes: 47 additions & 0 deletions src/Query/Constraint/OffsetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/**
* League.Csv (https://csv.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* 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');
}
}
17 changes: 13 additions & 4 deletions src/Query/Constraint/TwoColumns.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ArrayIterator;
use CallbackFilterIterator;
use Closure;
use Iterator;
use IteratorIterator;
use League\Csv\Query\Predicate;
Expand Down Expand Up @@ -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.');
}
}
}
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand Down
46 changes: 33 additions & 13 deletions src/Statement.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 998280c

Please sign in to comment.