packages/ztd-query-postgres/tests/Unit/PgSqlSessionFactoryTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use ZtdQuery\Config\ZtdConfig;
11use ZtdQuery\Connection\ConnectionInterface;
12use ZtdQuery\Connection\StatementInterface;
13use ZtdQuery\Platform\Postgres\Connection\Copy\PgSqlCopySupport;
14use ZtdQuery\Platform\Postgres\Connection\Parameter\PgSqlPdoParameterBindingCompiler;
15use ZtdQuery\Platform\Postgres\Connection\Parameter\PgSqlPdoPlaceholderEscaper;
16use ZtdQuery\Platform\Postgres\Connection\Result\PgSqlPdoResultColumnTypeResolver;
17use ZtdQuery\Platform\Postgres\PgSqlSessionFactory;
18use ZtdQuery\Platform\Postgres\Rewrite\PgSqlQueryGuard;
19use ZtdQuery\Platform\Postgres\Rewrite\PgSqlRewriter;
20use ZtdQuery\Platform\Postgres\Rewrite\Transformer\DeleteTransformer;
21use ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertTransformer;
22use ZtdQuery\Platform\Postgres\Rewrite\Transformer\PgSqlTransformer;
23use ZtdQuery\Platform\Postgres\Rewrite\Transformer\SelectTransformer;
24use ZtdQuery\Platform\Postgres\Rewrite\Transformer\UpdateTransformer;
25use ZtdQuery\Platform\Postgres\Schema\Partition\PgSqlPartitionReflector;
26use ZtdQuery\Platform\Postgres\Schema\PgSqlSchemaParser;
27use ZtdQuery\Platform\Postgres\Schema\PgSqlSchemaReflector;
28use ZtdQuery\Platform\Postgres\Shadow\PgSqlMutationResolver;
29use ZtdQuery\Platform\Postgres\Sql\Partition\PgSqlPartitionParser;
30use ZtdQuery\Platform\Postgres\Sql\PgSqlIdentifierQuoter;
31use ZtdQuery\Platform\Postgres\Sql\PgSqlParser;
32use ZtdQuery\Platform\Postgres\Sql\Value\PgSqlCastRenderer;
33
34#[CoversClass(PgSqlSessionFactory::class)]
35#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\PgSqlColumnTypeMapper::class)]
36#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Key\PgSqlForeignKeyDefinitionParser::class)]
37#[UsesClass(PgSqlParser::class)]
38#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Conflict\PgSqlConflictTarget::class)]
39#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\PostgreSqlLexicalMasker::class)]
40#[UsesClass(PgSqlSchemaParser::class)]
41#[UsesClass(PgSqlPartitionParser::class)]
42#[UsesClass(PgSqlPartitionReflector::class)]
43#[UsesClass(PgSqlQueryGuard::class)]
44#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Diagnostic\PgSqlReadOnlyDiagnosticStatement::class)]
45#[UsesClass(PgSqlRewriter::class)]
46#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Relation\PgSqlSelectRelationParser::class)]
47#[UsesClass(PgSqlMutationResolver::class)]
48#[UsesClass(PgSqlTransformer::class)]
49#[UsesClass(PgSqlSchemaReflector::class)]
50#[UsesClass(PgSqlCastRenderer::class)]
51#[UsesClass(PgSqlCopySupport::class)]
52#[UsesClass(PgSqlPdoParameterBindingCompiler::class)]
53#[UsesClass(PgSqlPdoPlaceholderEscaper::class)]
54#[UsesClass(PgSqlPdoResultColumnTypeResolver::class)]
55#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Value\PgSqlValueRenderer::class)]
56#[UsesClass(PgSqlIdentifierQuoter::class)]
57#[UsesClass(SelectTransformer::class)]
58#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Sampling\PgSqlTableSampleParser::class)]
59#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Sampling\PgSqlTableSampleRewriter::class)]
60#[UsesClass(InsertTransformer::class)]
61#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertRowRenderer::class)]
62#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertSelectRenderer::class)]
63#[UsesClass(UpdateTransformer::class)]
64#[UsesClass(DeleteTransformer::class)]
65#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Cte\PgSqlCteShadowComposer::class)]
66#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Upsert\PgSqlNativeUpsertProjector::class)]
67#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\View\PgSqlViewDefinitionParser::class)]
68#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\View\PgSqlViewShadowRenderer::class)]
69#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\GeneratedColumn\PgSqlGeneratedColumnProjector::class)]
70#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Partition\PgSqlPartitionPredicateRenderer::class)]
71#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Returning\PgSqlReturningProjectionParser::class)]
72#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\PgSqlUpsertExpressionParser::class)]
73#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\PgSqlLexerProfile::class)]
74#[CoversClass(\ZtdQuery\Platform\Postgres\Session\SchemaInitializer::class)]
75#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TargetColumns::class)]
76#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TargetSql::class)]
77#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Copy\TextFields::class)]
78#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Parameter\EscapeCursor::class)]
79#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Parameter\OperandInput::class)]
80#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Parameter\QuotedInput::class)]
81#[UsesClass(\ZtdQuery\Platform\Postgres\Connection\Parameter\TokenBoundary::class)]
82#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Table\TableMutationResolver::class)]
83#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Row\RowMutationResolver::class)]
84#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Conflict\ColumnSet::class)]
85#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Cte\HeaderParser::class)]
86#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Cte\IdentifierReferences::class)]
87#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Cte\PrefixMerge::class)]
88#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Cte\ShadowDependencies::class)]
89#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Diagnostic\KeywordSearch::class)]
90#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\Expression\ComparisonOperator::class)]
91#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\Expression\ExpressionCursor::class)]
92#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\Expression\ExpressionLexeme::class)]
93#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\Expression\PrecedenceParser::class)]
94#[UsesClass(\ZtdQuery\Platform\Postgres\Shadow\Mutation\Upsert\Expression\PrimaryParser::class)]
95#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\ActionClause::class)]
96#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\BranchTokens::class)]
97#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\RelationTarget::class)]
98#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\StatementParts::class)]
99#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Relation\FromClause::class)]
100#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Relation\RelationReference::class)]
101#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Returning\ProjectionItem::class)]
102#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Sampling\SampleClause::class)]
103#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Sampling\SampleTokens::class)]
104#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Statement\StatementClassifier::class)]
105#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Statement\ConflictClause::class)]
106#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Lexing\IdentifierDecoder::class)]
107#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Statement\TargetTableParser::class)]
108#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Dml\Update\UpdateClauseParser::class)]
109#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Dml\Delete\DeleteClauseParser::class)]
110#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Dml\Insert\InsertClauseParser::class)]
111#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Dml\Insert\InsertClauseParser::class)]
112#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Statement\SelectColumns::class)]
113#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Statement\TableDefinitionClauses::class)]
114#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Transaction\KeywordForm::class)]
115#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeActionKind::class)]
116#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeClause::class)]
117#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeMatchKind::class)]
118#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeParser::class)]
119#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Merge\PgSqlMergeStatement::class)]
120#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Sampling\PgSqlTableSample::class)]
121#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Sampling\PgSqlTableSampleMethod::class)]
122#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Transaction\PgSqlTransactionStatementParser::class)]
123#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Catalog\PartitionKeys::class)]
124#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Catalog\TableQueries::class)]
125#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Column\ColumnDefinitionSql::class)]
126#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Column\NativeTypeSql::class)]
127#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\ForeignKeyRow::class)]
128#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\ForeignKeys::class)]
129#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\IndexDefinitions::class)]
130#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\IndexRows::class)]
131#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\PrimaryColumns::class)]
132#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Reflection\Key\UniqueIndexes::class)]
133#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Sampling\SampleProjection::class)]
134#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Sampling\TableColumns::class)]
135#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Upsert\ConflictPredicate::class)]
136#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Upsert\ExpressionBinder::class)]
137#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Definition\ColumnDefinition::class)]
138#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Definition\ColumnTypeDeclaration::class)]
139#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Definition\TableBody::class)]
140#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Definition\TableConstraint::class)]
141#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Definition\TableFields::class)]
142#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Key\DefinitionEntry::class)]
143#[UsesClass(\ZtdQuery\Platform\Postgres\Schema\Key\DefinitionTokens::class)]
144#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Partition\BoundPredicate::class)]
145#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Partition\ClauseTokens::class)]
146#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Partition\StorageTable::class)]
147#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Context\TableContext::class)]
148#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\StatementRewriter::class)]
149#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Value\CastTypeMapper::class)]
150#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Lexing\CommentSpan::class)]
151#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Lexing\QuotedSpan::class)]
152#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Value\NativeCastTarget::class)]
153#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Value\BinaryStream::class)]
154#[UsesClass(\ZtdQuery\Platform\Postgres\Sql\Value\LiteralText::class)]
155#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Cte\RowSourceRenderer::class)]
156#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Insert\ExpressionCast::class)]
157#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Insert\OrderedExpressions::class)]
158#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Insert\SelectProjection::class)]
159#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Insert\UpsertProjection::class)]
160#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Insert\ValueProjection::class)]
161#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\MergeTransformer::class)]
162#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Merge\MatchConditions::class)]
163#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Merge\RowActions::class)]
164#[UsesClass(\ZtdQuery\Platform\Postgres\Rewrite\Transformer\Update\ColumnProjection::class)]
165final class PgSqlSessionFactoryTest extends TestCase
166{
167    public function testCreateRegistersReflectedPartitionMetadata(): void
168    {
169        $tables = self::createStub(StatementInterface::class);
170        $tables->method('fetchAll')->willReturn([['table_name' => 'logs'], ['table_name' => 'logs_2024']]);
171        $columns = self::createStub(StatementInterface::class);
172        $columns->method('fetchAll')->willReturn([['column_name' => 'id', 'data_type' => 'integer', 'character_maximum_length' => null, 'numeric_precision' => 32, 'numeric_scale' => 0, 'is_nullable' => 'NO', 'column_default' => null, 'udt_name' => 'int4'], ['column_name' => 'log_date', 'data_type' => 'date', 'character_maximum_length' => null, 'numeric_precision' => null, 'numeric_scale' => null, 'is_nullable' => 'NO', 'column_default' => null, 'udt_name' => 'date']]);
173        $primaryKey = self::createStub(StatementInterface::class);
174        $primaryKey->method('fetchAll')->willReturn([['column_name' => 'id'], ['column_name' => 'log_date']]);
175        $empty = self::createStub(StatementInterface::class);
176        $empty->method('fetchAll')->willReturn([]);
177        $partitionKeys = self::createStub(StatementInterface::class);
178        $partitionKeys->method('fetchAll')->willReturn([['table_name' => 'logs', 'partition_key' => 'RANGE (log_date)']]);
179        $relations = self::createStub(StatementInterface::class);
180        $relations->method('fetchAll')->willReturn([['child_table' => 'logs_2024', 'parent_table' => 'logs', 'predicate' => "log_date >= '2024-01-01'::date AND log_date < '2025-01-01'::date"]]);
181        $connection = self::createStub(ConnectionInterface::class);
182        $connection->method('query')->willReturnCallback(static function (string $sql) use ($tables, $columns, $primaryKey, $empty, $partitionKeys, $relations) {
183            return match (true) {
184                str_contains($sql, 'information_schema.tables') => $tables,
185                str_contains($sql, 'information_schema.columns') => $columns,
186                str_contains($sql, "constraint_type = 'PRIMARY KEY'") => $primaryKey,
187                str_contains($sql, 'SELECT c.relname AS table_name') => $partitionKeys,
188                str_contains($sql, 'SELECT child.relname AS child_table') => $relations,
189                default => $empty,
190            };
191        });
192        $session = (new PgSqlSessionFactory())->create($connection, ZtdConfig::default());
193        $sql = $session->rewrite('SELECT * FROM logs_2024')->sql();
194        self::assertStringContainsString('"logs" AS MATERIALIZED', $sql);
195        self::assertStringContainsString('"logs_2024" AS MATERIALIZED (SELECT * FROM "logs" WHERE log_date >=', $sql);
196        $create = $session->rewrite("CREATE TABLE logs_2025 PARTITION OF logs FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')");
197        self::assertNotNull($create->mutation());
198    }
199    public function testCreateRegistersReflectedViews(): void
200    {
201        $empty = self::createStub(StatementInterface::class);
202        $empty->method('fetchAll')->willReturn([]);
203        $views = self::createStub(StatementInterface::class);
204        $views->method('fetchAll')->willReturn([['viewname' => 'active_users', 'definition' => 'SELECT 1 AS id']]);
205        $connection = self::createStub(ConnectionInterface::class);
206        $connection->method('query')->willReturnCallback(static fn (string $sql): StatementInterface => str_contains($sql, 'pg_views') ? $views : $empty);
207        $session = (new PgSqlSessionFactory())->create($connection, ZtdConfig::default());
208        self::assertSame("WITH \"active_users\" AS MATERIALIZED (SELECT 1 AS id)\nSELECT * FROM active_users", $session->rewrite('SELECT * FROM active_users')->sql());
209    }
210    public function testCreateReturnsSession(): void
211    {
212        $connection = static::createStub(ConnectionInterface::class);
213        $tablesStmt = static::createStub(StatementInterface::class);
214        $tablesStmt->method('fetchAll')->willReturn([]);
215        $connection->method('query')->willReturn($tablesStmt);
216        $factory = new PgSqlSessionFactory();
217        $session = $factory->create($connection, ZtdConfig::default());
218        self::assertTrue($session->isEnabled());
219        self::assertInstanceOf(PgSqlCopySupport::class, $session->copySupport());
220        self::assertInstanceOf(PgSqlPdoParameterBindingCompiler::class, $session->parameterBindingCompiler());
221        self::assertInstanceOf(PgSqlPdoResultColumnTypeResolver::class, $session->resultColumnTypeResolver());
222    }
223    public function testCreatedSessionIsEnabledByDefault(): void
224    {
225        $connection = static::createStub(ConnectionInterface::class);
226        $tablesStmt = static::createStub(StatementInterface::class);
227        $tablesStmt->method('fetchAll')->willReturn([]);
228        $connection->method('query')->willReturn($tablesStmt);
229        $factory = new PgSqlSessionFactory();
230        $session = $factory->create($connection, ZtdConfig::default());
231        self::assertTrue($session->isEnabled());
232    }
233    public function testCreateWithTablesReflectsSchema(): void
234    {
235        $connection = static::createStub(ConnectionInterface::class);
236        $tablesStmt = static::createStub(StatementInterface::class);
237        $tablesStmt->method('fetchAll')->willReturn([['table_name' => 'users']]);
238        $columnsStmt = static::createStub(StatementInterface::class);
239        $columnsStmt->method('fetchAll')->willReturn([['column_name' => 'id', 'data_type' => 'INTEGER', 'character_maximum_length' => null, 'numeric_precision' => 32, 'numeric_scale' => 0, 'is_nullable' => 'NO', 'column_default' => null, 'udt_name' => 'int4'], ['column_name' => 'name', 'data_type' => 'TEXT', 'character_maximum_length' => null, 'numeric_precision' => null, 'numeric_scale' => null, 'is_nullable' => 'YES', 'column_default' => null, 'udt_name' => 'text']]);
240        $pkStmt = static::createStub(StatementInterface::class);
241        $pkStmt->method('fetchAll')->willReturn([['column_name' => 'id']]);
242        $uniqueStmt = static::createStub(StatementInterface::class);
243        $uniqueStmt->method('fetchAll')->willReturn([]);
244        $connection->method('query')->willReturnCallback(function (string $sql) use ($tablesStmt, $columnsStmt, $pkStmt, $uniqueStmt) {
245            if (str_contains($sql, 'information_schema.tables')) {
246                return $tablesStmt;
247            }
248            if (str_contains($sql, 'information_schema.columns')) {
249                return $columnsStmt;
250            }
251            if (str_contains($sql, "constraint_type = 'PRIMARY KEY'")) {
252                return $pkStmt;
253            }
254            if (str_contains($sql, "constraint_type = 'UNIQUE'")) {
255                return $uniqueStmt;
256            }
257            return false;
258        });
259        $factory = new PgSqlSessionFactory();
260        $session = $factory->create($connection, ZtdConfig::default());
261        self::assertTrue($session->isEnabled());
262    }
263    public function testCreateWithEmptyDatabaseReturnsSession(): void
264    {
265        $connection = static::createStub(ConnectionInterface::class);
266        $connection->method('query')->willReturn(false);
267        $factory = new PgSqlSessionFactory();
268        $session = $factory->create($connection, ZtdConfig::default());
269        self::assertTrue($session->isEnabled());
270    }
271    public function testSessionCanBeEnabledAfterCreation(): void
272    {
273        $connection = static::createStub(ConnectionInterface::class);
274        $tablesStmt = static::createStub(StatementInterface::class);
275        $tablesStmt->method('fetchAll')->willReturn([]);
276        $connection->method('query')->willReturn($tablesStmt);
277        $factory = new PgSqlSessionFactory();
278        $session = $factory->create($connection, ZtdConfig::default());
279        $session->enable();
280        self::assertTrue($session->isEnabled());
281    }
282    public function testCreateWithTableRegistersSchemaInSession(): void
283    {
284        $connection = static::createStub(ConnectionInterface::class);
285        $tablesStmt = static::createStub(StatementInterface::class);
286        $tablesStmt->method('fetchAll')->willReturn([['table_name' => 'products']]);
287        $columnsStmt = static::createStub(StatementInterface::class);
288        $columnsStmt->method('fetchAll')->willReturn([['column_name' => 'id', 'data_type' => 'INTEGER', 'character_maximum_length' => null, 'numeric_precision' => 32, 'numeric_scale' => 0, 'is_nullable' => 'NO', 'column_default' => null, 'udt_name' => 'int4'], ['column_name' => 'price', 'data_type' => 'NUMERIC', 'character_maximum_length' => null, 'numeric_precision' => 10, 'numeric_scale' => 2, 'is_nullable' => 'YES', 'column_default' => null, 'udt_name' => 'numeric']]);
289        $pkStmt = static::createStub(StatementInterface::class);
290        $pkStmt->method('fetchAll')->willReturn([['column_name' => 'id']]);
291        $uniqueStmt = static::createStub(StatementInterface::class);
292        $uniqueStmt->method('fetchAll')->willReturn([]);
293        $connection->method('query')->willReturnCallback(function (string $sql) use ($tablesStmt, $columnsStmt, $pkStmt, $uniqueStmt) {
294            if (str_contains($sql, 'information_schema.tables')) {
295                return $tablesStmt;
296            }
297            if (str_contains($sql, 'information_schema.columns')) {
298                return $columnsStmt;
299            }
300            if (str_contains($sql, "constraint_type = 'PRIMARY KEY'")) {
301                return $pkStmt;
302            }
303            if (str_contains($sql, "constraint_type = 'UNIQUE'")) {
304                return $uniqueStmt;
305            }
306            return false;
307        });
308        $factory = new PgSqlSessionFactory();
309        $session = $factory->create($connection, ZtdConfig::default());
310        $session->enable();
311        $plan = $session->rewrite('SELECT * FROM products');
312        self::assertStringContainsString('"products" AS MATERIALIZED', $plan->sql());
313    }
314    public function testCreateWithNullParseResultStillWorks(): void
315    {
316        $connection = static::createStub(ConnectionInterface::class);
317        $tablesStmt = static::createStub(StatementInterface::class);
318        $tablesStmt->method('fetchAll')->willReturn([['table_name' => 'bad_table']]);
319        $columnsStmt = static::createStub(StatementInterface::class);
320        $columnsStmt->method('fetchAll')->willReturn([]);
321        $connection->method('query')->willReturnCallback(function (string $sql) use ($tablesStmt, $columnsStmt) {
322            if (str_contains($sql, 'information_schema.tables')) {
323                return $tablesStmt;
324            }
325            return $columnsStmt;
326        });
327        $factory = new PgSqlSessionFactory();
328        $session = $factory->create($connection, ZtdConfig::default());
329        self::assertTrue($session->isEnabled());
330    }
331    public function testCreateRegistersReflectedPartialUniqueIndexes(): void
332    {
333        $tables = self::createStub(StatementInterface::class);
334        $tables->method('fetchAll')->willReturn([['table_name' => 'users']]);
335        $columns = self::createStub(StatementInterface::class);
336        $columns->method('fetchAll')->willReturn([['column_name' => 'id', 'data_type' => 'integer', 'is_nullable' => 'NO', 'udt_name' => 'int4'], ['column_name' => 'email', 'data_type' => 'text', 'is_nullable' => 'NO', 'udt_name' => 'text'], ['column_name' => 'status', 'data_type' => 'text', 'is_nullable' => 'NO', 'udt_name' => 'text']]);
337        $primaryKey = self::createStub(StatementInterface::class);
338        $primaryKey->method('fetchAll')->willReturn([['column_name' => 'id']]);
339        $unique = self::createStub(StatementInterface::class);
340        $unique->method('fetchAll')->willReturn([['constraint_name' => 'users_active_email', 'column_name' => 'email', 'predicate' => "status = 'active'"]]);
341        $empty = self::createStub(StatementInterface::class);
342        $empty->method('fetchAll')->willReturn([]);
343        $connection = self::createStub(ConnectionInterface::class);
344        $connection->method('query')->willReturnCallback(static function (string $sql) use ($tables, $columns, $primaryKey, $unique, $empty): StatementInterface {
345            return match (true) {
346                str_contains($sql, 'information_schema.tables') => $tables,
347                str_contains($sql, 'information_schema.columns') => $columns,
348                str_contains($sql, "constraint_type = 'PRIMARY KEY'") => $primaryKey,
349                str_contains($sql, 'pg_catalog.pg_index') => $unique,
350                default => $empty,
351            };
352        });
353        $session = (new PgSqlSessionFactory())->create($connection, ZtdConfig::default());
354        $plan = $session->rewrite("INSERT INTO users (id, email, status) VALUES (1, 'a@example.com', 'active') " . "ON CONFLICT (email) WHERE status = 'active' " . 'DO UPDATE SET status = EXCLUDED.status');
355        self::assertStringContainsString('"__ztd_existing"."email" = "__ztd_incoming"."email"', $plan->sql());
356        self::assertStringContainsString('"__ztd_existing"."status" = \'active\'', $plan->sql());
357    }
358}
359