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