From 188cc19cf37443c83528623b00f2b120a6ea5296 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Mon, 11 May 2026 15:34:25 +0400 Subject: [PATCH 1/5] Implement MVP with cursor loader --- src/Select.php | 83 +++++ src/Select/AbstractLoader.php | 11 + .../Driver/Common/Select/CursorTest.php | 340 ++++++++++++++++++ .../Driver/Postgres/Select/CursorTest.php | 17 + .../Driver/SQLite/Select/CursorTest.php | 17 + 5 files changed, 468 insertions(+) create mode 100644 tests/ORM/Functional/Driver/Common/Select/CursorTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Select/CursorTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Select/CursorTest.php diff --git a/src/Select.php b/src/Select.php index 27fa735e3..14743246f 100644 --- a/src/Select.php +++ b/src/Select.php @@ -7,6 +7,7 @@ use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\SelectQuery; +use Cycle\Database\StatementInterface; use Cycle\ORM\Heap\Node; use Cycle\ORM\Select\Options\LoadOptions; use Cycle\ORM\Service\EntityFactoryInterface; @@ -867,6 +868,88 @@ public function getIterator(bool $findInHeap = false): Iterator ); } + /** + * Stream entities from the database using a server-side cursor. + * + * The returned generator pulls rows lazily, hydrates them into entities, and + * yields one entity at a time. Memory usage is bound by `$chunkSize` plus the + * heap (which the caller is responsible for clearing between batches via + * `$orm->getHeap()->clean()` if needed). + * + * Requirements and limits (MVP): + * - Only Postgres is supported on the DBAL side. Other drivers throw a + * {@see \Cycle\Database\Exception\DriverException}. + * - An active transaction is required on the underlying database before iteration + * starts. Wrap the iteration in `$database->transaction(...)` or call + * `beginTransaction()` before iterating. + * - Relations are not yet supported: any `load()`, `with()`, eager-loaded + * schema relations, or hierarchical (JTI/STI) entities cause a + * {@see \LogicException} when the generator is first iterated. + * + * @param int<1, max> $chunkSize Number of rows fetched per round-trip; also the + * batch size used to flush the parser node and avoid unbounded growth. + * + * @return \Generator + */ + public function cursor(int $chunkSize = 1000): \Generator + { + $this->assertCursorCompatible(); + + $query = $this->buildQuery(); + $database = $this->loader->getSource()->getDatabase(); + $role = $this->loader->getTarget(); + $loader = $this->loader; + + $rows = static function () use ($database, $query, $chunkSize, $loader): \Generator { + $node = $loader->createNode(); + $count = 0; + foreach ($database->stream($query, $chunkSize, StatementInterface::FETCH_NUM) as $row) { + $node->parseRow(0, $row); + if (++$count >= $chunkSize) { + yield from $node->getResult(); + $node = $loader->createNode(); + $count = 0; + } + } + if ($count > 0) { + yield from $node->getResult(); + } + }; + + yield from Iterator::createWithServices( + $this->heap, + $this->schema, + $this->entityFactory, + $role, + $rows(), + findInHeap: false, + typecast: true, + ); + } + + private function assertCursorCompatible(): void + { + if ($this->loader->getLoadedRelations() !== []) { + throw new \LogicException( + 'Cursor mode does not support relations yet. ' + . 'Remove load() calls and eager-loaded relations from the entity schema.', + ); + } + + if ($this->loader->getJoinedLoaders() !== []) { + throw new \LogicException( + 'Cursor mode does not support with()-joined relations yet.', + ); + } + + if ($this->loader->isHierarchical()) { + throw new \LogicException( + 'Cursor mode does not support hierarchical entities (JTI/STI) yet. ' + . 'Disable subclass loading with loadSubclasses(false) and avoid parent inheritance.', + ); + } + } + /** * Load data tree from database and linked loaders in a form of array. * diff --git a/src/Select/AbstractLoader.php b/src/Select/AbstractLoader.php index e3cb4b307..e8bc11226 100644 --- a/src/Select/AbstractLoader.php +++ b/src/Select/AbstractLoader.php @@ -304,6 +304,17 @@ public function getJoinedLoaders(): array return $this->join; } + /** + * Returns all loaders that load data for relations of the current loader + * (both inline-joined and post-loaded). + * + * @return LoaderInterface[] + */ + public function getLoadedRelations(): array + { + return $this->load; + } + /** * Indicates that loader loads data. */ diff --git a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php new file mode 100644 index 000000000..7353dce64 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -0,0 +1,340 @@ +markTestSkipped('Cursor mode is only supported on Postgres in the MVP.'); + } + } + + public function testCursorYieldsAllRowsInOrder(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(7); + + $emails = $this->getDatabase()->transaction(function (): array { + $cursor = (new Select($this->orm, User::class))->orderBy('id')->cursor(3); + $out = []; + foreach ($cursor as $user) { + $out[] = $user->email; + } + return $out; + }); + + $this->assertCount(7, $emails); + $this->assertSame('user-1@example.com', $emails[0]); + $this->assertSame('user-7@example.com', $emails[6]); + } + + public function testCursorChunkSmallerThanTotal(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(25); + + $count = $this->getDatabase()->transaction(function (): int { + $i = 0; + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor(7) as $_) { + $i++; + } + return $i; + }); + + $this->assertSame(25, $count); + } + + public function testCursorChunkExactlyMatchesTotal(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(10); + + $count = $this->getDatabase()->transaction(function (): int { + $i = 0; + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor(10) as $_) { + $i++; + } + return $i; + }); + + $this->assertSame(10, $count); + } + + public function testCursorOnEmptyTable(): void + { + $this->skipUnlessPostgres(); + + $items = $this->getDatabase()->transaction(function (): array { + $out = []; + foreach ((new Select($this->orm, User::class))->cursor(10) as $u) { + $out[] = $u; + } + return $out; + }); + + $this->assertSame([], $items); + } + + public function testCursorTypecastsScalars(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(1); + + $this->getDatabase()->transaction(function (): void { + foreach ((new Select($this->orm, User::class))->cursor(10) as $user) { + $this->assertIsInt($user->id); + $this->assertIsFloat($user->balance); + $this->assertSame(100.0, $user->balance); + } + }); + } + + public function testCursorRegistersEntitiesInHeap(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(3); + + $this->getDatabase()->transaction(function (): void { + $collected = []; + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor(2) as $user) { + $collected[] = $user; + } + + // Same identity returned from the heap + foreach ($collected as $entity) { + $node = $this->orm->getHeap()->get($entity); + $this->assertNotNull($node); + $this->assertSame(Node::MANAGED, $node->getStatus()); + } + }); + } + + public function testCursorAllowsHeapCleanBetweenChunks(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(20); + + $this->getDatabase()->transaction(function (): void { + $heap = $this->orm->getHeap(); + $seenInChunk = 0; + $maxLive = 0; + $chunk = 5; + + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor($chunk) as $_) { + $seenInChunk++; + $maxLive = \max($maxLive, $this->countHeapEntries($heap, User::class)); + + if ($seenInChunk === $chunk) { + $heap->clean(); + $seenInChunk = 0; + } + } + + // After heap->clean() in the middle of streaming, the heap never accumulates + // more than a single chunk worth of entities. + $this->assertLessThanOrEqual($chunk, $maxLive); + }); + } + + public function testCursorRequiresActiveTransaction(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(2); + + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/active transaction/i'); + + foreach ((new Select($this->orm, User::class))->cursor(10) as $_) { + // pulling first row triggers DECLARE CURSOR which requires a transaction + } + } + + public function testCursorRespectsWhereAndOrderBy(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(10); + + $ids = $this->getDatabase()->transaction(function (): array { + $cursor = (new Select($this->orm, User::class)) + ->where('balance', '>=', 500.0) + ->orderBy('id', 'DESC') + ->cursor(3); + $out = []; + foreach ($cursor as $u) { + $out[] = $u->id; + } + return $out; + }); + + // balance = id * 100; balance >= 500 → ids 5..10; DESC → [10,9,8,7,6,5] + $this->assertSame([10, 9, 8, 7, 6, 5], $ids); + } + + public function testCursorSupportsEarlyBreak(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(50); + + $result = $this->getDatabase()->transaction(function (): array { + $seen = 0; + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor(5) as $_) { + if (++$seen === 3) { + break; + } + } + + // After break, cursor must be closed (finally clause); same transaction continues. + $total = (new Select($this->orm, User::class))->count(); + return ['seen' => $seen, 'total' => $total]; + }); + + $this->assertSame(3, $result['seen']); + $this->assertSame(50, $result['total']); + } + + public function testCursorRejectsLoadedRelation(): void + { + $this->skipUnlessPostgres(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('/relations/i'); + + $this->getDatabase()->transaction(function (): void { + $cursor = (new Select($this->orm, User::class)) + ->load('comments') + ->cursor(10); + foreach ($cursor as $_) { + // never reached — generator throws on first iteration + } + }); + } + + public function testCursorRejectsWithJoinedRelation(): void + { + $this->skipUnlessPostgres(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessageMatches('/with\(\)/i'); + + $this->getDatabase()->transaction(function (): void { + $cursor = (new Select($this->orm, User::class)) + ->with('comments') + ->cursor(10); + foreach ($cursor as $_) { + } + }); + } + + public function testCursorOnNonPostgresThrows(): void + { + if (static::DRIVER === 'postgres') { + $this->markTestSkipped('Postgres supports cursors — covered by other tests.'); + } + $this->fillUsers(1); + + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/cursors are not supported/i'); + + $this->getDatabase()->transaction(function (): void { + foreach ((new Select($this->orm, User::class))->cursor(10) as $_) { + } + }); + } + + public function setUp(): void + { + parent::setUp(); + + $this->makeTable('user', [ + 'id' => 'primary', + 'email' => 'string', + 'balance' => 'float', + ]); + + $this->makeTable('comment', [ + 'id' => 'primary', + 'user_id' => 'integer', + 'message' => 'string', + ]); + + $this->orm = $this->withSchema(new Schema([ + User::class => [ + Schema::ROLE => 'user', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'email', 'balance'], + Schema::TYPECAST => ['id' => 'int', 'balance' => 'float'], + Schema::SCHEMA => [], + Schema::RELATIONS => [ + 'comments' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => Comment::class, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => 'user_id', + ], + ], + ], + ], + Comment::class => [ + Schema::ROLE => 'comment', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'user_id', 'message'], + Schema::TYPECAST => ['id' => 'int', 'user_id' => 'int'], + Schema::SCHEMA => [], + Schema::RELATIONS => [], + ], + ])); + } + + private function fillUsers(int $count): void + { + // Each row's balance == its primary key * 100.0 (PK is auto-incremented from 1), + // so balance is aligned with id and tests can assert against ids/balances directly. + $rows = []; + for ($i = 1; $i <= $count; $i++) { + $rows[] = ["user-{$i}@example.com", $i * 100.0]; + } + if ($rows !== []) { + $this->getDatabase()->table('user')->insertMultiple(['email', 'balance'], $rows); + } + } + + private function countHeapEntries($heap, string $role): int + { + $count = 0; + foreach ($heap as $_) { + $count++; + } + return $count; + } +} diff --git a/tests/ORM/Functional/Driver/Postgres/Select/CursorTest.php b/tests/ORM/Functional/Driver/Postgres/Select/CursorTest.php new file mode 100644 index 000000000..57efe3c5d --- /dev/null +++ b/tests/ORM/Functional/Driver/Postgres/Select/CursorTest.php @@ -0,0 +1,17 @@ + Date: Mon, 11 May 2026 17:31:01 +0400 Subject: [PATCH 2/5] Support relations loading --- src/Select.php | 135 ++++++--- src/Select/AbstractLoader.php | 11 - src/Select/RootLoader.php | 47 +++- .../Driver/Common/Select/CursorTest.php | 258 +++++++++++++++++- .../Postgres/Inheritance/JTI/CursorTest.php | 80 ++++++ .../Postgres/Inheritance/STI/CursorTest.php | 44 +++ 6 files changed, 507 insertions(+), 68 deletions(-) create mode 100644 tests/ORM/Functional/Driver/Postgres/Inheritance/JTI/CursorTest.php create mode 100644 tests/ORM/Functional/Driver/Postgres/Inheritance/STI/CursorTest.php diff --git a/src/Select.php b/src/Select.php index 14743246f..dd1355a88 100644 --- a/src/Select.php +++ b/src/Select.php @@ -872,46 +872,83 @@ public function getIterator(bool $findInHeap = false): Iterator * Stream entities from the database using a server-side cursor. * * The returned generator pulls rows lazily, hydrates them into entities, and - * yields one entity at a time. Memory usage is bound by `$chunkSize` plus the - * heap (which the caller is responsible for clearing between batches via - * `$orm->getHeap()->clean()` if needed). - * - * Requirements and limits (MVP): + * yields one entity at a time. Memory usage is bound by `$chunkSize` parents + * plus the heap (which the caller is responsible for clearing between batches + * via `$orm->getHeap()->clean()` if needed). + * + * Chunking happens at **parent boundaries**, not strict row counts. When a + * relation is loaded inline and multiplies rows (HAS_MANY / MANY_TO_MANY), one + * parent corresponds to several rows. A chunk is closed only when a new parent + * PK appears in the stream, so a parent's full set of joined rows is never + * split. A chunk may therefore contain more than `$chunkSize` rows when + * children are present, but never more than `$chunkSize` distinct parents. + * + * For correctness with inline HAS_MANY / MANY_TO_MANY, rows of the same parent + * must arrive contiguously. The cursor appends the root primary key to the + * query's ORDER BY clause to guarantee this in the common case (no ORDER BY, + * or ORDER BY on parent columns). If you order by a joined child column, the + * scatter is not auto-fixed — order by parent columns instead. + * + * Requirements: * - Only Postgres is supported on the DBAL side. Other drivers throw a * {@see \Cycle\Database\Exception\DriverException}. - * - An active transaction is required on the underlying database before iteration - * starts. Wrap the iteration in `$database->transaction(...)` or call - * `beginTransaction()` before iterating. - * - Relations are not yet supported: any `load()`, `with()`, eager-loaded - * schema relations, or hierarchical (JTI/STI) entities cause a - * {@see \LogicException} when the generator is first iterated. + * - An active transaction is required on the underlying database before + * iteration starts. + * + * @param int<1, max> $chunkSize Maximum number of distinct parent entities + * per chunk; also the FETCH FORWARD size for the underlying cursor. * - * @param int<1, max> $chunkSize Number of rows fetched per round-trip; also the - * batch size used to flush the parser node and avoid unbounded growth. + * Entity identity: if a yielded row corresponds to a PK already attached to the + * heap, the same instance is returned. Fresh data from the current row is merged + * into previously-unresolved relations ({@see \Cycle\ORM\Reference\ReferenceInterface}) + * via {@see EntityFactoryInterface::make()}. * * @return \Generator */ public function cursor(int $chunkSize = 1000): \Generator { - $this->assertCursorCompatible(); - $query = $this->buildQuery(); + + // Append root PK to ORDER BY so rows for the same parent stay contiguous + // in the stream. PK is unique, so appending it never alters an existing + // user-defined order — it only resolves ties. + $pk = $this->loader->getPK(); + foreach ((array) $pk as $column) { + $query->orderBy($column); + } + $database = $this->loader->getSource()->getDatabase(); $role = $this->loader->getTarget(); $loader = $this->loader; + $extractPk = $this->buildPkExtractor(); - $rows = static function () use ($database, $query, $chunkSize, $loader): \Generator { + $rows = static function () use ($database, $query, $chunkSize, $loader, $extractPk): \Generator { $node = $loader->createNode(); - $count = 0; + $lastPk = null; + $hasLast = false; + $parentCount = 0; + foreach ($database->stream($query, $chunkSize, StatementInterface::FETCH_NUM) as $row) { - $node->parseRow(0, $row); - if (++$count >= $chunkSize) { - yield from $node->getResult(); - $node = $loader->createNode(); - $count = 0; + $rowPk = $extractPk($row); + + if (!$hasLast || $rowPk !== $lastPk) { + if ($parentCount >= $chunkSize) { + // Current row starts a new parent; previous chunk is fully complete. + $loader->loadChildren($node, true); + yield from $node->getResult(); + $node = $loader->createNode(); + $parentCount = 0; + } + $parentCount++; + $lastPk = $rowPk; + $hasLast = true; } + + $node->parseRow(0, $row); } - if ($count > 0) { + + if ($parentCount > 0) { + $loader->loadChildren($node, true); yield from $node->getResult(); } }; @@ -922,32 +959,52 @@ public function cursor(int $chunkSize = 1000): \Generator $this->entityFactory, $role, $rows(), + // Hardcoded false — do NOT expose as a parameter. Iterator's findInHeap=true + // takes a fast path that returns heap-attached entities untouched, skipping + // the merge of fresh row data into previously-unresolved Reference relations. + // For cursor streaming we always want the latest row to be merged in. findInHeap: false, typecast: true, ); } - private function assertCursorCompatible(): void + /** + * Build a closure that pulls the root primary key value out of a FETCH_NUM row + * produced by the cursor query. For composite PKs, the values are joined with a + * NUL separator into a single comparable string. + * + * @return \Closure(array): (int|string|float|null) + */ + private function buildPkExtractor(): \Closure { - if ($this->loader->getLoadedRelations() !== []) { - throw new \LogicException( - 'Cursor mode does not support relations yet. ' - . 'Remove load() calls and eager-loaded relations from the entity schema.', - ); + $columnNames = $this->loader->getColumnNames(); + $pkFields = $this->loader->getPrimaryFields(); + + $positions = []; + foreach ($pkFields as $field) { + $idx = \array_search($field, $columnNames, true); + if ($idx === false) { + throw new \LogicException(\sprintf( + 'Cursor cannot locate primary key column `%s` among root loader columns [%s].', + $field, + \implode(', ', $columnNames), + )); + } + $positions[] = $idx; } - if ($this->loader->getJoinedLoaders() !== []) { - throw new \LogicException( - 'Cursor mode does not support with()-joined relations yet.', - ); + if (\count($positions) === 1) { + $p = $positions[0]; + return static fn(array $row): int|string|float|null => $row[$p]; } - if ($this->loader->isHierarchical()) { - throw new \LogicException( - 'Cursor mode does not support hierarchical entities (JTI/STI) yet. ' - . 'Disable subclass loading with loadSubclasses(false) and avoid parent inheritance.', - ); - } + return static function (array $row) use ($positions): string { + $parts = []; + foreach ($positions as $i) { + $parts[] = (string) $row[$i]; + } + return \implode("\0", $parts); + }; } /** diff --git a/src/Select/AbstractLoader.php b/src/Select/AbstractLoader.php index e8bc11226..e3cb4b307 100644 --- a/src/Select/AbstractLoader.php +++ b/src/Select/AbstractLoader.php @@ -304,17 +304,6 @@ public function getJoinedLoaders(): array return $this->join; } - /** - * Returns all loaders that load data for relations of the current loader - * (both inline-joined and post-loaded). - * - * @return LoaderInterface[] - */ - public function getLoadedRelations(): array - { - return $this->load; - } - /** * Indicates that loader loads data. */ diff --git a/src/Select/RootLoader.php b/src/Select/RootLoader.php index 33d11dccf..56ec3c222 100644 --- a/src/Select/RootLoader.php +++ b/src/Select/RootLoader.php @@ -102,6 +102,20 @@ public function getQuery(): SelectQuery return $this->query; } + /** + * Return the ordered list of column names produced by the root loader's + * SELECT clause. Positions in this list correspond to positions in a + * FETCH_NUM row from {@see buildQuery()} — useful for callers that want + * to inspect raw rows before they reach the parser (e.g. cursor-based + * streaming with parent-boundary chunking). + * + * @return non-empty-string[] + */ + public function getColumnNames(): array + { + return $this->columnNames(); + } + /** * Compile query with all needed conditions, columns and etc. */ @@ -114,13 +128,38 @@ public function loadData(AbstractNode $node, bool $includeRole = false): void { $statement = $this->buildQuery()->run(); - foreach ($statement->fetchAll(StatementInterface::FETCH_NUM) as $row) { - $node->parseRow(0, $row); - } + $this->parseRows($node, $statement->fetchAll(StatementInterface::FETCH_NUM)); $statement->close(); - // loading child datasets + $this->loadChildren($node, $includeRole); + } + + /** + * Push a batch of raw rows through the parser into the given node. + * + * Separated from {@see loadData()} so callers that produce their own row stream + * (e.g. cursor-based streaming) can reuse the parsing step independently of + * query execution and child-loader orchestration. + * + * @param iterable> $rows Rows in FETCH_NUM (positional) shape. + */ + public function parseRows(AbstractNode $node, iterable $rows): void + { + foreach ($rows as $row) { + $node->parseRow(0, $row); + } + } + + /** + * Run all child loaders (POSTLOAD relations, inheritance) against the given node. + * + * Designed to be called after {@see parseRows()} on a node that already holds + * the parent rows of a chunk. Child loaders aggregate parent keys from the node's + * index and issue their own queries. + */ + public function loadChildren(AbstractNode $node, bool $includeRole = false): void + { foreach ($this->load as $relation => $loader) { $loader->loadData($node->getNode($relation), $includeRole); } diff --git a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php index 7353dce64..dc8a7eaa7 100644 --- a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -12,6 +12,7 @@ use Cycle\ORM\Select; use Cycle\ORM\Tests\Functional\Driver\Common\BaseTest; use Cycle\ORM\Tests\Fixtures\Comment; +use Cycle\ORM\Tests\Fixtures\Profile; use Cycle\ORM\Tests\Fixtures\User; use Cycle\ORM\Tests\Traits\TableTrait; @@ -132,6 +133,29 @@ public function testCursorRegistersEntitiesInHeap(): void }); } + public function testCursorReturnsHeapAttachedInstanceForDuplicatePk(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(3); + + // Pre-load user 2 — it's now attached to the heap. + $pre = (new Select($this->orm, User::class))->wherePK(2)->fetchOne(); + $this->assertNotNull($pre); + + $fromCursor = $this->getDatabase()->transaction(function () { + $found = null; + foreach ((new Select($this->orm, User::class))->orderBy('id')->cursor(10) as $user) { + if ($user->id === 2) { + $found = $user; + break; + } + } + return $found; + }); + + $this->assertSame($pre, $fromCursor, 'Cursor must reuse the heap-attached instance for duplicate PKs'); + } + public function testCursorAllowsHeapCleanBetweenChunks(): void { $this->skipUnlessPostgres(); @@ -215,37 +239,183 @@ public function testCursorSupportsEarlyBreak(): void $this->assertSame(50, $result['total']); } - public function testCursorRejectsLoadedRelation(): void + public function testCursorPostloadHasMany(): void { $this->skipUnlessPostgres(); + $this->fillUsers(3); + $this->fillCommentsForUsers([1 => 4, 2 => 3, 3 => 0]); - $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('/relations/i'); - - $this->getDatabase()->transaction(function (): void { + $byUser = $this->getDatabase()->transaction(function (): array { + $out = []; $cursor = (new Select($this->orm, User::class)) ->load('comments') - ->cursor(10); - foreach ($cursor as $_) { - // never reached — generator throws on first iteration + ->orderBy('id') + ->cursor(2); + foreach ($cursor as $user) { + $out[$user->id] = \array_map(fn($c) => $c->message, \iterator_to_array((function () use ($user) { + foreach ($user->comments as $c) { + yield $c; + } + })())); } + return $out; }); + + $this->assertSame(['msg 1-1', 'msg 1-2', 'msg 1-3', 'msg 1-4'], $byUser[1]); + $this->assertSame(['msg 2-1', 'msg 2-2', 'msg 2-3'], $byUser[2]); + $this->assertSame([], $byUser[3]); } - public function testCursorRejectsWithJoinedRelation(): void + public function testCursorInlineHasOne(): void { $this->skipUnlessPostgres(); + $this->fillUsers(3); + $this->fillProfileForUsers([1 => 'profile-1.png', 3 => 'profile-3.png']); - $this->expectException(\LogicException::class); - $this->expectExceptionMessageMatches('/with\(\)/i'); + $images = $this->getDatabase()->transaction(function (): array { + $out = []; + $cursor = (new Select($this->orm, User::class)) + ->load('profile', ['method' => Select::SINGLE_QUERY]) + ->orderBy('id') + ->cursor(2); + foreach ($cursor as $user) { + $out[$user->id] = $user->profile?->image; + } + return $out; + }); - $this->getDatabase()->transaction(function (): void { + $this->assertSame(['profile-1.png', null, 'profile-3.png'], \array_values($images)); + } + + public function testCursorInlineBelongsTo(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(2); + $this->fillCommentsForUsers([1 => 2, 2 => 1]); + + $rows = $this->getDatabase()->transaction(function (): array { + $out = []; + $cursor = (new Select($this->orm, Comment::class)) + ->load('user', ['method' => Select::SINGLE_QUERY]) + ->orderBy('id') + ->cursor(2); + foreach ($cursor as $comment) { + $out[] = [$comment->message, $comment->user->id, $comment->user->email]; + } + return $out; + }); + + $this->assertSame( + [ + ['msg 1-1', 1, 'user-1@example.com'], + ['msg 1-2', 1, 'user-1@example.com'], + ['msg 2-1', 2, 'user-2@example.com'], + ], + $rows, + ); + } + + public function testCursorAllowsWithOnNonMultiplyingRelation(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(2); + $this->fillCommentsForUsers([1 => 2, 2 => 1]); + + $messages = $this->getDatabase()->transaction(function (): array { + $cursor = (new Select($this->orm, Comment::class)) + ->with('user') + ->where('user.id', 2) + ->orderBy('comment.id') + ->cursor(10); + $out = []; + foreach ($cursor as $comment) { + $out[] = $comment->message; + } + return $out; + }); + + $this->assertSame(['msg 2-1'], $messages); + } + + public function testCursorInlineHasManySingleQuery(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(3); + $this->fillCommentsForUsers([1 => 4, 2 => 3, 3 => 0]); + + $byUser = $this->getDatabase()->transaction(function (): array { + $out = []; $cursor = (new Select($this->orm, User::class)) - ->with('comments') + ->load('comments', ['method' => Select::SINGLE_QUERY]) ->cursor(10); - foreach ($cursor as $_) { + foreach ($cursor as $user) { + $out[$user->id] = \array_map(fn($c) => $c->message, (function () use ($user) { + $r = []; + foreach ($user->comments as $c) { + $r[] = $c; + } + return $r; + })()); } + return $out; }); + + $this->assertSame(['msg 1-1', 'msg 1-2', 'msg 1-3', 'msg 1-4'], $byUser[1]); + $this->assertSame(['msg 2-1', 'msg 2-2', 'msg 2-3'], $byUser[2]); + $this->assertSame([], $byUser[3]); + } + + public function testCursorInlineHasManyAcrossChunkBoundary(): void + { + $this->skipUnlessPostgres(); + // 3 parents straddle a chunk boundary; user_2 has more children than fit in one chunk. + $this->fillUsers(3); + $this->fillCommentsForUsers([1 => 2, 2 => 10, 3 => 1]); + + $byUser = $this->getDatabase()->transaction(function (): array { + $out = []; + $cursor = (new Select($this->orm, User::class)) + ->load('comments', ['method' => Select::SINGLE_QUERY]) + ->cursor(2); // chunk = 2 parents → users [1,2] / [3] + foreach ($cursor as $user) { + $msgs = []; + foreach ($user->comments as $c) { + $msgs[] = $c->message; + } + $out[$user->id] = $msgs; + } + return $out; + }); + + $this->assertCount(2, $byUser[1]); + $this->assertCount(10, $byUser[2]); + $this->assertCount(1, $byUser[3]); + $this->assertSame('msg 2-10', $byUser[2][9]); + } + + public function testCursorInlineHasManyDoesNotDuplicateParents(): void + { + $this->skipUnlessPostgres(); + $this->fillUsers(5); + $this->fillCommentsForUsers([1 => 3, 2 => 4, 3 => 2, 4 => 5, 5 => 1]); + + $count = $this->getDatabase()->transaction(function (): int { + $seen = []; + $cursor = (new Select($this->orm, User::class)) + ->load('comments', ['method' => Select::SINGLE_QUERY]) + ->cursor(2); + foreach ($cursor as $user) { + $this->assertArrayNotHasKey( + $user->id, + $seen, + "User id={$user->id} was yielded more than once", + ); + $seen[$user->id] = true; + } + return \count($seen); + }); + + $this->assertSame(5, $count); } public function testCursorOnNonPostgresThrows(): void @@ -280,6 +450,12 @@ public function setUp(): void 'message' => 'string', ]); + $this->makeTable('profile', [ + 'id' => 'primary', + 'user_id' => 'integer', + 'image' => 'string', + ]); + $this->orm = $this->withSchema(new Schema([ User::class => [ Schema::ROLE => 'user', @@ -300,6 +476,16 @@ public function setUp(): void Relation::OUTER_KEY => 'user_id', ], ], + 'profile' => [ + Relation::TYPE => Relation::HAS_ONE, + Relation::TARGET => Profile::class, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::NULLABLE => true, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => 'user_id', + ], + ], ], ], Comment::class => [ @@ -311,11 +497,55 @@ public function setUp(): void Schema::COLUMNS => ['id', 'user_id', 'message'], Schema::TYPECAST => ['id' => 'int', 'user_id' => 'int'], Schema::SCHEMA => [], + Schema::RELATIONS => [ + 'user' => [ + Relation::TYPE => Relation::BELONGS_TO, + Relation::TARGET => User::class, + Relation::SCHEMA => [ + Relation::INNER_KEY => 'user_id', + Relation::OUTER_KEY => 'id', + ], + ], + ], + ], + Profile::class => [ + Schema::ROLE => 'profile', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'profile', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'user_id', 'image'], + Schema::TYPECAST => ['id' => 'int', 'user_id' => 'int'], + Schema::SCHEMA => [], Schema::RELATIONS => [], ], ])); } + private function fillCommentsForUsers(array $userIdToCount): void + { + $rows = []; + foreach ($userIdToCount as $userId => $count) { + for ($i = 1; $i <= $count; $i++) { + $rows[] = [$userId, "msg {$userId}-{$i}"]; + } + } + if ($rows !== []) { + $this->getDatabase()->table('comment')->insertMultiple(['user_id', 'message'], $rows); + } + } + + private function fillProfileForUsers(array $userIdToImage): void + { + $rows = []; + foreach ($userIdToImage as $userId => $image) { + $rows[] = [$userId, $image]; + } + if ($rows !== []) { + $this->getDatabase()->table('profile')->insertMultiple(['user_id', 'image'], $rows); + } + } + private function fillUsers(int $count): void { // Each row's balance == its primary key * 100.0 (PK is auto-incremented from 1), diff --git a/tests/ORM/Functional/Driver/Postgres/Inheritance/JTI/CursorTest.php b/tests/ORM/Functional/Driver/Postgres/Inheritance/JTI/CursorTest.php new file mode 100644 index 000000000..d173b309e --- /dev/null +++ b/tests/ORM/Functional/Driver/Postgres/Inheritance/JTI/CursorTest.php @@ -0,0 +1,80 @@ +getDatabase()->transaction(function (): array { + $out = []; + $cursor = (new Select($this->orm, static::EMPLOYEE_ROLE)) + ->orderBy('id') + ->cursor(2); + foreach ($cursor as $entity) { + $out[] = $entity; + } + return $out; + }); + + $this->assertCount(4, $entities); + + // id=1 → Manager (rank='top') + $this->assertInstanceOf(Manager::class, $entities[0]); + $this->assertSame('top', $entities[0]->rank); + + // id=2 → Programator (engineer.level=8, programator.language='php') + $this->assertInstanceOf(Programator::class, $entities[1]); + $this->assertSame(8, $entities[1]->level); + $this->assertSame('php', $entities[1]->language); + + // id=3 → Manager (rank='bottom') + $this->assertInstanceOf(Manager::class, $entities[2]); + $this->assertSame('bottom', $entities[2]->rank); + + // id=4 → Programator (engineer.level=10, programator.language='go') + $this->assertInstanceOf(Programator::class, $entities[3]); + $this->assertSame(10, $entities[3]->level); + $this->assertSame('go', $entities[3]->language); + } + + public function testCursorOnChildRoleReturnsFullParentColumns(): void + { + $entities = $this->getDatabase()->transaction(function (): array { + $out = []; + // Walk just the programator role — should include parent columns from employee + engineer. + $cursor = (new Select($this->orm, static::PROGRAMATOR_ROLE)) + ->orderBy('id') + ->cursor(10); + foreach ($cursor as $entity) { + $out[] = $entity; + } + return $out; + }); + + $this->assertCount(2, $entities); + foreach ($entities as $programator) { + $this->assertInstanceOf(Programator::class, $programator); + // Parent columns are populated + $this->assertNotEmpty($programator->name); + $this->assertNotNull($programator->age); + $this->assertGreaterThan(0, $programator->level); + $this->assertNotEmpty($programator->language); + } + } +} diff --git a/tests/ORM/Functional/Driver/Postgres/Inheritance/STI/CursorTest.php b/tests/ORM/Functional/Driver/Postgres/Inheritance/STI/CursorTest.php new file mode 100644 index 000000000..55042f05f --- /dev/null +++ b/tests/ORM/Functional/Driver/Postgres/Inheritance/STI/CursorTest.php @@ -0,0 +1,44 @@ +getDatabase()->transaction(function (): array { + $out = []; + foreach ((new Select($this->orm, Employee::class))->cursor(2) as $entity) { + $out[] = $entity; + } + return $out; + }); + + $this->assertCount(4, $entities); + + $this->assertInstanceOf(Manager::class, $entities[0]); + + $this->assertInstanceOf(Employee::class, $entities[1]); + $this->assertNotInstanceOf(Manager::class, $entities[1]); + + $this->assertInstanceOf(Manager::class, $entities[2]); + + $this->assertInstanceOf(Employee::class, $entities[3]); + $this->assertNotInstanceOf(Manager::class, $entities[3]); + + $this->assertSame([1, 2, 3, 4], \array_map(fn($e) => $e->id, $entities)); + } +} From be0bdedddcbaa6782ba3e183c8a462825011a939 Mon Sep 17 00:00:00 2001 From: github-actions Date: Mon, 11 May 2026 16:15:11 +0000 Subject: [PATCH 3/5] style(php-cs-fixer): fix coding standards --- src/Select.php | 78 +++++++++---------- .../Driver/Common/Select/CursorTest.php | 26 +++---- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/src/Select.php b/src/Select.php index dd1355a88..7405a5a3c 100644 --- a/src/Select.php +++ b/src/Select.php @@ -968,45 +968,6 @@ public function cursor(int $chunkSize = 1000): \Generator ); } - /** - * Build a closure that pulls the root primary key value out of a FETCH_NUM row - * produced by the cursor query. For composite PKs, the values are joined with a - * NUL separator into a single comparable string. - * - * @return \Closure(array): (int|string|float|null) - */ - private function buildPkExtractor(): \Closure - { - $columnNames = $this->loader->getColumnNames(); - $pkFields = $this->loader->getPrimaryFields(); - - $positions = []; - foreach ($pkFields as $field) { - $idx = \array_search($field, $columnNames, true); - if ($idx === false) { - throw new \LogicException(\sprintf( - 'Cursor cannot locate primary key column `%s` among root loader columns [%s].', - $field, - \implode(', ', $columnNames), - )); - } - $positions[] = $idx; - } - - if (\count($positions) === 1) { - $p = $positions[0]; - return static fn(array $row): int|string|float|null => $row[$p]; - } - - return static function (array $row) use ($positions): string { - $parts = []; - foreach ($positions as $i) { - $parts[] = (string) $row[$i]; - } - return \implode("\0", $parts); - }; - } - /** * Load data tree from database and linked loaders in a form of array. * @@ -1081,6 +1042,45 @@ protected function loadData(bool $addRole = true): array return $node->getResult(); } + /** + * Build a closure that pulls the root primary key value out of a FETCH_NUM row + * produced by the cursor query. For composite PKs, the values are joined with a + * NUL separator into a single comparable string. + * + * @return \Closure(array): (int|string|float|null) + */ + private function buildPkExtractor(): \Closure + { + $columnNames = $this->loader->getColumnNames(); + $pkFields = $this->loader->getPrimaryFields(); + + $positions = []; + foreach ($pkFields as $field) { + $idx = \array_search($field, $columnNames, true); + if ($idx === false) { + throw new \LogicException(\sprintf( + 'Cursor cannot locate primary key column `%s` among root loader columns [%s].', + $field, + \implode(', ', $columnNames), + )); + } + $positions[] = $idx; + } + + if (\count($positions) === 1) { + $p = $positions[0]; + return static fn(array $row): int|string|float|null => $row[$p]; + } + + return static function (array $row) use ($positions): string { + $parts = []; + foreach ($positions as $i) { + $parts[] = (string) $row[$i]; + } + return \implode("\0", $parts); + }; + } + /** * @param list $pk * @param list $args diff --git a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php index dc8a7eaa7..c3f71bf46 100644 --- a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -20,19 +20,6 @@ abstract class CursorTest extends BaseTest { use TableTrait; - /** - * MVP: cursor is Postgres-only. On other drivers the underlying DBAL stream() throws, - * and there is nothing meaningful to assert beyond that — the negative case is already - * covered by cycle/database's own functional suite. This guard keeps the suite green - * on sqlite/mysql/sqlserver while still exercising the Postgres path. - */ - protected function skipUnlessPostgres(): void - { - if (static::DRIVER !== 'postgres') { - $this->markTestSkipped('Cursor mode is only supported on Postgres in the MVP.'); - } - } - public function testCursorYieldsAllRowsInOrder(): void { $this->skipUnlessPostgres(); @@ -522,6 +509,19 @@ public function setUp(): void ])); } + /** + * MVP: cursor is Postgres-only. On other drivers the underlying DBAL stream() throws, + * and there is nothing meaningful to assert beyond that — the negative case is already + * covered by cycle/database's own functional suite. This guard keeps the suite green + * on sqlite/mysql/sqlserver while still exercising the Postgres path. + */ + protected function skipUnlessPostgres(): void + { + if (static::DRIVER !== 'postgres') { + $this->markTestSkipped('Cursor mode is only supported on Postgres in the MVP.'); + } + } + private function fillCommentsForUsers(array $userIdToCount): void { $rows = []; From 61b6fc9d5bbeff5acafa7b13f6ab8d2b767f3479 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 12 May 2026 19:09:10 +0400 Subject: [PATCH 4/5] Update cycle/database dependency and add SQL Server cursor tests --- composer.json | 3 +- src/Select.php | 23 +++++++--- .../Driver/Common/Select/CursorTest.php | 46 +++++-------------- .../Driver/SQLServer/Select/CursorTest.php | 17 +++++++ 4 files changed, 47 insertions(+), 42 deletions(-) create mode 100644 tests/ORM/Functional/Driver/SQLServer/Select/CursorTest.php diff --git a/composer.json b/composer.json index b91717d01..11054c61d 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ ], "require": { "php": ">=8.1", - "cycle/database": "^2.8.1", + "cycle/database": "^2.17.0", "doctrine/instantiator": "^1.3.1 || ^2.0", "spiral/core": "^2.8 || ^3.0" }, @@ -52,6 +52,7 @@ "mockery/mockery": "^1.1", "phpunit/phpunit": "^9.5", "ramsey/uuid": "^4.0", + "roxblnfk/unpoly": "^1.8", "spiral/code-style": "~2.2.0", "spiral/tokenizer": "^2.8 || ^3.0", "vimeo/psalm": "^6.0" diff --git a/src/Select.php b/src/Select.php index 7405a5a3c..c19e04db7 100644 --- a/src/Select.php +++ b/src/Select.php @@ -4,6 +4,7 @@ namespace Cycle\ORM; +use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\SelectQuery; @@ -890,13 +891,23 @@ public function getIterator(bool $findInHeap = false): Iterator * scatter is not auto-fixed — order by parent columns instead. * * Requirements: - * - Only Postgres is supported on the DBAL side. Other drivers throw a - * {@see \Cycle\Database\Exception\DriverException}. + * - The underlying driver must implement {@see \Cycle\Database\Driver\CursorableInterface} + * (Postgres, SQLite, SQL Server). Other drivers throw a {@see \Cycle\Database\Exception\DriverException}. * - An active transaction is required on the underlying database before * iteration starts. * * @param int<1, max> $chunkSize Maximum number of distinct parent entities - * per chunk; also the FETCH FORWARD size for the underlying cursor. + * per chunk. Controls when the ORM flushes the parser node and yields + * a batch of hydrated entities. This is **independent** of any + * DBAL-level chunk knob (e.g. Postgres `FETCH FORWARD N`), which is + * configured via `$options`. + * @param CursorOptions|null $options Driver-specific cursor configuration forwarded + * to {@see \Cycle\Database\Database::cursor()}. Use driver-specific subclasses + * ({@see \Cycle\Database\Driver\Postgres\PostgresCursorOptions}, + * {@see \Cycle\Database\Driver\SQLServer\SQLServerCursorOptions}) to tune + * Postgres FETCH FORWARD size, WITH HOLD, SQL Server cursor type, etc. + * Row fetch mode is forced to `FETCH_NUM` internally — the parser expects + * positional rows. * * Entity identity: if a yielded row corresponds to a PK already attached to the * heap, the same instance is returned. Fresh data from the current row is merged @@ -905,7 +916,7 @@ public function getIterator(bool $findInHeap = false): Iterator * * @return \Generator */ - public function cursor(int $chunkSize = 1000): \Generator + public function cursor(int $chunkSize = 1000, CursorOptions $options = new CursorOptions()): \Generator { $query = $this->buildQuery(); @@ -922,13 +933,13 @@ public function cursor(int $chunkSize = 1000): \Generator $loader = $this->loader; $extractPk = $this->buildPkExtractor(); - $rows = static function () use ($database, $query, $chunkSize, $loader, $extractPk): \Generator { + $rows = static function () use ($database, $query, $chunkSize, $loader, $extractPk, $options): \Generator { $node = $loader->createNode(); $lastPk = null; $hasLast = false; $parentCount = 0; - foreach ($database->stream($query, $chunkSize, StatementInterface::FETCH_NUM) as $row) { + foreach ($database->cursor($query, $options, StatementInterface::FETCH_NUM) as $row) { $rowPk = $extractPk($row); if (!$hasLast || $rowPk !== $lastPk) { diff --git a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php index c3f71bf46..a4723a85a 100644 --- a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -4,6 +4,7 @@ namespace Cycle\ORM\Tests\Functional\Driver\Common\Select; +use Cycle\Database\Driver\CursorableInterface; use Cycle\Database\Exception\DriverException; use Cycle\ORM\Heap\Node; use Cycle\ORM\Mapper\Mapper; @@ -15,14 +16,15 @@ use Cycle\ORM\Tests\Fixtures\Profile; use Cycle\ORM\Tests\Fixtures\User; use Cycle\ORM\Tests\Traits\TableTrait; +use Cycle\ORM\Tests\Util\DontGenerateAttribute; +#[DontGenerateAttribute] abstract class CursorTest extends BaseTest { use TableTrait; public function testCursorYieldsAllRowsInOrder(): void { - $this->skipUnlessPostgres(); $this->fillUsers(7); $emails = $this->getDatabase()->transaction(function (): array { @@ -41,7 +43,6 @@ public function testCursorYieldsAllRowsInOrder(): void public function testCursorChunkSmallerThanTotal(): void { - $this->skipUnlessPostgres(); $this->fillUsers(25); $count = $this->getDatabase()->transaction(function (): int { @@ -57,7 +58,6 @@ public function testCursorChunkSmallerThanTotal(): void public function testCursorChunkExactlyMatchesTotal(): void { - $this->skipUnlessPostgres(); $this->fillUsers(10); $count = $this->getDatabase()->transaction(function (): int { @@ -73,7 +73,6 @@ public function testCursorChunkExactlyMatchesTotal(): void public function testCursorOnEmptyTable(): void { - $this->skipUnlessPostgres(); $items = $this->getDatabase()->transaction(function (): array { $out = []; @@ -88,7 +87,6 @@ public function testCursorOnEmptyTable(): void public function testCursorTypecastsScalars(): void { - $this->skipUnlessPostgres(); $this->fillUsers(1); $this->getDatabase()->transaction(function (): void { @@ -102,7 +100,6 @@ public function testCursorTypecastsScalars(): void public function testCursorRegistersEntitiesInHeap(): void { - $this->skipUnlessPostgres(); $this->fillUsers(3); $this->getDatabase()->transaction(function (): void { @@ -122,7 +119,6 @@ public function testCursorRegistersEntitiesInHeap(): void public function testCursorReturnsHeapAttachedInstanceForDuplicatePk(): void { - $this->skipUnlessPostgres(); $this->fillUsers(3); // Pre-load user 2 — it's now attached to the heap. @@ -145,7 +141,6 @@ public function testCursorReturnsHeapAttachedInstanceForDuplicatePk(): void public function testCursorAllowsHeapCleanBetweenChunks(): void { - $this->skipUnlessPostgres(); $this->fillUsers(20); $this->getDatabase()->transaction(function (): void { @@ -172,7 +167,6 @@ public function testCursorAllowsHeapCleanBetweenChunks(): void public function testCursorRequiresActiveTransaction(): void { - $this->skipUnlessPostgres(); $this->fillUsers(2); $this->expectException(DriverException::class); @@ -185,7 +179,6 @@ public function testCursorRequiresActiveTransaction(): void public function testCursorRespectsWhereAndOrderBy(): void { - $this->skipUnlessPostgres(); $this->fillUsers(10); $ids = $this->getDatabase()->transaction(function (): array { @@ -206,7 +199,6 @@ public function testCursorRespectsWhereAndOrderBy(): void public function testCursorSupportsEarlyBreak(): void { - $this->skipUnlessPostgres(); $this->fillUsers(50); $result = $this->getDatabase()->transaction(function (): array { @@ -228,7 +220,6 @@ public function testCursorSupportsEarlyBreak(): void public function testCursorPostloadHasMany(): void { - $this->skipUnlessPostgres(); $this->fillUsers(3); $this->fillCommentsForUsers([1 => 4, 2 => 3, 3 => 0]); @@ -255,7 +246,6 @@ public function testCursorPostloadHasMany(): void public function testCursorInlineHasOne(): void { - $this->skipUnlessPostgres(); $this->fillUsers(3); $this->fillProfileForUsers([1 => 'profile-1.png', 3 => 'profile-3.png']); @@ -276,7 +266,6 @@ public function testCursorInlineHasOne(): void public function testCursorInlineBelongsTo(): void { - $this->skipUnlessPostgres(); $this->fillUsers(2); $this->fillCommentsForUsers([1 => 2, 2 => 1]); @@ -304,7 +293,6 @@ public function testCursorInlineBelongsTo(): void public function testCursorAllowsWithOnNonMultiplyingRelation(): void { - $this->skipUnlessPostgres(); $this->fillUsers(2); $this->fillCommentsForUsers([1 => 2, 2 => 1]); @@ -326,7 +314,6 @@ public function testCursorAllowsWithOnNonMultiplyingRelation(): void public function testCursorInlineHasManySingleQuery(): void { - $this->skipUnlessPostgres(); $this->fillUsers(3); $this->fillCommentsForUsers([1 => 4, 2 => 3, 3 => 0]); @@ -354,7 +341,6 @@ public function testCursorInlineHasManySingleQuery(): void public function testCursorInlineHasManyAcrossChunkBoundary(): void { - $this->skipUnlessPostgres(); // 3 parents straddle a chunk boundary; user_2 has more children than fit in one chunk. $this->fillUsers(3); $this->fillCommentsForUsers([1 => 2, 2 => 10, 3 => 1]); @@ -362,7 +348,10 @@ public function testCursorInlineHasManyAcrossChunkBoundary(): void $byUser = $this->getDatabase()->transaction(function (): array { $out = []; $cursor = (new Select($this->orm, User::class)) - ->load('comments', ['method' => Select::SINGLE_QUERY]) + ->load('comments', [ + 'method' => Select::SINGLE_QUERY, + 'orderBy' => ['@.id' => 'ASC'], + ]) ->cursor(2); // chunk = 2 parents → users [1,2] / [3] foreach ($cursor as $user) { $msgs = []; @@ -377,12 +366,12 @@ public function testCursorInlineHasManyAcrossChunkBoundary(): void $this->assertCount(2, $byUser[1]); $this->assertCount(10, $byUser[2]); $this->assertCount(1, $byUser[3]); + $this->assertSame('msg 2-1', $byUser[2][0]); $this->assertSame('msg 2-10', $byUser[2][9]); } public function testCursorInlineHasManyDoesNotDuplicateParents(): void { - $this->skipUnlessPostgres(); $this->fillUsers(5); $this->fillCommentsForUsers([1 => 3, 2 => 4, 3 => 2, 4 => 5, 5 => 1]); @@ -405,10 +394,10 @@ public function testCursorInlineHasManyDoesNotDuplicateParents(): void $this->assertSame(5, $count); } - public function testCursorOnNonPostgresThrows(): void + public function testCursorOnNonCursorableDriverThrows(): void { - if (static::DRIVER === 'postgres') { - $this->markTestSkipped('Postgres supports cursors — covered by other tests.'); + if ($this->getDatabase()->getDriver() instanceof CursorableInterface) { + $this->markTestSkipped('Driver supports cursors — covered by other tests.'); } $this->fillUsers(1); @@ -509,19 +498,6 @@ public function setUp(): void ])); } - /** - * MVP: cursor is Postgres-only. On other drivers the underlying DBAL stream() throws, - * and there is nothing meaningful to assert beyond that — the negative case is already - * covered by cycle/database's own functional suite. This guard keeps the suite green - * on sqlite/mysql/sqlserver while still exercising the Postgres path. - */ - protected function skipUnlessPostgres(): void - { - if (static::DRIVER !== 'postgres') { - $this->markTestSkipped('Cursor mode is only supported on Postgres in the MVP.'); - } - } - private function fillCommentsForUsers(array $userIdToCount): void { $rows = []; diff --git a/tests/ORM/Functional/Driver/SQLServer/Select/CursorTest.php b/tests/ORM/Functional/Driver/SQLServer/Select/CursorTest.php new file mode 100644 index 000000000..082c530e3 --- /dev/null +++ b/tests/ORM/Functional/Driver/SQLServer/Select/CursorTest.php @@ -0,0 +1,17 @@ + Date: Thu, 14 May 2026 12:09:10 +0400 Subject: [PATCH 5/5] fix: update interface references from `CursorableInterface` to `CursorInterface` --- src/Select.php | 2 +- tests/ORM/Functional/Driver/Common/Select/CursorTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Select.php b/src/Select.php index c19e04db7..0b4c8aadf 100644 --- a/src/Select.php +++ b/src/Select.php @@ -891,7 +891,7 @@ public function getIterator(bool $findInHeap = false): Iterator * scatter is not auto-fixed — order by parent columns instead. * * Requirements: - * - The underlying driver must implement {@see \Cycle\Database\Driver\CursorableInterface} + * - The underlying driver must implement {@see \Cycle\Database\Driver\CursorInterface} * (Postgres, SQLite, SQL Server). Other drivers throw a {@see \Cycle\Database\Exception\DriverException}. * - An active transaction is required on the underlying database before * iteration starts. diff --git a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php index a4723a85a..3f1a171a1 100644 --- a/tests/ORM/Functional/Driver/Common/Select/CursorTest.php +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -4,7 +4,7 @@ namespace Cycle\ORM\Tests\Functional\Driver\Common\Select; -use Cycle\Database\Driver\CursorableInterface; +use Cycle\Database\Driver\CursorInterface; use Cycle\Database\Exception\DriverException; use Cycle\ORM\Heap\Node; use Cycle\ORM\Mapper\Mapper; @@ -396,7 +396,7 @@ public function testCursorInlineHasManyDoesNotDuplicateParents(): void public function testCursorOnNonCursorableDriverThrows(): void { - if ($this->getDatabase()->getDriver() instanceof CursorableInterface) { + if ($this->getDatabase()->getDriver() instanceof CursorInterface) { $this->markTestSkipped('Driver supports cursors — covered by other tests.'); } $this->fillUsers(1);