Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Find operation #204

Merged
merged 14 commits into from
Oct 26, 2021
22 changes: 22 additions & 0 deletions docs/pages/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~

Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions docs/pages/code/operations/find.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

/**
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

namespace App;

use loophp\collection\Collection;

use function range;

include __DIR__ . '/../../../../vendor/autoload.php';

$divisibleBy3 = static fn ($value): bool => 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);
23 changes: 23 additions & 0 deletions spec/loophp/collection/CollectionSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
6 changes: 6 additions & 0 deletions src/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()]);
Expand Down
3 changes: 3 additions & 0 deletions src/Contract/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -164,6 +165,7 @@
* @template-extends Explodeable<TKey, T>
* @template-extends Falsyable<TKey, T>
* @template-extends Filterable<TKey, T>
* @template-extends Findable<TKey, T>
* @template-extends Firstable<TKey, T>
* @template-extends FlatMapable<TKey, T>
* @template-extends Flattenable<TKey, T>
Expand Down Expand Up @@ -280,6 +282,7 @@ interface Collection extends
Explodeable,
Falsyable,
Filterable,
Findable,
Firstable,
FlatMapable,
Flattenable,
Expand Down
34 changes: 34 additions & 0 deletions src/Contract/Operation/Findable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/**
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

namespace loophp\collection\Contract\Operation;

use Iterator;

/**
* @template T
* @template TKey
*/
interface Findable
{
/**
* Find a value in the collection that matches all predicates or return the
* default value.
*
* @see https://loophp-collection.readthedocs.io/en/stable/pages/api.html#find
*
* @template V
*
* @param V $default
* @param (callable(T=, TKey=, Iterator<TKey, T>=): bool) ...$callbacks
*
* @return T|V
*/
public function find($default = null, callable ...$callbacks);
}
59 changes: 59 additions & 0 deletions src/Operation/Find.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

/**
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

namespace loophp\collection\Operation;

use Closure;
use Generator;
use Iterator;

/**
* @immutable
*
* @template TKey
* @template T
*
* phpcs:disable Generic.Files.LineLength.TooLong
*/
final class Find extends AbstractOperation
{
/**
* @pure
*
* @template V
*
* @return Closure(V): Closure(callable(T=, TKey=, Iterator<TKey, T>=): bool ...): Closure(Iterator<TKey, T>): Generator<TKey, T|V>
*/
public function __invoke(): Closure
{
return
/**
* @param V $default
*
* @return Closure(callable(T=, TKey=, Iterator<TKey, T>=): bool ...): Closure(Iterator<TKey, T>): Generator<TKey, T|V>
*/
static fn ($default): Closure =>
/**
* @param callable(T=, TKey=, Iterator<TKey, T>=): bool ...$callbacks
*
* @return Closure(Iterator<TKey, T>): Generator<TKey, T|V>
*/
static function (callable ...$callbacks) use ($default): Closure {
/** @var Closure(Iterator<TKey, T>): Generator<TKey, T|V> $pipe */
$pipe = Pipe::of()(
Filter::of()(...$callbacks),
Append::of()($default),
Head::of(),
);

// Point free style.
return $pipe;
};
}
}
77 changes: 77 additions & 0 deletions tests/static-analysis/find.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

/**
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

declare(strict_types=1);

include __DIR__ . '/../../vendor/autoload.php';

use loophp\collection\Collection;

function find_checkIntElement(int $value): void
{
}
function find_checkNullableInt(?int $value): void
{
}
function find_checkStringElement(string $value): void
{
}
function find_checkNullableString(?string $value): void
{
}

$intValueCallback = static fn (int $value): bool => $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));
*/