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 27fa735e3..0b4c8aadf 100644 --- a/src/Select.php +++ b/src/Select.php @@ -4,9 +4,11 @@ namespace Cycle\ORM; +use Cycle\Database\Driver\CursorOptions; 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 +869,116 @@ 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` 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: + * - 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. + * + * @param int<1, max> $chunkSize Maximum number of distinct parent entities + * 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 + * into previously-unresolved relations ({@see \Cycle\ORM\Reference\ReferenceInterface}) + * via {@see EntityFactoryInterface::make()}. + * + * @return \Generator + */ + public function cursor(int $chunkSize = 1000, CursorOptions $options = new CursorOptions()): \Generator + { + $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, $extractPk, $options): \Generator { + $node = $loader->createNode(); + $lastPk = null; + $hasLast = false; + $parentCount = 0; + + foreach ($database->cursor($query, $options, StatementInterface::FETCH_NUM) as $row) { + $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 ($parentCount > 0) { + $loader->loadChildren($node, true); + yield from $node->getResult(); + } + }; + + yield from Iterator::createWithServices( + $this->heap, + $this->schema, + $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, + ); + } + /** * Load data tree from database and linked loaders in a form of array. * @@ -941,6 +1053,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/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 new file mode 100644 index 000000000..3f1a171a1 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Select/CursorTest.php @@ -0,0 +1,546 @@ +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->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->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 + { + + $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->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->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 testCursorReturnsHeapAttachedInstanceForDuplicatePk(): void + { + $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->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->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->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->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 testCursorPostloadHasMany(): void + { + $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)) + ->load('comments') + ->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 testCursorInlineHasOne(): void + { + $this->fillUsers(3); + $this->fillProfileForUsers([1 => 'profile-1.png', 3 => 'profile-3.png']); + + $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->assertSame(['profile-1.png', null, 'profile-3.png'], \array_values($images)); + } + + public function testCursorInlineBelongsTo(): void + { + $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->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->fillUsers(3); + $this->fillCommentsForUsers([1 => 4, 2 => 3, 3 => 0]); + + $byUser = $this->getDatabase()->transaction(function (): array { + $out = []; + $cursor = (new Select($this->orm, User::class)) + ->load('comments', ['method' => Select::SINGLE_QUERY]) + ->cursor(10); + 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 + { + // 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, + 'orderBy' => ['@.id' => 'ASC'], + ]) + ->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-1', $byUser[2][0]); + $this->assertSame('msg 2-10', $byUser[2][9]); + } + + public function testCursorInlineHasManyDoesNotDuplicateParents(): void + { + $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 testCursorOnNonCursorableDriverThrows(): void + { + if ($this->getDatabase()->getDriver() instanceof CursorInterface) { + $this->markTestSkipped('Driver 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->makeTable('profile', [ + 'id' => 'primary', + 'user_id' => 'integer', + 'image' => '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', + ], + ], + '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 => [ + 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 => [ + '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), + // 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/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)); + } +} 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 @@ +