From d0adef61a4f78f931fe9ae5baa7518bcf15ffc5c Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:09:39 +0700
Subject: [PATCH 1/9] =?UTF-8?q?Version=202:=20=F0=9F=9A=80=20Faster=20proc?=
 =?UTF-8?q?ess=20with=20early=20validate=20filter=20before=20loop?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 rector.php        |  9 ++++++---
 src/AtLeast.php   |  6 +++---
 src/Collector.php |  8 ++++++--
 src/Finder.php    | 19 ++++++++++---------
 src/Only.php      |  6 +++---
 5 files changed, 28 insertions(+), 20 deletions(-)

diff --git a/rector.php b/rector.php
index b145f79..3231b06 100644
--- a/rector.php
+++ b/rector.php
@@ -3,7 +3,7 @@
 declare(strict_types=1);
 
 use Rector\Config\RectorConfig;
-use Rector\PHPUnit\Set\PHPUnitSetList;
+use Rector\TypeDeclaration\Rector\ClassMethod\BoolReturnTypeFromBooleanStrictReturnsRector;
 
 return RectorConfig::configure()
     ->withPhpSets(php81: true)
@@ -15,8 +15,11 @@
         privatization: true,
         typeDeclarations: true
     )
-    ->withSets([
-        PHPUnitSetList::PHPUNIT_100,
+    ->withComposerBased(phpunit: true)
+    ->withSkip([
+        BoolReturnTypeFromBooleanStrictReturnsRector::class => [
+            __DIR__ . '/tests/FilterTest.php',
+        ],
     ])
     ->withParallel()
     ->withRootFiles()
diff --git a/src/AtLeast.php b/src/AtLeast.php
index dada2db..14c76de 100644
--- a/src/AtLeast.php
+++ b/src/AtLeast.php
@@ -4,6 +4,7 @@
 
 namespace ArrayLookup;
 
+use ArrayLookup\Assert\Filter;
 use Traversable;
 use Webmozart\Assert\Assert;
 
@@ -47,14 +48,13 @@ private static function atLeastFoundTimes(
     ): bool {
         // usage must be higher than 0
         Assert::greaterThan($maxCount, 0);
+        // filter must be a callable with bool return type
+        Filter::boolean($filter);
 
         $totalFound = 0;
         foreach ($data as $key => $datum) {
             $isFound = $filter($datum, $key);
 
-            // returns of callable must be bool
-            Assert::boolean($isFound);
-
             if (! $isFound) {
                 continue;
             }
diff --git a/src/Collector.php b/src/Collector.php
index 23eb475..0449419 100644
--- a/src/Collector.php
+++ b/src/Collector.php
@@ -4,6 +4,7 @@
 
 namespace ArrayLookup;
 
+use ArrayLookup\Assert\Filter;
 use Traversable;
 use Webmozart\Assert\Assert;
 
@@ -71,6 +72,11 @@ public function getResults(): array
         $collectedData  = [];
         $isCallableWhen = is_callable($this->when);
 
+        if (is_callable($this->when)) {
+            // filter must be a callable with bool return type
+            Filter::boolean($this->when);
+        }
+
         foreach ($this->data as $key => $datum) {
             if ($isCallableWhen) {
                 /**
@@ -79,8 +85,6 @@ public function getResults(): array
                 $when    = $this->when;
                 $isFound = ($when)($datum, $key);
 
-                Assert::boolean($isFound);
-
                 if (! $isFound) {
                     continue;
                 }
diff --git a/src/Finder.php b/src/Finder.php
index c13c8a6..7fbeb37 100644
--- a/src/Finder.php
+++ b/src/Finder.php
@@ -5,6 +5,7 @@
 namespace ArrayLookup;
 
 use ArrayIterator;
+use ArrayLookup\Assert\Filter;
 use ArrayObject;
 use Traversable;
 use Webmozart\Assert\Assert;
@@ -24,12 +25,12 @@ final class Finder
      */
     public static function first(iterable $data, callable $filter, bool $returnKey = false): mixed
     {
+        // filter must be a callable with bool return type
+        Filter::boolean($filter);
+
         foreach ($data as $key => $datum) {
             $isFound = $filter($datum, $key);
 
-            // returns of callable must be bool
-            Assert::boolean($isFound);
-
             if (! $isFound) {
                 continue;
             }
@@ -71,6 +72,9 @@ public static function last(
         // ensure data is array for end(), key(), current(), prev() usage
         Assert::isArray($data);
 
+        // filter must be a callable with bool return type
+        Filter::boolean($filter);
+
         // Use end(), key(), current(), prev() usage instead of array_reverse()
         // to avoid immediatelly got "Out of memory" on many data
         // see https://3v4l.org/IHo2H vs https://3v4l.org/Wqejc
@@ -91,9 +95,6 @@ public static function last(
             $current = current($data);
             $isFound = $filter($current, $key);
 
-            // returns of callable must be bool
-            Assert::boolean($isFound);
-
             if (! $isFound) {
                 // go to previous row
                 prev($data);
@@ -133,12 +134,12 @@ public static function rows(
         $newKey     = 0;
         $totalFound = 0;
 
+        // filter must be a callable with bool return type
+        Filter::boolean($filter);
+
         foreach ($data as $key => $datum) {
             $isFound = $filter($datum, $key);
 
-            // returns of callable must be bool
-            Assert::boolean($isFound);
-
             if (! $isFound) {
                 continue;
             }
diff --git a/src/Only.php b/src/Only.php
index 83978fc..e296abd 100644
--- a/src/Only.php
+++ b/src/Only.php
@@ -4,6 +4,7 @@
 
 namespace ArrayLookup;
 
+use ArrayLookup\Assert\Filter;
 use Traversable;
 use Webmozart\Assert\Assert;
 
@@ -47,14 +48,13 @@ private static function onlyFoundTimes(
     ): bool {
         // usage must be higher than 0
         Assert::greaterThan($maxCount, 0);
+        // filter must be a callable with bool return type
+        Filter::boolean($filter);
 
         $totalFound = 0;
         foreach ($data as $key => $datum) {
             $isFound = $filter($datum, $key);
 
-            // returns of callable must be bool
-            Assert::boolean($isFound);
-
             if (! $isFound) {
                 continue;
             }

From 8d50f6d90f51253750d36776fdaba8047aa9a501 Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:09:48 +0700
Subject: [PATCH 2/9] add filter validation

---
 src/Assert/Filter.php | 39 ++++++++++++++++++++++++++++
 tests/FilterTest.php  | 60 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 99 insertions(+)
 create mode 100644 src/Assert/Filter.php
 create mode 100644 tests/FilterTest.php

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
new file mode 100644
index 0000000..f9b56e5
--- /dev/null
+++ b/src/Assert/Filter.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ArrayLookup\Assert;
+
+use InvalidArgumentException;
+use ReflectionFunction;
+use ReflectionMethod;
+use ReflectionNamedType;
+use TypeError;
+
+use function sprintf;
+
+final class Filter
+{
+    public static function boolean(callable $filter): void
+    {
+        try {
+            $reflection = new ReflectionFunction($filter);
+        } catch (TypeError) {
+            $reflection = new ReflectionMethod($filter, '__invoke');
+        }
+
+        $returnType = $reflection->getReturnType();
+
+        if (! $returnType instanceof ReflectionNamedType) {
+            throw new InvalidArgumentException('Expected a bool return type on callable filter, null given');
+        }
+
+        $returnTypeName = $returnType->getName();
+        if ($returnTypeName !== 'bool') {
+            throw new InvalidArgumentException(sprintf(
+                'Expected a bool return type on callable filter, %s given',
+                $returnTypeName
+            ));
+        }
+    }
+}
diff --git a/tests/FilterTest.php b/tests/FilterTest.php
new file mode 100644
index 0000000..b559e0b
--- /dev/null
+++ b/tests/FilterTest.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace ArrayLookup\Tests;
+
+use ArrayLookup\AtLeast;
+use InvalidArgumentException;
+use PHPUnit\Framework\TestCase;
+
+final class FilterTest extends TestCase
+{
+    public function testOnceWithFilterInvokableClass(): void
+    {
+        $data   = [1, 2, 3];
+        $filter = new class {
+            public function __invoke(int $datum): bool
+            {
+                return $datum === 1;
+            }
+        };
+
+        $this->assertTrue(AtLeast::once($data, $filter));
+    }
+
+    public function testWithoutReturnTypeCallable(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Expected a bool return type on callable filter, null given');
+
+        $data = [1, 2, 3];
+
+        // phpcs:disable
+        $filter = new class {
+            public function __invoke(int $datum)
+            {
+                return $datum === 1;
+            }
+        };
+        // phpcs:enable
+
+        $this->assertTrue(AtLeast::once($data, $filter));
+    }
+
+    public function testWithNonBoolReturnTypeCallable(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Expected a bool return type on callable filter, string given');
+
+        $data   = [1, 2, 3];
+        $filter = new class {
+            public function __invoke(int $datum): string
+            {
+                return 'test';
+            }
+        };
+
+        $this->assertTrue(AtLeast::once($data, $filter));
+    }
+}

From 87743dab2dbb74d91649f58ee480548207226f0f Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:17:28 +0700
Subject: [PATCH 3/9] phpstan fix

---
 src/Assert/Filter.php | 16 +++++++++++++---
 1 file changed, 13 insertions(+), 3 deletions(-)

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
index f9b56e5..8dae94f 100644
--- a/src/Assert/Filter.php
+++ b/src/Assert/Filter.php
@@ -4,11 +4,12 @@
 
 namespace ArrayLookup\Assert;
 
+use Closure;
 use InvalidArgumentException;
 use ReflectionFunction;
 use ReflectionMethod;
 use ReflectionNamedType;
-use TypeError;
+use Webmozart\Assert\Assert;
 
 use function sprintf;
 
@@ -16,10 +17,19 @@ final class Filter
 {
     public static function boolean(callable $filter): void
     {
-        try {
+        if ($filter instanceof Closure) {
             $reflection = new ReflectionFunction($filter);
-        } catch (TypeError) {
+        } elseif (is_object($filter)) {
             $reflection = new ReflectionMethod($filter, '__invoke');
+        } else {
+            Assert::string($filter);
+
+            if (! str_contains($filter, '::')) {
+                $reflection = new ReflectionFunction($filter);
+            } else {
+                [, $method] = explode('::', $filter);
+                $reflection = new ReflectionMethod($filter, $method);
+            }
         }
 
         $returnType = $reflection->getReturnType();

From 29b6d2b3fcc9adc9f93e697ef32d8473cd198327 Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:17:51 +0700
Subject: [PATCH 4/9] cs fix

---
 src/Assert/Filter.php | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
index 8dae94f..8d727bf 100644
--- a/src/Assert/Filter.php
+++ b/src/Assert/Filter.php
@@ -11,7 +11,10 @@
 use ReflectionNamedType;
 use Webmozart\Assert\Assert;
 
+use function explode;
+use function is_object;
 use function sprintf;
+use function str_contains;
 
 final class Filter
 {

From 3e18640f1309853f106781345eedd905cdbed7fd Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:19:00 +0700
Subject: [PATCH 5/9] cs fix

---
 src/Collector.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Collector.php b/src/Collector.php
index 0449419..5fce33c 100644
--- a/src/Collector.php
+++ b/src/Collector.php
@@ -72,7 +72,7 @@ public function getResults(): array
         $collectedData  = [];
         $isCallableWhen = is_callable($this->when);
 
-        if (is_callable($this->when)) {
+        if ($isCallableWhen) {
             // filter must be a callable with bool return type
             Filter::boolean($this->when);
         }

From 94f1a87d2d2e80ec74d9642103074b2ed0c357dd Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:27:47 +0700
Subject: [PATCH 6/9] fix phpstan

---
 src/Collector.php | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/src/Collector.php b/src/Collector.php
index 5fce33c..8b536b5 100644
--- a/src/Collector.php
+++ b/src/Collector.php
@@ -70,15 +70,14 @@ public function getResults(): array
 
         $count          = 0;
         $collectedData  = [];
-        $isCallableWhen = is_callable($this->when);
 
-        if ($isCallableWhen) {
+        if (is_callable($this->when)) {
             // filter must be a callable with bool return type
             Filter::boolean($this->when);
         }
 
         foreach ($this->data as $key => $datum) {
-            if ($isCallableWhen) {
+            if ($this->when !== null) {
                 /**
                  * @var callable(mixed $datum, int|string|null $key): bool $when
                  */

From 358ef1596efd526c9fe4bb966a63d81f7a466f7e Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:39:28 +0700
Subject: [PATCH 7/9] Fix

---
 src/Assert/Filter.php | 12 +-----------
 src/Collector.php     |  4 ++--
 tests/FilterTest.php  | 15 +++++++++++++--
 3 files changed, 16 insertions(+), 15 deletions(-)

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
index 8d727bf..44fd51f 100644
--- a/src/Assert/Filter.php
+++ b/src/Assert/Filter.php
@@ -9,12 +9,9 @@
 use ReflectionFunction;
 use ReflectionMethod;
 use ReflectionNamedType;
-use Webmozart\Assert\Assert;
 
-use function explode;
 use function is_object;
 use function sprintf;
-use function str_contains;
 
 final class Filter
 {
@@ -25,14 +22,7 @@ public static function boolean(callable $filter): void
         } elseif (is_object($filter)) {
             $reflection = new ReflectionMethod($filter, '__invoke');
         } else {
-            Assert::string($filter);
-
-            if (! str_contains($filter, '::')) {
-                $reflection = new ReflectionFunction($filter);
-            } else {
-                [, $method] = explode('::', $filter);
-                $reflection = new ReflectionMethod($filter, $method);
-            }
+            throw new InvalidArgumentException('Expected Closure or invokable object, string given');
         }
 
         $returnType = $reflection->getReturnType();
diff --git a/src/Collector.php b/src/Collector.php
index 8b536b5..6ad5ae9 100644
--- a/src/Collector.php
+++ b/src/Collector.php
@@ -68,8 +68,8 @@ public function getResults(): array
         // ensure transform property is set early ->withTransform() method
         Assert::isCallable($this->transform);
 
-        $count          = 0;
-        $collectedData  = [];
+        $count         = 0;
+        $collectedData = [];
 
         if (is_callable($this->when)) {
             // filter must be a callable with bool return type
diff --git a/tests/FilterTest.php b/tests/FilterTest.php
index b559e0b..ccebcd7 100644
--- a/tests/FilterTest.php
+++ b/tests/FilterTest.php
@@ -23,6 +23,17 @@ public function __invoke(int $datum): bool
         $this->assertTrue(AtLeast::once($data, $filter));
     }
 
+    public function testOnceWithStringFilter(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Expected Closure or invokable object, string given');
+
+        $data   = [1, 'f'];
+        $filter = 'is_string';
+
+        AtLeast::once($data, $filter);
+    }
+
     public function testWithoutReturnTypeCallable(): void
     {
         $this->expectException(InvalidArgumentException::class);
@@ -39,7 +50,7 @@ public function __invoke(int $datum)
         };
         // phpcs:enable
 
-        $this->assertTrue(AtLeast::once($data, $filter));
+        AtLeast::once($data, $filter);
     }
 
     public function testWithNonBoolReturnTypeCallable(): void
@@ -55,6 +66,6 @@ public function __invoke(int $datum): string
             }
         };
 
-        $this->assertTrue(AtLeast::once($data, $filter));
+        AtLeast::once($data, $filter);
     }
 }

From bc932e1b3147a0ba1264e479ef0072168125ef99 Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:41:15 +0700
Subject: [PATCH 8/9] modernize message

---
 src/Assert/Filter.php | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
index 44fd51f..41bd2a1 100644
--- a/src/Assert/Filter.php
+++ b/src/Assert/Filter.php
@@ -22,7 +22,9 @@ public static function boolean(callable $filter): void
         } elseif (is_object($filter)) {
             $reflection = new ReflectionMethod($filter, '__invoke');
         } else {
-            throw new InvalidArgumentException('Expected Closure or invokable object, string given');
+            throw new InvalidArgumentException(
+                sprintf('Expected Closure or invokable object, %s given', gettype($filter))
+            );
         }
 
         $returnType = $reflection->getReturnType();

From 29ea948261657626182bde6ea674db91fe989707 Mon Sep 17 00:00:00 2001
From: Abdul Malik Ikhsan <samsonasik@gmail.com>
Date: Sat, 28 Dec 2024 18:43:07 +0700
Subject: [PATCH 9/9] cs fix

---
 src/Assert/Filter.php | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/Assert/Filter.php b/src/Assert/Filter.php
index 41bd2a1..1a46930 100644
--- a/src/Assert/Filter.php
+++ b/src/Assert/Filter.php
@@ -10,6 +10,7 @@
 use ReflectionMethod;
 use ReflectionNamedType;
 
+use function gettype;
 use function is_object;
 use function sprintf;