From 39fa43c011258a8ff3626f78a3d6828d01aaef2f Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 7 Aug 2026 13:13:46 +0200 Subject: [PATCH 1/2] ResultSet: Implement interface `Countable` resolves #168 --- src/ResultSet.php | 27 ++++++- tests/ResultSetTest.php | 164 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/src/ResultSet.php b/src/ResultSet.php index 55d581fe..5977d768 100644 --- a/src/ResultSet.php +++ b/src/ResultSet.php @@ -3,15 +3,17 @@ namespace ipl\Orm; use ArrayIterator; +use Countable; use Generator; use Iterator; +use RuntimeException; use Traversable; /** * @template TRow of Model * @implements Iterator */ -class ResultSet implements Iterator +class ResultSet implements Iterator, Countable { /** @var ArrayIterator */ protected ArrayIterator $cache; @@ -29,6 +31,8 @@ class ResultSet implements Iterator protected ?int $position = null; + protected ?int $count = null; + /** * Create a new result set from the given traversable * @@ -98,6 +102,10 @@ public function next(): void } if ($this->isCacheDisabled || ! $this->cache->valid()) { + // Raise count during the first loop only after each iteration, so + // that it is synchronized with how many times a loop has been run. + $this->count += 1; + $this->generator->next(); $this->advance(); } else { @@ -131,11 +139,28 @@ public function rewind(): void if ($this->position === null) { $this->advance(); + $this->count = 0; } else { $this->position = 0; } } + public function count(): int + { + if (! $this->isCacheDisabled && $this->count === null && $this->cache->count() === 0) { + foreach ($this as $_) { + // exhaust the generator and establish the cache + } + } elseif ( + $this->count === null + || ($this->limit === null || $this->count < $this->limit) && $this->hasMore() + ) { + throw new RuntimeException('Cannot count result set while it is not fully iterated'); + } + + return $this->count; + } + protected function advance(): void { if (! $this->generator->valid()) { diff --git a/tests/ResultSetTest.php b/tests/ResultSetTest.php index c0c14131..ddee2390 100644 --- a/tests/ResultSetTest.php +++ b/tests/ResultSetTest.php @@ -81,4 +81,168 @@ public function testResultWithCacheEnabledWithLimit() ['a', 'b', 'a', 'b'] ); } + + public function testCountWithCacheDisabled(): void + { + $set = (new ResultSet(new ArrayIterator(['a', 'b', 'c'])))->disableCache(); + + foreach ($set as $item) { + // pass + } + + $this->assertSame(3, $set->count()); + + $limitedSet = (new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2))->disableCache(); + + foreach ($limitedSet as $item) { + // pass + } + + $this->assertSame(2, $limitedSet->count()); + $this->assertTrue($limitedSet->hasMore()); + + $partialSet = (new ResultSet(new ArrayIterator(['a', 'b']), 3))->disableCache(); + + foreach ($partialSet as $item) { + // pass + } + + $this->assertSame(2, $partialSet->count()); + $this->assertFalse($partialSet->hasMore()); + } + + public function testCountWithCacheEnabled(): void + { + $set = new ResultSet(new ArrayIterator(['a', 'b', 'c'])); + + foreach ($set as $item) { + // pass + } + + $this->assertSame(3, $set->count()); + + // During a subsequent iteration, count should be allowed + foreach ($set as $item) { + $this->assertSame(3, $set->count()); + } + + $limitedSet = new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2); + + foreach ($limitedSet as $item) { + // pass + } + + $this->assertSame(2, $limitedSet->count()); + $this->assertTrue($limitedSet->hasMore()); + + $partialSet = new ResultSet(new ArrayIterator(['a', 'b']), 3); + + foreach ($partialSet as $item) { + // pass + } + + $this->assertSame(2, $partialSet->count()); + $this->assertFalse($partialSet->hasMore()); + } + + public function testCountWithCacheDisabledBeforeIteration(): void + { + $set = (new ResultSet(new ArrayIterator(['a', 'b', 'c'])))->disableCache(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + $set->count(); + } + + public function testLimitedCountWithCacheDisabledBeforeIteration(): void + { + $set = (new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2))->disableCache(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + $set->count(); + } + + public function testCountWithCacheDisabledDuringIteration(): void + { + $set = (new ResultSet(new ArrayIterator(['a', 'b', 'c'])))->disableCache(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + foreach ($set as $item) { + $set->count(); + } + } + + public function testLimitedCountWithCacheDisabledDuringIteration(): void + { + $set = (new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2))->disableCache(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + foreach ($set as $item) { + $set->count(); + } + } + + public function testCountWithCacheBeforeIteration(): void + { + $set = new ResultSet(new ArrayIterator(['a', 'b', 'c'])); + + $this->assertSame(3, $set->count()); + + $result = []; + foreach ($set as $item) { + $result[] = $item; + } + + $this->assertSame(['a', 'b', 'c'], $result); + } + + public function testLimitedCountWithCacheBeforeIteration(): void + { + $set = new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2); + + $this->assertSame(2, $set->count()); + + $result = []; + foreach ($set as $item) { + $result[] = $item; + } + + $this->assertSame(['a', 'b'], $result); + } + + public function testCountWithCacheDuringIteration(): void + { + $set = new ResultSet(new ArrayIterator(['a', 'b', 'c'])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + foreach ($set as $item) { + $set->count(); + } + } + + public function testLimitedCountWithCacheDuringIteration(): void + { + $set = new ResultSet(new ArrayIterator(['a', 'b', 'c']), 2); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot count result set while it is not fully iterated'); + + foreach ($set as $item) { + $set->count(); + } + } + + public function testEmptySetHasCountZero(): void + { + $this->assertSame(0, (new ResultSet(new ArrayIterator([])))->count()); + } } From dc933f92b59997ebee272a6cdebda2053e7c95f0 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Mon, 10 Aug 2026 12:58:25 +0200 Subject: [PATCH 2/2] ResultSetTest: Correctly pass assertion arguments --- tests/ResultSetTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ResultSetTest.php b/tests/ResultSetTest.php index ddee2390..5d48a74d 100644 --- a/tests/ResultSetTest.php +++ b/tests/ResultSetTest.php @@ -39,8 +39,8 @@ public function testResultWithCacheDisabled() } $this->assertEquals( - $items, - ['a', 'b', 'c'] + ['a', 'b', 'c'], + $items ); } @@ -58,8 +58,8 @@ public function testResultWithCacheEnabled() } $this->assertEquals( - $items, - ['a', 'b', 'c', 'a', 'b', 'c'] + ['a', 'b', 'c', 'a', 'b', 'c'], + $items ); } @@ -77,8 +77,8 @@ public function testResultWithCacheEnabledWithLimit() } $this->assertEquals( - $items, - ['a', 'b', 'a', 'b'] + ['a', 'b', 'a', 'b'], + $items ); }