packages/ztd-query-postgres/fuzz/FullPipelineFuzzTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz;
6
7use Faker\Factory;
8use Override;
9use PHPUnit\Framework\Attributes\CoversNothing;
10use PHPUnit\Framework\Attributes\Large;
11use PHPUnit\Framework\TestCase;
12use SqlFaker\PostgreSqlProvider;
13use ZtdQuery\Exception\UnknownSchemaException;
14use ZtdQuery\Exception\UnsupportedSqlException;
15use ZtdQuery\Platform\Postgres\Rewrite\PgSqlQueryGuard;
16use ZtdQuery\Platform\Postgres\Rewrite\PgSqlRewriter;
17use ZtdQuery\Platform\Postgres\Rewrite\Transformer\DeleteTransformer;
18use ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertTransformer;
19use ZtdQuery\Platform\Postgres\Rewrite\Transformer\PgSqlTransformer;
20use ZtdQuery\Platform\Postgres\Rewrite\Transformer\SelectTransformer;
21use ZtdQuery\Platform\Postgres\Rewrite\Transformer\UpdateTransformer;
22use ZtdQuery\Platform\Postgres\Schema\PgSqlSchemaParser;
23use ZtdQuery\Platform\Postgres\Shadow\PgSqlMutationResolver;
24use ZtdQuery\Platform\Postgres\Sql\PgSqlIdentifierQuoter;
25use ZtdQuery\Platform\Postgres\Sql\PgSqlParser;
26use ZtdQuery\Platform\Postgres\Sql\Value\PgSqlCastRenderer;
27use ZtdQuery\Rewrite\QueryKind;
28use ZtdQuery\Schema\TableDefinitionRegistry;
29use ZtdQuery\Shadow\ShadowStore;
30
31/**
32 * Full pipeline fuzz test for PostgreSQL.
33 *
34 * Tests the complete pipeline: schema parse -> register -> populate ShadowStore
35 * -> rewrite DML -> apply mutation -> verify integrity.
36 *
37 * Guards the following properties:
38 * - P-SM-1: INSERT mutation increases row count
39 * - P-SM-3: UPDATE mutation preserves row count
40 * - P-SM-5: Mutations only affect the target table (table isolation)
41 * - INV-L4-01: ShadowStore maintains array-of-arrays structure after mutations
42 * - INV-L2-02/03: Plan consistency (mutation presence matches kind)
43 * - DDL -> DML pipeline continuity (CREATE TABLE enables subsequent DML)
44 */
45#[CoversNothing]
46#[Large]
47final class FullPipelineFuzzTest extends TestCase
48{
49    private const ITERATIONS = 50;
50    private PgSqlSchemaParser $schemaParser;
51    private PostgreSqlProvider $provider;
52    private \Faker\Generator $faker;
53    #[Override]
54    protected function setUp(): void
55    {
56        $this->schemaParser = new PgSqlSchemaParser();
57        $this->faker = Factory::create();
58        $this->faker->seed(20260815);
59        $this->provider = new PostgreSqlProvider($this->faker, 'pg-17.2');
60    }
61
62
63
64    /**
65     * Test create table then select does not crash.
66     */
67    public function testCreateTableThenSelectDoesNotCrash(): void
68    {
69        $this->faker->seed(20260815);
70        for ($i = 0; $i < self::ITERATIONS; $i++) {
71            $createSql = $this->provider->createTableStatement(50);
72            $definition = $this->schemaParser->parse($createSql);
73            if ($definition === null || $definition->columns === []) {
74                continue;
75            }
76            $shadowStore = new ShadowStore();
77            $registry = new TableDefinitionRegistry();
78            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
79            if ($tableName === null) {
80                continue;
81            }
82            $registry->register($tableName, $definition);
83            $fixtureRows = (new Input\SchemaRows())->generateFixtureRows($definition, $this->faker->numberBetween(0, 5));
84            $shadowStore->set($tableName, $fixtureRows);
85            $parser = new PgSqlParser();
86            $guard = new PgSqlQueryGuard($parser);
87            $castRenderer = new PgSqlCastRenderer();
88            $quoter = new PgSqlIdentifierQuoter();
89            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
90            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
91            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
92            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
93            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
94            $schemaParser = new PgSqlSchemaParser();
95            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
96            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
97            $selectSql = 'SELECT * FROM ' . (new PgSqlIdentifierQuoter())->quote($tableName);
98            try {
99                $plan = $rewriter->rewrite($selectSql);
100                self::assertNotEmpty($plan->sql());
101                self::assertSame(QueryKind::READ, $plan->kind());
102                self::assertNull($plan->mutation());
103            } catch (UnsupportedSqlException|UnknownSchemaException) {
104                continue;
105            }
106        }
107        self::addToAssertionCount(self::ITERATIONS);
108    }
109    /**
110     * Test create table then insert does not crash.
111     */
112    public function testCreateTableThenInsertDoesNotCrash(): void
113    {
114        $this->faker->seed(20260815);
115        for ($i = 0; $i < self::ITERATIONS; $i++) {
116            $createSql = $this->provider->createTableStatement(50);
117            $definition = $this->schemaParser->parse($createSql);
118            if ($definition === null || $definition->columns === []) {
119                continue;
120            }
121            $shadowStore = new ShadowStore();
122            $registry = new TableDefinitionRegistry();
123            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
124            if ($tableName === null) {
125                continue;
126            }
127            $registry->register($tableName, $definition);
128            $shadowStore->set($tableName, []);
129            $parser = new PgSqlParser();
130            $guard = new PgSqlQueryGuard($parser);
131            $castRenderer = new PgSqlCastRenderer();
132            $quoter = new PgSqlIdentifierQuoter();
133            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
134            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
135            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
136            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
137            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
138            $schemaParser = new PgSqlSchemaParser();
139            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
140            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
141            $values = (new Input\InsertLiterals($this->faker))->buildInsertValues($definition);
142            $insertSql = 'INSERT INTO ' . (new PgSqlIdentifierQuoter())->quote($tableName) . ' (' . implode(', ', array_map(fn (string $c) => (new PgSqlIdentifierQuoter())->quote($c), $definition->columns)) . ') VALUES (' . $values . ')';
143            try {
144                $plan = $rewriter->rewrite($insertSql);
145                self::assertNotEmpty($plan->sql());
146                self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
147                if ($plan->mutation() !== null) {
148                    $countBefore = count($shadowStore->get($tableName));
149                    $fakeResultRows = [(new Input\SchemaRows())->generateFixtureRows($definition, 1)[0]];
150                    $plan->mutation()->apply($shadowStore, $fakeResultRows);
151                    $storedRows = $shadowStore->get($tableName);
152                    self::assertNotEmpty($storedRows);
153                    self::assertGreaterThanOrEqual($countBefore, count($storedRows), "INSERT mutation should not decrease row count on iteration {$i}");
154                }
155            } catch (UnsupportedSqlException|UnknownSchemaException) {
156                continue;
157            }
158        }
159        self::addToAssertionCount(self::ITERATIONS);
160    }
161    /**
162     * Test create table then update does not crash.
163     */
164    public function testCreateTableThenUpdateDoesNotCrash(): void
165    {
166        $this->faker->seed(20260815);
167        for ($i = 0; $i < self::ITERATIONS; $i++) {
168            $createSql = $this->provider->createTableStatement(50);
169            $definition = $this->schemaParser->parse($createSql);
170            if ($definition === null || $definition->columns === [] || $definition->primaryKeys === []) {
171                continue;
172            }
173            $shadowStore = new ShadowStore();
174            $registry = new TableDefinitionRegistry();
175            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
176            if ($tableName === null) {
177                continue;
178            }
179            $registry->register($tableName, $definition);
180            $fixtureRows = (new Input\SchemaRows())->generateFixtureRows($definition, 3);
181            $shadowStore->set($tableName, $fixtureRows);
182            $parser = new PgSqlParser();
183            $guard = new PgSqlQueryGuard($parser);
184            $castRenderer = new PgSqlCastRenderer();
185            $quoter = new PgSqlIdentifierQuoter();
186            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
187            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
188            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
189            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
190            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
191            $schemaParser = new PgSqlSchemaParser();
192            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
193            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
194            $firstCol = $definition->columns[0];
195            $updateSql = 'UPDATE ' . (new PgSqlIdentifierQuoter())->quote($tableName) . ' SET ' . (new PgSqlIdentifierQuoter())->quote($firstCol) . ' = ' . (new PgSqlIdentifierQuoter())->quote($firstCol);
196            try {
197                $plan = $rewriter->rewrite($updateSql);
198                self::assertNotEmpty($plan->sql());
199                self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
200                if ($plan->mutation() !== null) {
201                    $countBefore = count($shadowStore->get($tableName));
202                    $plan->mutation()->apply($shadowStore, $fixtureRows);
203                    $storedRows = $shadowStore->get($tableName);
204                    self::assertCount($countBefore, $storedRows, "UPDATE mutation should preserve row count on iteration {$i}");
205                }
206            } catch (UnsupportedSqlException|UnknownSchemaException) {
207                continue;
208            }
209        }
210        self::addToAssertionCount(self::ITERATIONS);
211    }
212    /**
213     * Test create table then delete does not crash.
214     */
215    public function testCreateTableThenDeleteDoesNotCrash(): void
216    {
217        $this->faker->seed(20260815);
218        for ($i = 0; $i < self::ITERATIONS; $i++) {
219            $createSql = $this->provider->createTableStatement(50);
220            $definition = $this->schemaParser->parse($createSql);
221            if ($definition === null || $definition->columns === []) {
222                continue;
223            }
224            $shadowStore = new ShadowStore();
225            $registry = new TableDefinitionRegistry();
226            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
227            if ($tableName === null) {
228                continue;
229            }
230            $registry->register($tableName, $definition);
231            $fixtureRows = (new Input\SchemaRows())->generateFixtureRows($definition, 3);
232            $shadowStore->set($tableName, $fixtureRows);
233            $parser = new PgSqlParser();
234            $guard = new PgSqlQueryGuard($parser);
235            $castRenderer = new PgSqlCastRenderer();
236            $quoter = new PgSqlIdentifierQuoter();
237            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
238            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
239            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
240            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
241            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
242            $schemaParser = new PgSqlSchemaParser();
243            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
244            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
245            $deleteSql = 'DELETE FROM ' . (new PgSqlIdentifierQuoter())->quote($tableName);
246            try {
247                $plan = $rewriter->rewrite($deleteSql);
248                self::assertNotEmpty($plan->sql());
249                self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
250                if ($plan->mutation() !== null) {
251                    $plan->mutation()->apply($shadowStore, $fixtureRows);
252                    $storedRows = $shadowStore->get($tableName);
253                    self::assertSame([], $storedRows, "Deleting every result row should empty the shadow table on iteration {$i}");
254                }
255            } catch (UnsupportedSqlException|UnknownSchemaException) {
256                continue;
257            }
258        }
259        self::addToAssertionCount(self::ITERATIONS);
260    }
261    /**
262     * Test create table rewrite registers then dml succeeds.
263     */
264    public function testCreateTableRewriteRegistersThenDmlSucceeds(): void
265    {
266        $this->faker->seed(20260815);
267        for ($i = 0; $i < self::ITERATIONS; $i++) {
268            $createSql = $this->provider->createTableStatement(50);
269            $definition = $this->schemaParser->parse($createSql);
270            if ($definition === null || $definition->columns === []) {
271                continue;
272            }
273            $shadowStore = new ShadowStore();
274            $registry = new TableDefinitionRegistry();
275            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
276            if ($tableName === null) {
277                continue;
278            }
279            $parser = new PgSqlParser();
280            $guard = new PgSqlQueryGuard($parser);
281            $castRenderer = new PgSqlCastRenderer();
282            $quoter = new PgSqlIdentifierQuoter();
283            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
284            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
285            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
286            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
287            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
288            $schemaParser = new PgSqlSchemaParser();
289            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
290            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
291            try {
292                $createPlan = $rewriter->rewrite($createSql);
293                self::assertSame(QueryKind::DDL_SIMULATED, $createPlan->kind());
294                if ($createPlan->mutation() !== null) {
295                    $createPlan->mutation()->apply($shadowStore, []);
296                }
297                $selectSql = 'SELECT * FROM ' . (new PgSqlIdentifierQuoter())->quote($tableName);
298                $selectPlan = $rewriter->rewrite($selectSql);
299                self::assertNotEmpty($selectPlan->sql());
300                self::assertSame(QueryKind::READ, $selectPlan->kind());
301            } catch (UnsupportedSqlException|UnknownSchemaException) {
302                continue;
303            }
304        }
305        self::addToAssertionCount(self::ITERATIONS);
306    }
307    /**
308     * Test shadow store integrity after multiple operations.
309     */
310    public function testShadowStoreIntegrityAfterMultipleOperations(): void
311    {
312        $this->faker->seed(20260815);
313        for ($i = 0; $i < self::ITERATIONS; $i++) {
314            $createSql = $this->provider->createTableStatement(50);
315            $definition = $this->schemaParser->parse($createSql);
316            if ($definition === null || $definition->columns === []) {
317                continue;
318            }
319            $shadowStore = new ShadowStore();
320            $registry = new TableDefinitionRegistry();
321            $tableName = (new Input\SchemaRows())->extractTableName($createSql);
322            if ($tableName === null) {
323                continue;
324            }
325            $registry->register($tableName, $definition);
326            $fixtureRows = (new Input\SchemaRows())->generateFixtureRows($definition, 3);
327            $shadowStore->set($tableName, $fixtureRows);
328            $parser = new PgSqlParser();
329            $guard = new PgSqlQueryGuard($parser);
330            $castRenderer = new PgSqlCastRenderer();
331            $quoter = new PgSqlIdentifierQuoter();
332            $selectTransformer = new SelectTransformer($castRenderer, $quoter);
333            $insertTransformer = new InsertTransformer($parser, $selectTransformer);
334            $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
335            $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
336            $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
337            $schemaParser = new PgSqlSchemaParser();
338            $mutationResolver = new PgSqlMutationResolver($shadowStore, $registry, $schemaParser, $parser);
339            $rewriter = new PgSqlRewriter($guard, $shadowStore, $registry, $transformer, $mutationResolver, $parser);
340            $operations = ['SELECT * FROM ' . (new PgSqlIdentifierQuoter())->quote($tableName), 'INSERT INTO ' . (new PgSqlIdentifierQuoter())->quote($tableName) . ' (' . implode(', ', array_map(fn (string $c) => (new PgSqlIdentifierQuoter())->quote($c), $definition->columns)) . ') VALUES (' . (new Input\InsertLiterals($this->faker))->buildInsertValues($definition) . ')'];
341            if ($definition->primaryKeys !== []) {
342                $operations[] = 'UPDATE ' . (new PgSqlIdentifierQuoter())->quote($tableName) . ' SET ' . (new PgSqlIdentifierQuoter())->quote($definition->columns[0]) . ' = ' . (new PgSqlIdentifierQuoter())->quote($definition->columns[0]);
343            }
344            $operations[] = 'DELETE FROM ' . (new PgSqlIdentifierQuoter())->quote($tableName);
345            foreach ($operations as $sql) {
346                try {
347                    $plan = $rewriter->rewrite($sql);
348                    self::assertNotEmpty($plan->sql());
349                    self::assertInstanceOf(QueryKind::class, $plan->kind());
350                    if ($plan->mutation() !== null) {
351                        $fakeRows = (new Input\SchemaRows())->generateFixtureRows($definition, 1);
352                        $plan->mutation()->apply($shadowStore, $fakeRows);
353                    }
354                    $allData = $shadowStore->getAll();
355                    foreach ($allData as $tblName => $tblRows) {
356                        self::assertNotEmpty($tblName, 'ShadowStore table name must not be empty');
357                        foreach ($tblRows as $rowIdx => $row) {
358                            self::assertNotEmpty($row, "ShadowStore table '{$tblName}' row {$rowIdx} must not be empty");
359                        }
360                    }
361                    self::assertArrayHasKey($tableName, $allData);
362                    if ($plan->kind() === QueryKind::READ) {
363                        self::assertNull($plan->mutation(), 'READ plan must have no mutation');
364                    } elseif ($plan->kind() === QueryKind::WRITE_SIMULATED) {
365                        self::assertNotNull($plan->mutation(), 'WRITE_SIMULATED plan must have a mutation');
366                    } elseif ($plan->kind() === QueryKind::DDL_SIMULATED) {
367                        self::assertNotNull($plan->mutation(), 'DDL_SIMULATED plan must have a mutation');
368                    }
369                } catch (UnsupportedSqlException|UnknownSchemaException) {
370                    continue;
371                }
372            }
373        }
374        self::addToAssertionCount(self::ITERATIONS);
375    }
376
377
378
379}
380