diff --git a/docs/pages/api.rst b/docs/pages/api.rst index 5ed6f627c..0ac03873c 100644 --- a/docs/pages/api.rst +++ b/docs/pages/api.rst @@ -813,6 +813,27 @@ Signature: ``Collection::filter(callable ...$callbacks): Collection;`` .. literalinclude:: code/operations/filter.php :language: php +find +~~~~ + +Find a collection item using one or more callbacks. If the value cannot be found, that is, no callback returns true for +any collection item, it will return the ``$default`` value. + +.. warning:: The ``callbacks`` parameter is variadic and will be evaluated as a logical ``OR``. + If you're looking for a logical ``AND``, you have to make multiple calls to the + same operation. + +.. tip:: This operation is a shortcut for ``filter`` + ``current``. + +.. tip:: It is only when the callback returns ``true`` that the value is selected. + +Interface: `Findable`_ + +Signature: ``Collection::find($default = null, callable ...$callbacks);`` + +.. literalinclude:: code/operations/find.php + :language: php + first ~~~~~ @@ -2509,6 +2530,7 @@ Signature: ``Collection::zip(iterable ...$iterables): Collection;`` .. _Explodeable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Explodeable.php .. _Falsyable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Falsyable.php .. _Filterable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Filterable.php +.. _Findable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Findable.php .. _Firstable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Firstable.php .. _FlatMapable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/FlatMapable.php .. _Flattenable: https://github.com/loophp/collection/blob/master/src/Contract/Operation/Flattenable.php diff --git a/docs/pages/code/operations/find.php b/docs/pages/code/operations/find.php new file mode 100644 index 000000000..71ed0eb53 --- /dev/null +++ b/docs/pages/code/operations/find.php @@ -0,0 +1,47 @@ + 0 === $value % 3; + +// Example 1: find a value and use the default `null` if not found +$value = Collection::fromIterable(range(1, 10)) + ->find(null, $divisibleBy3); // 3 + +$value = Collection::fromIterable([1, 2, 4]) + ->find(null, $divisibleBy3); // null + +$value = Collection::fromIterable(['foo' => 'f', 'bar' => 'b']) + ->find(null, static fn ($value): bool => 'b' === $value); // 'b' + +$value = Collection::fromIterable(['foo' => 'f', 'bar' => 'b']) + ->find(null, static fn ($value): bool => 'x' === $value); // null + +// Example 2: find a value and use a custom default if not found +$value = Collection::fromIterable([1, 2, 4]) + ->find(-1, $divisibleBy3); // -1 + +$value = Collection::fromIterable(['foo' => 'f', 'bar' => 'b']) + ->find(404, static fn ($value): bool => 'x' === $value); // 404 + +// Example 3: use with a Doctrine Query +/** @var EntityManagerInterface $em */ +$q = $em->createQuery('SELECT u FROM MyProject\Model\Product p'); + +$isBook = static fn ($product): bool => 'books' === $product->getCategory(); + +$value = Collection::fromIterable($q->toIterable()) + ->find(null, $isBook); diff --git a/spec/loophp/collection/CollectionSpec.php b/spec/loophp/collection/CollectionSpec.php index 515823a68..99f53e25c 100644 --- a/spec/loophp/collection/CollectionSpec.php +++ b/spec/loophp/collection/CollectionSpec.php @@ -1401,6 +1401,29 @@ public function it_can_filter(): void ->shouldIterateAs([true]); } + public function it_can_find(): void + { + $this::fromIterable(['foo' => 'a', 'bar' => 'b']) + ->find('missing', static fn ($value): bool => 'b' === $value) + ->shouldReturn('b'); + + $this::fromIterable(['foo' => 'a', 'bar' => 'b']) + ->find('missing', static fn ($value): bool => 'd' === $value) + ->shouldReturn('missing'); + + $this::fromIterable([1, 3, 5]) + ->find(null, static fn ($value): bool => $value % 2 === 0) + ->shouldBeNull(); + + $this::fromIterable([1, 3, 5]) + ->find(-1, static fn ($value): bool => $value % 2 === 0) + ->shouldReturn(-1); + + $this::fromIterable([1, 3, 5]) + ->find(null, static fn ($value): bool => $value % 2 !== 0) + ->shouldReturn(1); + } + public function it_can_flatMap(): void { $this::fromIterable([1, 2, 3]) diff --git a/src/Collection.php b/src/Collection.php index 883f214e3..c9f69ef3e 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -49,6 +49,7 @@ use loophp\collection\Operation\Explode; use loophp\collection\Operation\Falsy; use loophp\collection\Operation\Filter; +use loophp\collection\Operation\Find; use loophp\collection\Operation\First; use loophp\collection\Operation\FlatMap; use loophp\collection\Operation\Flatten; @@ -393,6 +394,11 @@ public function filter(callable ...$callbacks): CollectionInterface return new self(Filter::of()(...$callbacks), [$this->getIterator()]); } + public function find($default = null, callable ...$callbacks) + { + return (new self(Find::of()($default)(...$callbacks), [$this->getIterator()]))->getIterator()->current(); + } + public function first(): CollectionInterface { return new self(First::of(), [$this->getIterator()]); diff --git a/src/Contract/Collection.php b/src/Contract/Collection.php index c77ea7d47..e80cac9df 100644 --- a/src/Contract/Collection.php +++ b/src/Contract/Collection.php @@ -42,6 +42,7 @@ use loophp\collection\Contract\Operation\Explodeable; use loophp\collection\Contract\Operation\Falsyable; use loophp\collection\Contract\Operation\Filterable; +use loophp\collection\Contract\Operation\Findable; use loophp\collection\Contract\Operation\Firstable; use loophp\collection\Contract\Operation\FlatMapable; use loophp\collection\Contract\Operation\Flattenable; @@ -164,6 +165,7 @@ * @template-extends Explodeable * @template-extends Falsyable * @template-extends Filterable + * @template-extends Findable * @template-extends Firstable * @template-extends FlatMapable * @template-extends Flattenable @@ -280,6 +282,7 @@ interface Collection extends Explodeable, Falsyable, Filterable, + Findable, Firstable, FlatMapable, Flattenable, diff --git a/src/Contract/Operation/Findable.php b/src/Contract/Operation/Findable.php new file mode 100644 index 000000000..4e476f5a4 --- /dev/null +++ b/src/Contract/Operation/Findable.php @@ -0,0 +1,34 @@ +=): bool) ...$callbacks + * + * @return T|V + */ + public function find($default = null, callable ...$callbacks); +} diff --git a/src/Operation/Find.php b/src/Operation/Find.php new file mode 100644 index 000000000..f10742989 --- /dev/null +++ b/src/Operation/Find.php @@ -0,0 +1,59 @@ +=): bool ...): Closure(Iterator): Generator + */ + public function __invoke(): Closure + { + return + /** + * @param V $default + * + * @return Closure(callable(T=, TKey=, Iterator=): bool ...): Closure(Iterator): Generator + */ + static fn ($default): Closure => + /** + * @param callable(T=, TKey=, Iterator=): bool ...$callbacks + * + * @return Closure(Iterator): Generator + */ + static function (callable ...$callbacks) use ($default): Closure { + /** @var Closure(Iterator): Generator $pipe */ + $pipe = Pipe::of()( + Filter::of()(...$callbacks), + Append::of()($default), + Head::of(), + ); + + // Point free style. + return $pipe; + }; + } +} diff --git a/tests/static-analysis/find.php b/tests/static-analysis/find.php new file mode 100644 index 000000000..827681948 --- /dev/null +++ b/tests/static-analysis/find.php @@ -0,0 +1,77 @@ + $value % 2 === 0; +$intValueCallback2 = static fn (int $value): bool => 2 < $value; +$intKeyValueCallback1 = static fn (int $value, int $key): bool => $value % 2 === 0 && $key % 2 === 0; +$intKeyValueCallback2 = static fn (int $value, int $key): bool => 2 < $value && 2 < $key; + +$stringValueCallback = static fn (string $value): bool => 'bar' === $value; +$stringValueCallback2 = static fn (string $value): bool => '' === $value; +$stringKeyValueCallback1 = static fn (string $value, string $key): bool => 'bar' !== $value && 'foo' !== $key; +$stringKeyValueCallback2 = static fn (string $value, string $key): bool => 'bar' !== $value && '' === $key; + +find_checkNullableInt(Collection::fromIterable([1, 2, 3])->find(null, $intValueCallback)); +find_checkNullableInt(Collection::fromIterable([1, 2, 3])->find(null, $intValueCallback, $intValueCallback2)); +find_checkNullableInt(Collection::fromIterable([1, 2, 3])->find(null, $intKeyValueCallback1)); +find_checkNullableInt(Collection::fromIterable([1, 2, 3])->find(null, $intKeyValueCallback1, $intKeyValueCallback2)); + +find_checkNullableString(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(null, $stringValueCallback)); +find_checkNullableString(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(null, $stringValueCallback, $stringValueCallback2)); +find_checkNullableString(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(null, $stringKeyValueCallback1)); +find_checkNullableString(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(null, $stringKeyValueCallback1, $stringKeyValueCallback2)); + +find_checkIntElement(Collection::fromIterable([1, 2, 3])->find(-1, $intValueCallback)); +find_checkStringElement(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find('not found', $stringValueCallback)); + +// VALID failures - `null` as default value +/** @psalm-suppress PossiblyNullArgument @phpstan-ignore-next-line */ +find_checkIntElement(Collection::fromIterable([1, 2, 3])->find(null, $intValueCallback)); +/** @psalm-suppress PossiblyNullArgument @phpstan-ignore-next-line */ +find_checkStringElement(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(null, $stringValueCallback)); + +// VALID failures - default value type mismatch +/** @psalm-suppress PossiblyInvalidArgument @phpstan-ignore-next-line */ +find_checkIntElement(Collection::fromIterable([1, 2, 3])->find('not integer', $intValueCallback)); +/** @psalm-suppress PossiblyInvalidArgument @phpstan-ignore-next-line */ +find_checkStringElement(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(-1, $stringValueCallback)); + +/* +PHP 8 - using named parameters and the default `null` value -> these should work fine, +but Psalm reports no issue and PHPStan is reporting an unexpected issue: +"Parameter #1 $value of function find_checkNullableInt expects int|null, (Closure)|int given." + +find_checkNullableInt(Collection::fromIterable([1, 2, 3])->find(callbacks: $intValueCallback)); +find_checkNullableString(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(callbacks: $stringValueCallback)); + */ + +/* +PHP 8 - using named parameters and the default `null` value -> these should legitimately fail, +but Psalm reports no issue and the current PHPStan failures are due to the error mentioned above. + +find_checkIntElement(Collection::fromIterable([1, 2, 3])->find(callbacks: $intValueCallback)); +find_checkStringElement(Collection::fromIterable(['foo' => 'a', 'bar' => 'b'])->find(callbacks: $stringValueCallback)); + */