packages/ztd-query-postgres/fuzz/TransformerFuzzTest.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\UnsupportedSqlException;
14use ZtdQuery\Platform\Postgres\Rewrite\Transformer\SelectTransformer;
15use ZtdQuery\Platform\Postgres\Sql\PgSqlIdentifierQuoter;
16use ZtdQuery\Platform\Postgres\Sql\Value\PgSqlCastRenderer;
17use ZtdQuery\Schema\ColumnDeclaration;
18use ZtdQuery\Schema\ColumnTypeFamily;
19
20/**
21 * Fuzz tests for SelectTransformer::transform().
22 *
23 * Guards the following properties:
24 * - P-TF-1: transform() accepts generated SELECTs or reports UnsupportedSqlException
25 * - P-TF-2: When SQL references a shadowed table, the output contains a WITH clause (CTE injection)
26 * - P-TF-3: Accepted SELECTs with empty table context retain their SQL
27 * - P-TF-4: Empty-row shadow tables still inject CTE with WHERE FALSE
28 */
29#[CoversNothing]
30#[Large]
31final class TransformerFuzzTest extends TestCase
32{
33    private const ITERATIONS = 100;
34    private SelectTransformer $transformer;
35    private PostgreSqlProvider $provider;
36    #[Override]
37    protected function setUp(): void
38    {
39        $this->transformer = new SelectTransformer(new PgSqlCastRenderer(), new PgSqlIdentifierQuoter());
40        $faker = Factory::create();
41        $this->provider = new PostgreSqlProvider($faker, 'pg-17.2');
42        $faker->seed(20260815);
43    }
44    /**
45     * Test transform does not crash on random select with empty tables.
46     */
47    public function testTransformDoesNotCrashOnRandomSelectWithEmptyTables(): void
48    {
49        for ($i = 0; $i < self::ITERATIONS; $i++) {
50            $sql = $this->provider->selectStatement(50);
51            try {
52                $result = $this->transformer->transform($sql, []);
53            } catch (UnsupportedSqlException $exception) {
54                self::assertStringContainsString('ZTD Write Protection:', $exception->getMessage());
55                continue;
56            }
57            self::assertNotEmpty($result, "transform() returned empty string on iteration {$i}");
58            self::assertSame($sql, $result);
59        }
60        self::addToAssertionCount(self::ITERATIONS);
61    }
62    /**
63     * Test transform with shadow data contains with clause.
64     */
65    public function testTransformWithShadowDataContainsWithClause(): void
66    {
67        $tables = ['users' => ['rows' => [['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'], ['id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com']], 'columns' => ['id', 'name', 'email'], 'columnTypes' => ['id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER'), 'name' => new ColumnDeclaration(ColumnTypeFamily::TEXT, 'TEXT'), 'email' => new ColumnDeclaration(ColumnTypeFamily::TEXT, 'TEXT')]]];
68        $withCount = 0;
69        for ($i = 0; $i < self::ITERATIONS; $i++) {
70            $sql = $this->provider->selectStatement(50);
71            try {
72                $result = $this->transformer->transform($sql, $tables);
73            } catch (UnsupportedSqlException $exception) {
74                self::assertStringContainsString('ZTD Write Protection:', $exception->getMessage());
75                continue;
76            }
77            self::assertNotEmpty($result, "transform() returned empty string on iteration {$i}");
78            if (stripos($sql, 'users') !== false) {
79                self::assertStringContainsString('WITH', $result, "transform() should inject CTE when SQL references shadowed table on iteration {$i}");
80                $withCount++;
81            }
82        }
83        self::addToAssertionCount(self::ITERATIONS);
84    }
85    /**
86     * Test transform with empty rows contains with clause.
87     */
88    public function testTransformWithEmptyRowsContainsWithClause(): void
89    {
90        $tables = ['users' => ['rows' => [], 'columns' => ['id', 'name', 'email'], 'columnTypes' => ['id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, 'INTEGER'), 'name' => new ColumnDeclaration(ColumnTypeFamily::TEXT, 'TEXT'), 'email' => new ColumnDeclaration(ColumnTypeFamily::TEXT, 'TEXT')]]];
91        for ($i = 0; $i < self::ITERATIONS; $i++) {
92            $sql = $this->provider->selectStatement(50);
93            try {
94                $result = $this->transformer->transform($sql, $tables);
95            } catch (UnsupportedSqlException $exception) {
96                self::assertStringContainsString('ZTD Write Protection:', $exception->getMessage());
97                continue;
98            }
99            self::assertNotEmpty($result, "transform() returned empty string on iteration {$i}");
100            if (stripos($sql, 'users') !== false) {
101                self::assertStringContainsString('WITH', $result, "transform() should inject CTE when SQL references shadowed table on iteration {$i}");
102            }
103        }
104        self::addToAssertionCount(self::ITERATIONS);
105    }
106}
107