packages/ztd-query-core/tests/Contract/TransformerContractTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Contract;
6
7use PHPUnit\Framework\TestCase;
8use ZtdQuery\Rewrite\SqlTransformer;
9use ZtdQuery\Schema\ColumnDeclaration;
10use ZtdQuery\Schema\ColumnTypeFamily;
11use ZtdQuery\Schema\TableDefinition;
12
13/**
14 * Abstract contract test for SqlTransformer implementations.
15 *
16 * Enforces contracts for the SqlTransformer interface:
17 * - P-TF-1: Empty table context returns original SQL unchanged.
18 * - P-TF-2: CTE-injected SQL starts with WITH.
19 * - P-TF-3: Table names appear as CTE names in transformed output.
20 * - P-TF-4: Transform is deterministic.
21 * - P-TF-5: Output is always non-empty.
22 *
23 * @phpstan-import-type Row from TableDefinition
24 */
25abstract class TransformerContractTest extends TestCase
26{
27    /**
28     * Answers the transformer this dialect rewrites a query with.
29     *
30     * @return SqlTransformer The transformer under test
31     */
32    abstract protected function createTransformer(): SqlTransformer;
33
34    /**
35     * A valid SELECT statement referencing the "users" table.
36     */
37    abstract protected function selectSql(): string;
38
39    /**
40     * Empty table context must return the original SQL unchanged (P-TF-1).
41     */
42    public function testEmptyTableContextReturnsOriginalSql(): void
43    {
44        $transformer = $this->createTransformer();
45        $sql = $this->selectSql();
46
47        $result = $transformer->transform($sql, []);
48
49        self::assertSame($sql, $result);
50    }
51
52    /**
53     * When table context contains data, the result must start with WITH (P-TF-2).
54     */
55    public function testCteInjectedSqlStartsWithWith(): void
56    {
57        $transformer = $this->createTransformer();
58        $sql = $this->selectSql();
59        $tables = $this->singleRowTableContext();
60
61        $result = $transformer->transform($sql, $tables);
62
63        self::assertStringStartsWith('WITH', ltrim($result));
64    }
65
66    /**
67     * Table name must appear as a CTE name in the transformed output (P-TF-3).
68     */
69    public function testTableNameUsedAsCte(): void
70    {
71        $transformer = $this->createTransformer();
72        $sql = $this->selectSql();
73        $tables = $this->singleRowTableContext();
74
75        $result = $transformer->transform($sql, $tables);
76
77        self::assertStringContainsString('users', $result);
78        self::assertStringContainsString('SELECT', strtoupper($result));
79    }
80
81    /**
82     * Transform must be deterministic: same inputs produce identical outputs (P-TF-4).
83     */
84    public function testTransformIsDeterministic(): void
85    {
86        $transformer = $this->createTransformer();
87        $sql = $this->selectSql();
88        $tables = $this->singleRowTableContext();
89
90        $result1 = $transformer->transform($sql, $tables);
91        $result2 = $transformer->transform($sql, $tables);
92
93        self::assertSame($result1, $result2);
94    }
95
96    /**
97     * Transform output must always be non-empty (P-TF-5).
98     */
99    public function testTransformOutputIsNonEmpty(): void
100    {
101        $transformer = $this->createTransformer();
102        $sql = $this->selectSql();
103        $tables = $this->singleRowTableContext();
104
105        $result = $transformer->transform($sql, $tables);
106
107        self::assertNotEmpty($result);
108    }
109
110    /**
111     * CTE-injected output must contain SELECT and UNION ALL structure for data rows (P-TF-3).
112     */
113    public function testCteContainsSelectUnionStructure(): void
114    {
115        $transformer = $this->createTransformer();
116        $sql = $this->selectSql();
117        $tables = [
118            'users' => [
119                'rows' => [
120                    ['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'],
121                    ['id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com'],
122                ],
123                'columns' => ['id', 'name', 'email'],
124                'columnTypes' => [
125                    'id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, $this->nativeIntegerType()),
126                    'name' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
127                    'email' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
128                ],
129            ],
130        ];
131
132        $result = $transformer->transform($sql, $tables);
133        $upper = strtoupper($result);
134
135        self::assertTrue(
136            str_contains($upper, 'UNION ALL') || str_contains($upper, 'VALUES'),
137            'Multiple data rows should produce UNION ALL or VALUES in the CTE, got: ' . $result
138        );
139        self::assertStringContainsString('SELECT', $upper);
140        self::assertStringContainsString(' AS ', $upper, 'CTE must contain AS keyword');
141    }
142
143    /**
144     * CTE output must contain CAST expressions for typed columns.
145     */
146    public function testCteContainsCastExpressions(): void
147    {
148        $transformer = $this->createTransformer();
149        $sql = $this->selectSql();
150        $tables = $this->singleRowTableContext();
151
152        $result = $transformer->transform($sql, $tables);
153        $upper = strtoupper($result);
154
155        self::assertStringContainsString('CAST(', $upper, 'CTE output must contain CAST expressions for typed columns');
156    }
157
158    /**
159     * Transform with empty rows but known columns should still produce valid output.
160     */
161    public function testEmptyRowsWithColumnsReturnsWithClause(): void
162    {
163        $transformer = $this->createTransformer();
164        $sql = $this->selectSql();
165        $tables = $this->emptyRowsTableContext();
166
167        $result = $transformer->transform($sql, $tables);
168
169        self::assertStringStartsWith('WITH', ltrim($result));
170        self::assertNotEmpty($result);
171    }
172
173    /**
174     * Build a single-row table context for the "users" table.
175     *
176     * @return array<string, array{rows: list<Row>, columns: array<int, string>, columnTypes: array<string, ColumnDeclaration>}>
177     */
178    protected function singleRowTableContext(): array
179    {
180        return [
181            'users' => [
182                'rows' => [
183                    ['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com'],
184                ],
185                'columns' => ['id', 'name', 'email'],
186                'columnTypes' => [
187                    'id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, $this->nativeIntegerType()),
188                    'name' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
189                    'email' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
190                ],
191            ],
192        ];
193    }
194
195    /**
196     * Build an empty-rows table context for the "users" table (columns known, no data).
197     *
198     * @return array<string, array{rows: list<Row>, columns: array<int, string>, columnTypes: array<string, ColumnDeclaration>}>
199     */
200    protected function emptyRowsTableContext(): array
201    {
202        return [
203            'users' => [
204                'rows' => [],
205                'columns' => ['id', 'name', 'email'],
206                'columnTypes' => [
207                    'id' => new ColumnDeclaration(ColumnTypeFamily::INTEGER, $this->nativeIntegerType()),
208                    'name' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
209                    'email' => new ColumnDeclaration(ColumnTypeFamily::STRING, $this->nativeStringType()),
210                ],
211            ],
212        ];
213    }
214
215    /**
216     * Return the platform-specific native type for INTEGER.
217     */
218    protected function nativeIntegerType(): string
219    {
220        return 'INTEGER';
221    }
222
223    /**
224     * Return the platform-specific native type for VARCHAR/STRING.
225     */
226    protected function nativeStringType(): string
227    {
228        return 'VARCHAR(255)';
229    }
230}
231