diff --git a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php index 2cb9f29bd..5b3649386 100644 --- a/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php +++ b/src/adapter/etl-adapter-postgresql/src/Flow/ETL/Adapter/PostgreSql/PostgreSqlCursorExtractor.php @@ -61,10 +61,15 @@ public function extract(FlowContext $context) : \Generator while (true) { $cursor = $this->client->cursor(fetch($cursorName)->forward($this->fetchSize)); - $hasRows = false; + $rowCount = $cursor->count(); + + if ($rowCount === 0) { + $cursor->free(); + + break; + } foreach ($cursor->iterate() as $row) { - $hasRows = true; $signal = yield array_to_rows($row, $context->entryFactory(), [], $this->schema); if ($signal === Signal::STOP) { @@ -84,7 +89,7 @@ public function extract(FlowContext $context) : \Generator $cursor->free(); - if (!$hasRows) { + if ($rowCount < $this->fetchSize) { break; } } diff --git a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php index 2c15d6e59..59e7f8040 100644 --- a/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php +++ b/src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Unit/PostgreSqlCursorExtractorTest.php @@ -5,14 +5,165 @@ namespace Flow\ETL\Adapter\PostgreSql\Tests\Unit; use Flow\ETL\Adapter\PostgreSql\PostgreSqlCursorExtractor; +use Flow\ETL\{Config, FlowContext, Schema}; use Flow\ETL\Exception\InvalidArgumentException; -use Flow\ETL\Schema; use Flow\ETL\Tests\FlowTestCase; -use Flow\PostgreSql\Client\Client; +use Flow\PostgreSql\Client\{Client, Cursor}; use PHPUnit\Framework\MockObject\MockObject; final class PostgreSqlCursorExtractorTest extends FlowTestCase { + public function test_cursor_loop_breaks_immediately_when_empty_result() : void + { + $client = $this->createClientMock(); + $cursor = $this->createCursorMock(rows: [], count: 0); + + $client->expects(self::once()) + ->method('getTransactionNestingLevel') + ->willReturn(1); + + $client->expects(self::exactly(2)) + ->method('execute'); + + $client->expects(self::once()) + ->method('cursor') + ->willReturn($cursor); + + $extractor = new PostgreSqlCursorExtractor($client, 'SELECT * FROM users'); + $extractor = $extractor->withFetchSize(10); + + $rows = []; + + foreach ($extractor->extract($this->createFlowContext()) as $rowsData) { + $rows[] = $rowsData; + } + + self::assertSame([], $rows); + } + + public function test_cursor_loop_breaks_when_rows_less_than_fetch_size() : void + { + $client = $this->createClientMock(); + + $cursor = $this->createCursorMock( + rows: [ + ['id' => 1, 'name' => 'User 1'], + ['id' => 2, 'name' => 'User 2'], + ['id' => 3, 'name' => 'User 3'], + ], + count: 3 + ); + + $client->expects(self::once()) + ->method('getTransactionNestingLevel') + ->willReturn(1); + + $client->expects(self::exactly(2)) + ->method('execute'); + + $client->expects(self::once()) + ->method('cursor') + ->willReturn($cursor); + + $extractor = new PostgreSqlCursorExtractor($client, 'SELECT * FROM users'); + $extractor = $extractor->withFetchSize(10); + + $rows = []; + + foreach ($extractor->extract($this->createFlowContext()) as $rowsData) { + $rows = [...$rows, ...$rowsData->toArray()]; + } + + self::assertCount(3, $rows); + } + + public function test_cursor_loop_fetches_multiple_batches_when_needed() : void + { + $client = $this->createClientMock(); + + $cursor1 = $this->createCursorMock( + rows: [ + ['id' => 1, 'name' => 'User 1'], + ['id' => 2, 'name' => 'User 2'], + ], + count: 2 + ); + + $cursor2 = $this->createCursorMock( + rows: [ + ['id' => 3, 'name' => 'User 3'], + ], + count: 1 + ); + + $client->expects(self::once()) + ->method('getTransactionNestingLevel') + ->willReturn(1); + + $client->expects(self::exactly(2)) + ->method('execute'); + + $client->expects(self::exactly(2)) + ->method('cursor') + ->willReturnOnConsecutiveCalls($cursor1, $cursor2); + + $extractor = new PostgreSqlCursorExtractor($client, 'SELECT * FROM users'); + $extractor = $extractor->withFetchSize(2); + + $rows = []; + + foreach ($extractor->extract($this->createFlowContext()) as $rowsData) { + $rows = [...$rows, ...$rowsData->toArray()]; + } + + self::assertCount(3, $rows); + } + + public function test_cursor_loop_with_exact_fetch_size_multiple_does_extra_fetch() : void + { + $client = $this->createClientMock(); + + $cursor1 = $this->createCursorMock( + rows: [ + ['id' => 1, 'name' => 'User 1'], + ['id' => 2, 'name' => 'User 2'], + ], + count: 2 + ); + + $cursor2 = $this->createCursorMock( + rows: [ + ['id' => 3, 'name' => 'User 3'], + ['id' => 4, 'name' => 'User 4'], + ], + count: 2 + ); + + $cursor3 = $this->createCursorMock(rows: [], count: 0); + + $client->expects(self::once()) + ->method('getTransactionNestingLevel') + ->willReturn(1); + + $client->expects(self::exactly(2)) + ->method('execute'); + + $client->expects(self::exactly(3)) + ->method('cursor') + ->willReturnOnConsecutiveCalls($cursor1, $cursor2, $cursor3); + + $extractor = new PostgreSqlCursorExtractor($client, 'SELECT * FROM users'); + $extractor = $extractor->withFetchSize(2); + + $rows = []; + + foreach ($extractor->extract($this->createFlowContext()) as $rowsData) { + $rows = [...$rows, ...$rowsData->toArray()]; + } + + self::assertCount(4, $rows); + } + public function test_with_fetch_size_returns_self() : void { $client = $this->createClientMock(); @@ -95,4 +246,35 @@ private function createClientMock() : Client { return $this->createMock(Client::class); } + + /** + * @param array> $rows + * + * @return Cursor&MockObject + */ + private function createCursorMock(array $rows, int $count) : Cursor + { + $cursor = $this->createMock(Cursor::class); + + $cursor->expects(self::once()) + ->method('count') + ->willReturn($count); + + $cursor->method('iterate') + ->willReturnCallback(function () use ($rows) : \Generator { + foreach ($rows as $row) { + yield $row; + } + }); + + $cursor->expects(self::once()) + ->method('free'); + + return $cursor; + } + + private function createFlowContext() : FlowContext + { + return new FlowContext(Config::default()); + } } diff --git a/src/lib/postgresql/src/Flow/PostgreSql/AST/Transformers/ExplainConfig.php b/src/lib/postgresql/src/Flow/PostgreSql/AST/Transformers/ExplainConfig.php index 114e836d0..4e1d6fa03 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/AST/Transformers/ExplainConfig.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/AST/Transformers/ExplainConfig.php @@ -56,6 +56,66 @@ public static function forEstimate() : self ); } + /** + * @param array{ + * analyze: bool, + * verbose: bool, + * costs: bool, + * buffers: bool, + * timing: bool, + * summary: bool, + * memory: bool, + * settings: bool, + * wal: bool, + * format: string + * } $data + */ + public static function fromArray(array $data) : self + { + return new self( + analyze: $data['analyze'], + verbose: $data['verbose'], + costs: $data['costs'], + buffers: $data['buffers'], + timing: $data['timing'], + summary: $data['summary'], + memory: $data['memory'], + settings: $data['settings'], + wal: $data['wal'], + format: ExplainFormat::from($data['format']), + ); + } + + /** + * @return array{ + * analyze: bool, + * verbose: bool, + * costs: bool, + * buffers: bool, + * timing: bool, + * summary: bool, + * memory: bool, + * settings: bool, + * wal: bool, + * format: string + * } + */ + public function normalize() : array + { + return [ + 'analyze' => $this->analyze, + 'verbose' => $this->verbose, + 'costs' => $this->costs, + 'buffers' => $this->buffers, + 'timing' => $this->timing, + 'summary' => $this->summary, + 'memory' => $this->memory, + 'settings' => $this->settings, + 'wal' => $this->wal, + 'format' => $this->format->value, + ]; + } + public function withAnalyze() : self { return new self( diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Buffers.php b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Buffers.php index 58a9be094..16ca30f4c 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Buffers.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Buffers.php @@ -4,6 +4,9 @@ namespace Flow\PostgreSql\Explain\Plan; +/** + * @phpstan-type BuffersShape = array{shared_hit: int, shared_read: int, shared_dirtied: int, shared_written: int, local_hit: int, local_read: int, local_dirtied: int, local_written: int, temp_read: int, temp_written: int} + */ final readonly class Buffers { public function __construct( @@ -20,6 +23,25 @@ public function __construct( ) { } + /** + * @param BuffersShape $data + */ + public static function fromArray(array $data) : self + { + return new self( + sharedHit: $data['shared_hit'], + sharedRead: $data['shared_read'], + sharedDirtied: $data['shared_dirtied'], + sharedWritten: $data['shared_written'], + localHit: $data['local_hit'], + localRead: $data['local_read'], + localDirtied: $data['local_dirtied'], + localWritten: $data['local_written'], + tempRead: $data['temp_read'], + tempWritten: $data['temp_written'], + ); + } + public function hasDiskSpill() : bool { return $this->tempRead > 0 || $this->tempWritten > 0; @@ -52,6 +74,25 @@ public function localWritten() : int return $this->localWritten; } + /** + * @return BuffersShape + */ + public function normalize() : array + { + return [ + 'shared_hit' => $this->sharedHit, + 'shared_read' => $this->sharedRead, + 'shared_dirtied' => $this->sharedDirtied, + 'shared_written' => $this->sharedWritten, + 'local_hit' => $this->localHit, + 'local_read' => $this->localRead, + 'local_dirtied' => $this->localDirtied, + 'local_written' => $this->localWritten, + 'temp_read' => $this->tempRead, + 'temp_written' => $this->tempWritten, + ]; + } + public function sharedDirtied() : int { return $this->sharedDirtied; diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Cost.php b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Cost.php index 95228e422..91e58b59e 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Cost.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Cost.php @@ -12,11 +12,33 @@ public function __construct( ) { } + /** + * @param array{startup_cost: float, total_cost: float} $data + */ + public static function fromArray(array $data) : self + { + return new self( + startupCost: $data['startup_cost'], + totalCost: $data['total_cost'], + ); + } + public function incrementalCost() : float { return $this->totalCost - $this->startupCost; } + /** + * @return array{startup_cost: float, total_cost: float} + */ + public function normalize() : array + { + return [ + 'startup_cost' => $this->startupCost, + 'total_cost' => $this->totalCost, + ]; + } + public function startupCost() : float { return $this->startupCost; diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Plan.php b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Plan.php index b8558632c..baa2c49d8 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Plan.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Plan.php @@ -4,6 +4,9 @@ namespace Flow\PostgreSql\Explain\Plan; +/** + * @phpstan-import-type PlanNodeShape from PlanNode + */ final readonly class Plan { public function __construct( @@ -15,6 +18,29 @@ public function __construct( ) { } + /** + * @param array{ + * root_node: PlanNodeShape, + * planning_time: ?float, + * execution_time: ?float, + * memory_used: ?int, + * memory_peak: ?int + * } $data + */ + public static function fromArray(array $data) : self + { + /** @var PlanNodeShape $rootNodeData */ + $rootNodeData = $data['root_node']; + + return new self( + rootNode: PlanNode::fromArray($rootNodeData), + planningTime: $data['planning_time'], + executionTime: $data['execution_time'], + memoryUsed: $data['memory_used'], + memoryPeak: $data['memory_peak'], + ); + } + /** * @return array */ @@ -49,6 +75,26 @@ public function nodesByType(PlanNodeType $type) : array ); } + /** + * @return array{ + * root_node: PlanNodeShape, + * planning_time: ?float, + * execution_time: ?float, + * memory_used: ?int, + * memory_peak: ?int + * } + */ + public function normalize() : array + { + return [ + 'root_node' => $this->rootNode->normalize(), + 'planning_time' => $this->planningTime, + 'execution_time' => $this->executionTime, + 'memory_used' => $this->memoryUsed, + 'memory_peak' => $this->memoryPeak, + ]; + } + public function planningTime() : ?float { return $this->planningTime; diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/PlanNode.php b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/PlanNode.php index 2a915ef17..7bbed606a 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/PlanNode.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/PlanNode.php @@ -4,6 +4,39 @@ namespace Flow\PostgreSql\Explain\Plan; +/** + * @phpstan-import-type TimingShape from Timing + * @phpstan-import-type BuffersShape from Buffers + * + * @phpstan-type PlanNodeShape = array{ + * node_type: string, + * cost: array{startup_cost: float, total_cost: float}, + * estimated_rows: int, + * row_width: int, + * children: array>, + * relation_name: ?string, + * schema: ?string, + * alias: ?string, + * index_name: ?string, + * index_cond: ?string, + * filter: ?string, + * timing: ?TimingShape, + * buffers: ?BuffersShape, + * actual_rows: ?int, + * actual_loops: ?int, + * rows_removed_by_filter: ?int, + * rows_removed_by_index_recheck: ?int, + * parent_relationship: ?string, + * scan_direction: ?string, + * join_type: ?string, + * hash_cond: ?string, + * sort_key: ?string, + * sort_method: ?string, + * sort_space_used: ?int, + * sort_space_type: ?string, + * raw_data: array + * } + */ final readonly class PlanNode { /** @@ -40,6 +73,48 @@ public function __construct( ) { } + /** + * @param PlanNodeShape $data + */ + public static function fromArray(array $data) : self + { + $children = []; + + /** @var PlanNodeShape $childData */ + foreach ($data['children'] as $childData) { + $children[] = self::fromArray($childData); + } + + return new self( + nodeType: PlanNodeType::fromString($data['node_type']), + cost: Cost::fromArray($data['cost']), + estimatedRows: $data['estimated_rows'], + rowWidth: $data['row_width'], + children: $children, + relationName: $data['relation_name'], + schema: $data['schema'], + alias: $data['alias'], + indexName: $data['index_name'], + indexCond: $data['index_cond'], + filter: $data['filter'], + timing: $data['timing'] !== null ? Timing::fromArray($data['timing']) : null, + buffers: $data['buffers'] !== null ? Buffers::fromArray($data['buffers']) : null, + actualRows: $data['actual_rows'], + actualLoops: $data['actual_loops'], + rowsRemovedByFilter: $data['rows_removed_by_filter'], + rowsRemovedByIndexRecheck: $data['rows_removed_by_index_recheck'], + parentRelationship: $data['parent_relationship'], + scanDirection: $data['scan_direction'], + joinType: $data['join_type'], + hashCond: $data['hash_cond'], + sortKey: $data['sort_key'], + sortMethod: $data['sort_method'], + sortSpaceUsed: $data['sort_space_used'], + sortSpaceType: $data['sort_space_type'], + rawData: $data['raw_data'], + ); + } + public function actualLoops() : ?int { return $this->actualLoops; @@ -155,6 +230,44 @@ public function nodeType() : PlanNodeType return $this->nodeType; } + /** + * @return PlanNodeShape + */ + public function normalize() : array + { + return [ + 'node_type' => $this->nodeType->value, + 'cost' => $this->cost->normalize(), + 'estimated_rows' => $this->estimatedRows, + 'row_width' => $this->rowWidth, + 'children' => \array_map( + static fn (self $child) : array => $child->normalize(), + $this->children + ), + 'relation_name' => $this->relationName, + 'schema' => $this->schema, + 'alias' => $this->alias, + 'index_name' => $this->indexName, + 'index_cond' => $this->indexCond, + 'filter' => $this->filter, + 'timing' => $this->timing?->normalize(), + 'buffers' => $this->buffers?->normalize(), + 'actual_rows' => $this->actualRows, + 'actual_loops' => $this->actualLoops, + 'rows_removed_by_filter' => $this->rowsRemovedByFilter, + 'rows_removed_by_index_recheck' => $this->rowsRemovedByIndexRecheck, + 'parent_relationship' => $this->parentRelationship, + 'scan_direction' => $this->scanDirection, + 'join_type' => $this->joinType, + 'hash_cond' => $this->hashCond, + 'sort_key' => $this->sortKey, + 'sort_method' => $this->sortMethod, + 'sort_space_used' => $this->sortSpaceUsed, + 'sort_space_type' => $this->sortSpaceType, + 'raw_data' => $this->rawData, + ]; + } + public function parentRelationship() : ?string { return $this->parentRelationship; diff --git a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Timing.php b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Timing.php index 1c6eabf01..fa087fbfc 100644 --- a/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Timing.php +++ b/src/lib/postgresql/src/Flow/PostgreSql/Explain/Plan/Timing.php @@ -4,6 +4,9 @@ namespace Flow\PostgreSql\Explain\Plan; +/** + * @phpstan-type TimingShape = array{startup_time: float, total_time: float, loops: int} + */ final readonly class Timing { public function __construct( @@ -13,6 +16,18 @@ public function __construct( ) { } + /** + * @param TimingShape $data + */ + public static function fromArray(array $data) : self + { + return new self( + startupTime: $data['startup_time'], + totalTime: $data['total_time'], + loops: $data['loops'], + ); + } + public function averageTime() : float { return $this->loops > 0 ? $this->totalTime / $this->loops : 0.0; @@ -23,6 +38,18 @@ public function loops() : int return $this->loops; } + /** + * @return TimingShape + */ + public function normalize() : array + { + return [ + 'startup_time' => $this->startupTime, + 'total_time' => $this->totalTime, + 'loops' => $this->loops, + ]; + } + public function startupTime() : float { return $this->startupTime; diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/AST/Transformers/ExplainConfigTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/AST/Transformers/ExplainConfigTest.php index 5db5498c0..a99fc24d7 100644 --- a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/AST/Transformers/ExplainConfigTest.php +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/AST/Transformers/ExplainConfigTest.php @@ -147,6 +147,131 @@ public function test_for_estimate_returns_config_without_analyze() : void self::assertFalse($config->wal); } + public function test_from_array_and_normalize_are_inverse() : void + { + $original = new ExplainConfig( + analyze: true, + verbose: true, + costs: true, + buffers: true, + timing: true, + summary: true, + memory: true, + settings: true, + wal: true, + format: ExplainFormat::TEXT, + ); + + $normalized = $original->normalize(); + $restored = ExplainConfig::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_creates_instance() : void + { + $data = [ + 'analyze' => true, + 'verbose' => true, + 'costs' => true, + 'buffers' => true, + 'timing' => true, + 'summary' => true, + 'memory' => true, + 'settings' => true, + 'wal' => true, + 'format' => 'text', + ]; + + $config = ExplainConfig::fromArray($data); + + self::assertTrue($config->analyze); + self::assertTrue($config->verbose); + self::assertTrue($config->costs); + self::assertTrue($config->buffers); + self::assertTrue($config->timing); + self::assertTrue($config->summary); + self::assertTrue($config->memory); + self::assertTrue($config->settings); + self::assertTrue($config->wal); + self::assertSame(ExplainFormat::TEXT, $config->format); + } + + public function test_from_array_validates_config() : void + { + $data = [ + 'analyze' => false, + 'verbose' => false, + 'costs' => true, + 'buffers' => true, + 'timing' => false, + 'summary' => false, + 'memory' => false, + 'settings' => false, + 'wal' => false, + 'format' => 'json', + ]; + + $this->expectException(InvalidExplainConfigException::class); + $this->expectExceptionMessage('BUFFERS option requires ANALYZE to be enabled'); + + ExplainConfig::fromArray($data); + } + + public function test_from_array_with_for_estimate_config() : void + { + $original = ExplainConfig::forEstimate(); + $normalized = $original->normalize(); + $restored = ExplainConfig::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_normalize_returns_all_fields() : void + { + $config = ExplainConfig::forAnalysis() + ->withVerbose() + ->withMemory() + ->withSettings() + ->withWal() + ->withFormat(ExplainFormat::YAML); + + $normalized = $config->normalize(); + + self::assertTrue($normalized['analyze']); + self::assertTrue($normalized['verbose']); + self::assertTrue($normalized['costs']); + self::assertTrue($normalized['buffers']); + self::assertTrue($normalized['timing']); + self::assertTrue($normalized['summary']); + self::assertTrue($normalized['memory']); + self::assertTrue($normalized['settings']); + self::assertTrue($normalized['wal']); + self::assertSame('yaml', $normalized['format']); + } + + public function test_normalize_returns_expected_keys() : void + { + $config = ExplainConfig::forAnalysis(); + + $normalized = $config->normalize(); + + $expectedKeys = [ + 'analyze', + 'verbose', + 'costs', + 'buffers', + 'timing', + 'summary', + 'memory', + 'settings', + 'wal', + 'format', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } + public function test_with_analyze_returns_new_instance() : void { $original = ExplainConfig::forEstimate(); diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/BuffersTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/BuffersTest.php new file mode 100644 index 000000000..87a2d47c8 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/BuffersTest.php @@ -0,0 +1,123 @@ +normalize(); + $restored = Buffers::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_creates_instance() : void + { + $data = [ + 'shared_hit' => 100, + 'shared_read' => 50, + 'shared_dirtied' => 10, + 'shared_written' => 5, + 'local_hit' => 20, + 'local_read' => 10, + 'local_dirtied' => 2, + 'local_written' => 1, + 'temp_read' => 15, + 'temp_written' => 8, + ]; + + $buffers = Buffers::fromArray($data); + + self::assertSame(100, $buffers->sharedHit()); + self::assertSame(50, $buffers->sharedRead()); + self::assertSame(10, $buffers->sharedDirtied()); + self::assertSame(5, $buffers->sharedWritten()); + self::assertSame(20, $buffers->localHit()); + self::assertSame(10, $buffers->localRead()); + self::assertSame(2, $buffers->localDirtied()); + self::assertSame(1, $buffers->localWritten()); + self::assertSame(15, $buffers->tempRead()); + self::assertSame(8, $buffers->tempWritten()); + } + + public function test_normalize_returns_all_fields() : void + { + $buffers = new Buffers( + sharedHit: 100, + sharedRead: 50, + sharedDirtied: 10, + sharedWritten: 5, + localHit: 20, + localRead: 10, + localDirtied: 2, + localWritten: 1, + tempRead: 15, + tempWritten: 8, + ); + + $normalized = $buffers->normalize(); + + self::assertSame(100, $normalized['shared_hit']); + self::assertSame(50, $normalized['shared_read']); + self::assertSame(10, $normalized['shared_dirtied']); + self::assertSame(5, $normalized['shared_written']); + self::assertSame(20, $normalized['local_hit']); + self::assertSame(10, $normalized['local_read']); + self::assertSame(2, $normalized['local_dirtied']); + self::assertSame(1, $normalized['local_written']); + self::assertSame(15, $normalized['temp_read']); + self::assertSame(8, $normalized['temp_written']); + } + + public function test_normalize_returns_expected_keys() : void + { + $buffers = new Buffers( + sharedHit: 0, + sharedRead: 0, + sharedDirtied: 0, + sharedWritten: 0, + localHit: 0, + localRead: 0, + localDirtied: 0, + localWritten: 0, + tempRead: 0, + tempWritten: 0, + ); + + $normalized = $buffers->normalize(); + + $expectedKeys = [ + 'shared_hit', + 'shared_read', + 'shared_dirtied', + 'shared_written', + 'local_hit', + 'local_read', + 'local_dirtied', + 'local_written', + 'temp_read', + 'temp_written', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/CostTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/CostTest.php new file mode 100644 index 000000000..6c7140658 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/CostTest.php @@ -0,0 +1,67 @@ +normalize(); + $restored = Cost::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_creates_instance() : void + { + $data = [ + 'startup_cost' => 10.5, + 'total_cost' => 150.75, + ]; + + $cost = Cost::fromArray($data); + + self::assertSame(10.5, $cost->startupCost()); + self::assertSame(150.75, $cost->totalCost()); + } + + public function test_normalize_returns_all_fields() : void + { + $cost = new Cost( + startupCost: 10.5, + totalCost: 150.75, + ); + + $normalized = $cost->normalize(); + + self::assertSame(10.5, $normalized['startup_cost']); + self::assertSame(150.75, $normalized['total_cost']); + } + + public function test_normalize_returns_expected_keys() : void + { + $cost = new Cost( + startupCost: 0.0, + totalCost: 100.0, + ); + + $normalized = $cost->normalize(); + + $expectedKeys = [ + 'startup_cost', + 'total_cost', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanNodeTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanNodeTest.php new file mode 100644 index 000000000..dfbfa5d4e --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanNodeTest.php @@ -0,0 +1,203 @@ + 'value'], + ); + + $normalized = $original->normalize(); + $restored = PlanNode::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_and_normalize_with_nested_children() : void + { + $childNode = new PlanNode( + nodeType: PlanNodeType::INDEX_SCAN, + cost: new Cost(0.0, 50.0), + estimatedRows: 100, + rowWidth: 32, + ); + + $original = new PlanNode( + nodeType: PlanNodeType::NESTED_LOOP, + cost: new Cost(0.0, 200.0), + estimatedRows: 1000, + rowWidth: 64, + children: [$childNode], + joinType: 'Inner', + ); + + $normalized = $original->normalize(); + $restored = PlanNode::fromArray($normalized); + + self::assertEquals($original, $restored); + self::assertCount(1, $restored->children()); + self::assertEquals(PlanNodeType::INDEX_SCAN, $restored->children()[0]->nodeType()); + } + + public function test_from_array_creates_instance_with_all_fields() : void + { + $data = [ + 'node_type' => 'Seq Scan', + 'cost' => ['startup_cost' => 0.0, 'total_cost' => 100.0], + 'estimated_rows' => 1000, + 'row_width' => 64, + 'children' => [], + 'relation_name' => 'users', + 'schema' => 'public', + 'alias' => 'u', + 'index_name' => null, + 'index_cond' => null, + 'filter' => '(active = true)', + 'timing' => ['startup_time' => 0.1, 'total_time' => 5.5, 'loops' => 1], + 'buffers' => [ + 'shared_hit' => 50, + 'shared_read' => 10, + 'shared_dirtied' => 0, + 'shared_written' => 0, + 'local_hit' => 0, + 'local_read' => 0, + 'local_dirtied' => 0, + 'local_written' => 0, + 'temp_read' => 0, + 'temp_written' => 0, + ], + 'actual_rows' => 950, + 'actual_loops' => 1, + 'rows_removed_by_filter' => 50, + 'rows_removed_by_index_recheck' => null, + 'parent_relationship' => null, + 'scan_direction' => 'Forward', + 'join_type' => null, + 'hash_cond' => null, + 'sort_key' => null, + 'sort_method' => null, + 'sort_space_used' => null, + 'sort_space_type' => null, + 'raw_data' => ['custom_field' => 'value'], + ]; + + $node = PlanNode::fromArray($data); + + self::assertEquals(PlanNodeType::SEQ_SCAN, $node->nodeType()); + self::assertSame(0.0, $node->cost()->startupCost()); + self::assertSame(100.0, $node->cost()->totalCost()); + self::assertSame(1000, $node->estimatedRows()); + self::assertSame(64, $node->rowWidth()); + self::assertSame('users', $node->relationName()); + self::assertSame('public', $node->schema()); + self::assertSame('u', $node->alias()); + self::assertSame('(active = true)', $node->filter()); + self::assertSame(950, $node->actualRows()); + self::assertSame(1, $node->actualLoops()); + self::assertSame(50, $node->rowsRemovedByFilter()); + self::assertSame('Forward', $node->scanDirection()); + self::assertSame(['custom_field' => 'value'], $node->rawData()); + self::assertNotNull($node->timing()); + self::assertSame(0.1, $node->timing()->startupTime()); + self::assertNotNull($node->buffers()); + self::assertSame(50, $node->buffers()->sharedHit()); + } + + public function test_normalize_returns_expected_keys() : void + { + $node = new PlanNode( + nodeType: PlanNodeType::SEQ_SCAN, + cost: new Cost(0.0, 100.0), + estimatedRows: 1000, + rowWidth: 64, + ); + + $normalized = $node->normalize(); + + $expectedKeys = [ + 'node_type', + 'cost', + 'estimated_rows', + 'row_width', + 'children', + 'relation_name', + 'schema', + 'alias', + 'index_name', + 'index_cond', + 'filter', + 'timing', + 'buffers', + 'actual_rows', + 'actual_loops', + 'rows_removed_by_filter', + 'rows_removed_by_index_recheck', + 'parent_relationship', + 'scan_direction', + 'join_type', + 'hash_cond', + 'sort_key', + 'sort_method', + 'sort_space_used', + 'sort_space_type', + 'raw_data', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } + + public function test_normalize_with_null_optional_fields() : void + { + $node = new PlanNode( + nodeType: PlanNodeType::SEQ_SCAN, + cost: new Cost(0.0, 100.0), + estimatedRows: 1000, + rowWidth: 64, + ); + + $normalized = $node->normalize(); + + self::assertSame('Seq Scan', $normalized['node_type']); + self::assertNull($normalized['relation_name']); + self::assertNull($normalized['schema']); + self::assertNull($normalized['alias']); + self::assertNull($normalized['timing']); + self::assertNull($normalized['buffers']); + self::assertNull($normalized['actual_rows']); + self::assertSame([], $normalized['children']); + self::assertSame([], $normalized['raw_data']); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanTest.php new file mode 100644 index 000000000..db13fe290 --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/PlanTest.php @@ -0,0 +1,190 @@ +normalize(); + $restored = Plan::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_and_normalize_with_nested_plan() : void + { + $childNode = new PlanNode( + nodeType: PlanNodeType::INDEX_SCAN, + cost: new Cost(0.0, 50.0), + estimatedRows: 100, + rowWidth: 32, + indexName: 'users_pkey', + ); + + $rootNode = new PlanNode( + nodeType: PlanNodeType::NESTED_LOOP, + cost: new Cost(0.0, 200.0), + estimatedRows: 1000, + rowWidth: 64, + children: [$childNode], + joinType: 'Inner', + ); + + $original = new Plan( + rootNode: $rootNode, + planningTime: 1.0, + executionTime: 50.0, + ); + + $normalized = $original->normalize(); + $restored = Plan::fromArray($normalized); + + self::assertEquals($original, $restored); + self::assertCount(1, $restored->rootNode()->children()); + } + + public function test_from_array_creates_instance() : void + { + $data = [ + 'root_node' => [ + 'node_type' => 'Seq Scan', + 'cost' => ['startup_cost' => 0.0, 'total_cost' => 100.0], + 'estimated_rows' => 1000, + 'row_width' => 64, + 'children' => [], + 'relation_name' => 'users', + 'schema' => null, + 'alias' => null, + 'index_name' => null, + 'index_cond' => null, + 'filter' => null, + 'timing' => null, + 'buffers' => null, + 'actual_rows' => null, + 'actual_loops' => null, + 'rows_removed_by_filter' => null, + 'rows_removed_by_index_recheck' => null, + 'parent_relationship' => null, + 'scan_direction' => null, + 'join_type' => null, + 'hash_cond' => null, + 'sort_key' => null, + 'sort_method' => null, + 'sort_space_used' => null, + 'sort_space_type' => null, + 'raw_data' => [], + ], + 'planning_time' => 0.5, + 'execution_time' => 25.0, + 'memory_used' => 1024, + 'memory_peak' => 2048, + ]; + + $plan = Plan::fromArray($data); + + self::assertEquals(PlanNodeType::SEQ_SCAN, $plan->rootNode()->nodeType()); + self::assertSame(0.5, $plan->planningTime()); + self::assertSame(25.0, $plan->executionTime()); + self::assertSame(1024, $plan->memoryUsed()); + self::assertSame(2048, $plan->memoryPeak()); + } + + public function test_normalize_returns_all_fields() : void + { + $rootNode = new PlanNode( + nodeType: PlanNodeType::SEQ_SCAN, + cost: new Cost(0.0, 100.0), + estimatedRows: 1000, + rowWidth: 64, + ); + + $plan = new Plan( + rootNode: $rootNode, + planningTime: 0.5, + executionTime: 25.0, + memoryUsed: 1024, + memoryPeak: 2048, + ); + + $normalized = $plan->normalize(); + + self::assertIsArray($normalized['root_node']); + self::assertSame(0.5, $normalized['planning_time']); + self::assertSame(25.0, $normalized['execution_time']); + self::assertSame(1024, $normalized['memory_used']); + self::assertSame(2048, $normalized['memory_peak']); + } + + public function test_normalize_returns_expected_keys() : void + { + $rootNode = new PlanNode( + nodeType: PlanNodeType::SEQ_SCAN, + cost: new Cost(0.0, 100.0), + estimatedRows: 1000, + rowWidth: 64, + ); + + $plan = new Plan( + rootNode: $rootNode, + ); + + $normalized = $plan->normalize(); + + $expectedKeys = [ + 'root_node', + 'planning_time', + 'execution_time', + 'memory_used', + 'memory_peak', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } + + public function test_normalize_with_null_values() : void + { + $rootNode = new PlanNode( + nodeType: PlanNodeType::SEQ_SCAN, + cost: new Cost(0.0, 100.0), + estimatedRows: 1000, + rowWidth: 64, + ); + + $plan = new Plan( + rootNode: $rootNode, + ); + + $normalized = $plan->normalize(); + + self::assertIsArray($normalized['root_node']); + self::assertNull($normalized['planning_time']); + self::assertNull($normalized['execution_time']); + self::assertNull($normalized['memory_used']); + self::assertNull($normalized['memory_peak']); + } +} diff --git a/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/TimingTest.php b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/TimingTest.php new file mode 100644 index 000000000..a92bf8acf --- /dev/null +++ b/src/lib/postgresql/tests/Flow/PostgreSql/Tests/Unit/Explain/Plan/TimingTest.php @@ -0,0 +1,74 @@ +normalize(); + $restored = Timing::fromArray($normalized); + + self::assertEquals($original, $restored); + } + + public function test_from_array_creates_instance() : void + { + $data = [ + 'startup_time' => 0.5, + 'total_time' => 25.75, + 'loops' => 3, + ]; + + $timing = Timing::fromArray($data); + + self::assertSame(0.5, $timing->startupTime()); + self::assertSame(25.75, $timing->totalTime()); + self::assertSame(3, $timing->loops()); + } + + public function test_normalize_returns_all_fields() : void + { + $timing = new Timing( + startupTime: 0.5, + totalTime: 25.75, + loops: 3, + ); + + $normalized = $timing->normalize(); + + self::assertSame(0.5, $normalized['startup_time']); + self::assertSame(25.75, $normalized['total_time']); + self::assertSame(3, $normalized['loops']); + } + + public function test_normalize_returns_expected_keys() : void + { + $timing = new Timing( + startupTime: 0.0, + totalTime: 100.0, + loops: 1, + ); + + $normalized = $timing->normalize(); + + $expectedKeys = [ + 'startup_time', + 'total_time', + 'loops', + ]; + + self::assertSame($expectedKeys, \array_keys($normalized)); + } +}