diff --git a/README.md b/README.md index 098b46a..52b5716 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Features - [x] Verify exact times: [`once()`](#1-onlyonce), [`twice()`](#2-onlytwice), [`times()`](#3-onlytimes) - [x] Verify in interval range: [`isInclusiveOf()`](#1-intervalisinclusiveof), [`isExclusiveOf()`](#2-intervalisexclusiveof) - [x] Verify all or none match: [`match()`](#1-allmatch), [`none()`](#2-allnone) -- [x] Search data: [`first()`](#1-finderfirst), [`last()`](#2-finderlast), [`rows()`](#3-finderrows), [`partition()`](#4-finderpartition) +- [x] Search data: [`first()`](#1-finderfirst), [`last()`](#2-finderlast), [`nth()`](#3-findernth), [`rows()`](#4-finderrows), [`partition()`](#5-finderpartition) - [x] Collect data with [filter and transform](#g-collector) Installation @@ -503,7 +503,36 @@ var_dump(Finder::last( )); // null ``` -#### 3. `Finder::rows()` +#### 3. `Finder::nth()` + +It returns the 1st, 2nd, 3rd, and so on matching item, or multiple matching items at specific positions. The position is 1-based among matched results, not the original array index. Pass a single position to get one matching item, or an array of positions to get multiple matching items. If no match is found, it returns `null` for a single position or an empty array for multiple positions. Pass `true` to the 4th argument to return the key(s) instead of the value(s). + +```php +use ArrayLookup\Finder; + +$data = [10, 20, 30, 40, 50]; +$filter = static fn($datum): bool => $datum > 15; + +// Get the 2nd matching value (should be 30) +var_dump(Finder::nth($data, $filter, 2)); // 30 + +// Get the 2nd matching key (should be 2) +var_dump(Finder::nth($data, $filter, 2, true)); // 2 + +// Get the 1st and 3rd matching values (should be [20, 40]) +var_dump(Finder::nth($data, $filter, [1, 3])); // [20, 40] + +// Get the 1st and 3rd matching keys (should be [1, 3]) +var_dump(Finder::nth($data, $filter, [1, 3], true)); // [1, 3] + +// No match (single) +var_dump(Finder::nth($data, $filter, 5)); // null + +// No match (multiple) +var_dump(Finder::nth($data, $filter, [5, 6])); // [] +``` + +#### 4. `Finder::rows()` It get rows data filtered found. diff --git a/src/Finder.php b/src/Finder.php index dee410b..37677f7 100644 --- a/src/Finder.php +++ b/src/Finder.php @@ -11,8 +11,11 @@ use Traversable; use Webmozart\Assert\Assert; +use function count; use function current; use function end; +use function in_array; +use function is_int; use function is_numeric; use function iterator_to_array; use function key; @@ -218,4 +221,62 @@ public static function partition( return [$matching, $notMatching]; } + + /** + * Find the 1st, 2nd, 3rd, and so on matching item, or multiple matching items at specific positions. + * + * @param array|Traversable $data + * @param callable(mixed $datum, int|string|null $key): bool $filter + * @param int|array $n The match position(s) to find + */ + public static function nth(iterable $data, callable $filter, int|array $n, bool $returnKey = false): mixed + { + // filter must be a callable with bool return type + Filter::boolean($filter); + + $singleMode = is_int($n); + + if ($singleMode) { + Assert::positiveInteger($n); + $positions = [$n]; + } else { + Assert::notEmpty($n); + Assert::uniqueValues($n); + Assert::allPositiveInteger($n); + + $positions = $n; + } + + $currentMatchIndex = 0; + $collectedCount = 0; + $totalExpected = count($positions); + $result = []; + + foreach ($data as $key => $datum) { + if (! $filter($datum, $key)) { + continue; + } + + ++$currentMatchIndex; + + if (! in_array($currentMatchIndex, $positions, true)) { + continue; + } + + $value = $returnKey ? $key : $datum; + + if ($singleMode) { + return $value; + } + + $result[] = $value; + ++$collectedCount; + + if ($collectedCount === $totalExpected) { + break; + } + } + + return $singleMode ? null : $result; + } } diff --git a/tests/FinderTest.php b/tests/FinderTest.php index f348565..68efd48 100644 --- a/tests/FinderTest.php +++ b/tests/FinderTest.php @@ -469,4 +469,67 @@ public function testPartitionPreservesOriginalData(): void $this->assertSame([1], $notMatching); $this->assertSame($copy, $data); } + + /** + * @param mixed $expected + * @param int[] $data + */ + #[DataProvider('nthDataProvider')] + public function testNth( + iterable $data, + callable $filter, + int|array $n, + int|array|null $expected, + bool $returnKey = false + ): void { + $this->assertSame( + $expected, + Finder::nth($data, $filter, $n, $returnKey) + ); + } + + /** + * @return Iterator + */ + public static function nthDataProvider(): Iterator + { + yield 'single nth match' => [ + [10, 20, 30, 40, 50], + static fn($datum): bool => $datum > 15, + 2, + 30, + ]; + yield 'single nth no match' => [ + [10, 20, 30], + static fn($datum): bool => $datum > 15, + 5, + null, + ]; + yield 'multiple nth matches' => [ + [10, 20, 30, 40, 50], + static fn($datum): bool => $datum > 15, + [1, 3], + [20, 40], + ]; + yield 'multiple nth no matches' => [ + [10, 20, 30], + static fn($datum): bool => $datum > 15, + [5, 6], + [], + ]; + yield 'single nth match returnKey' => [ + [10, 20, 30, 40, 50], + static fn($datum): bool => $datum > 15, + 2, + 2, + true, + ]; + yield 'multiple nth matches returnKey' => [ + [10, 20, 30, 40, 50], + static fn($datum): bool => $datum > 15, + [1, 3], + [1, 3], + true, + ]; + } }