From 48d51187cdef05475a7e4e2509e8ddf6843ae710 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 11 Jun 2026 13:34:11 +0400 Subject: [PATCH 1/4] test(SelectQuery): reproduce runChunks() skipping the last partial chunk Adds a regression test for #254: runChunks() silently drops the trailing partial page when the total row count is not a multiple of the chunk size (e.g. 10 rows with a chunk size of 3 leaves row #10 unvisited). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Driver/Common/Driver/StatementTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php index fb782896..97559f75 100644 --- a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php +++ b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php @@ -292,6 +292,27 @@ function ($result) use (&$count) { $this->assertSame(5, $count); } + public function testChunksProcessLastPartialChunk(): void + { + $table = $this->database->table('sample_table'); + $this->fillData(); + + $select = $table->select(); + + // 10 rows split by chunks of 3: the last chunk holds the single remaining row (10 % 3 == 1) + $visited = []; + $select->runChunks( + 3, + function (StatementInterface $result) use (&$visited): void { + foreach ($result as $row) { + $visited[] = $row['id']; + } + }, + ); + + $this->assertSame(\range(1, 10), $visited); + } + public function testNativeParameters(): void { $this->fillData(); From 6a1576468ea54d1b9e494ad6edd504b78100dbe8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 11 Jun 2026 13:35:40 +0400 Subject: [PATCH 2/4] fix(SelectQuery): process the last partial chunk in runChunks() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop guard `$offset + $limit <= $count` stopped as soon as fewer than $limit rows remained, so the trailing partial page (count % limit rows) was never passed to the callback — silent data loss in any batch/export/migration built on runChunks(). Changing the guard to `$offset < $count` lets the final iteration return the remaining rows via LIMIT/OFFSET. Fixes #254 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Query/SelectQuery.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Query/SelectQuery.php b/src/Query/SelectQuery.php index 92ce213a..0d80c809 100644 --- a/src/Query/SelectQuery.php +++ b/src/Query/SelectQuery.php @@ -305,7 +305,7 @@ public function runChunks(int $limit, callable $callback): void $select->limit($limit); $offset = 0; - while ($offset + $limit <= $count) { + while ($offset < $count) { $result = $callback( $select->offset($offset)->getIterator(), $offset, From 8a8a82c0a5e755ba29bf468bdbebd9c2f3da1dff Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 11 Jun 2026 13:37:12 +0400 Subject: [PATCH 3/4] test(SelectQuery): add edge-case coverage for runChunks() Covers cases left untested around #254: chunk size larger than the total row count (previously processed zero rows under the old guard), an empty result set, and the $offset/$count arguments passed to the callback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Driver/Common/Driver/StatementTest.php | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php index 97559f75..94edb918 100644 --- a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php +++ b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php @@ -313,6 +313,69 @@ function (StatementInterface $result) use (&$visited): void { $this->assertSame(\range(1, 10), $visited); } + public function testChunksWithLimitGreaterThanCount(): void + { + $table = $this->database->table('sample_table'); + $this->fillData(); + + $select = $table->select(); + + // chunk size larger than the total row count must still yield every row in a single chunk + $visited = []; + $chunks = 0; + $select->runChunks( + 100, + function (StatementInterface $result) use (&$visited, &$chunks): void { + $chunks++; + foreach ($result as $row) { + $visited[] = $row['id']; + } + }, + ); + + $this->assertSame(1, $chunks); + $this->assertSame(\range(1, 10), $visited); + } + + public function testChunksOnEmptyResultNeverInvokesCallback(): void + { + $table = $this->database->table('sample_table'); + // no data inserted + + $select = $table->select(); + + $invoked = false; + $select->runChunks( + 5, + function () use (&$invoked): void { + $invoked = true; + }, + ); + + $this->assertFalse($invoked); + } + + public function testChunksPassOffsetAndCountToCallback(): void + { + $table = $this->database->table('sample_table'); + $this->fillData(); + + $select = $table->select(); + + $offsets = []; + $counts = []; + $select->runChunks( + 3, + function (StatementInterface $result, int $offset, int $count) use (&$offsets, &$counts): void { + $offsets[] = $offset; + $counts[] = $count; + }, + ); + + $this->assertSame([0, 3, 6, 9], $offsets); + $this->assertSame([10, 10, 10, 10], $counts); + } + public function testNativeParameters(): void { $this->fillData(); From b4bf8a9e1098ca89ba761a71c209cdfcbef1f549 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Thu, 11 Jun 2026 13:51:03 +0400 Subject: [PATCH 4/4] test(SelectQuery): make runChunks tests driver-agnostic MSSQL returns the paginated id column as a string and its OFFSET/FETCH ordering is not guaranteed without an explicit ORDER BY. Add orderBy('id') for deterministic iteration and use assertEquals (matching the existing testChunks convention) so the row-value assertions don't depend on the driver's scalar type. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Functional/Driver/Common/Driver/StatementTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php index 94edb918..0785dc5b 100644 --- a/tests/Database/Functional/Driver/Common/Driver/StatementTest.php +++ b/tests/Database/Functional/Driver/Common/Driver/StatementTest.php @@ -297,7 +297,7 @@ public function testChunksProcessLastPartialChunk(): void $table = $this->database->table('sample_table'); $this->fillData(); - $select = $table->select(); + $select = $table->select()->orderBy('id'); // 10 rows split by chunks of 3: the last chunk holds the single remaining row (10 % 3 == 1) $visited = []; @@ -310,7 +310,7 @@ function (StatementInterface $result) use (&$visited): void { }, ); - $this->assertSame(\range(1, 10), $visited); + $this->assertEquals(\range(1, 10), $visited); } public function testChunksWithLimitGreaterThanCount(): void @@ -318,7 +318,7 @@ public function testChunksWithLimitGreaterThanCount(): void $table = $this->database->table('sample_table'); $this->fillData(); - $select = $table->select(); + $select = $table->select()->orderBy('id'); // chunk size larger than the total row count must still yield every row in a single chunk $visited = []; @@ -334,7 +334,7 @@ function (StatementInterface $result) use (&$visited, &$chunks): void { ); $this->assertSame(1, $chunks); - $this->assertSame(\range(1, 10), $visited); + $this->assertEquals(\range(1, 10), $visited); } public function testChunksOnEmptyResultNeverInvokesCallback(): void