packages/ztd-query-core/tests/Contract/RewriterContractTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Contract;
6
7use PHPUnit\Framework\TestCase;
8use ZtdQuery\Exception\UnsupportedSqlException;
9use ZtdQuery\Platform\SchemaParser;
10use ZtdQuery\Rewrite\QueryKind;
11use ZtdQuery\Rewrite\SqlRewriter;
12use ZtdQuery\Schema\TableDefinitionRegistry;
13use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
14use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
15use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
16use ZtdQuery\Shadow\ShadowStore;
17
18/**
19 * Abstract contract test for SqlRewriter implementations.
20 *
21 * Each platform provides a concrete subclass with platform-specific SQL fixtures.
22 * Enforces contracts defined in quality-standards.md Section 1.1 and Section 5.1.
23 */
24abstract class RewriterContractTest extends TestCase
25{
26 /**
27 * Answers the rewriter this dialect shadows with.
28 *
29 * @param ShadowStore $store Rows the rewriter will shadow tables with
30 * @param TableDefinitionRegistry $registry What those tables will and will not hold
31 *
32 * @return SqlRewriter The rewriter under test
33 */
34 abstract protected function createRewriter(ShadowStore $store, TableDefinitionRegistry $registry): SqlRewriter;
35
36 /**
37 * Answers the parser this dialect reads a declaration with.
38 *
39 * @return SchemaParser The parser the rewriter is fed from
40 */
41 abstract protected function createSchemaParser(): SchemaParser;
42
43 /**
44 * Answers a SELECT this dialect accepts.
45 *
46 * @return string The statement
47 */
48 abstract protected function selectSql(): string;
49
50 /**
51 * Answers an INSERT this dialect accepts.
52 *
53 * @return string The statement
54 */
55 abstract protected function insertSql(): string;
56
57 /**
58 * Answers an UPDATE this dialect accepts.
59 *
60 * @return string The statement
61 */
62 abstract protected function updateSql(): string;
63
64 /**
65 * Answers a DELETE this dialect accepts.
66 *
67 * @return string The statement
68 */
69 abstract protected function deleteSql(): string;
70
71 /**
72 * Answers a CREATE TABLE this dialect accepts.
73 *
74 * @return string The statement
75 */
76 abstract protected function createTableSql(): string;
77
78 /**
79 * Answers a DROP TABLE this dialect accepts.
80 *
81 * @return string The statement
82 */
83 abstract protected function dropTableSql(): string;
84
85 /**
86 * Answers a statement this dialect will not rewrite.
87 *
88 * @return string The statement
89 */
90 abstract protected function unsupportedSql(): string;
91
92 /**
93 * Answers the CREATE TABLE the shared users fixture is declared by.
94 *
95 * @return string The statement
96 */
97 abstract protected function usersCreateTableSql(): string;
98
99 /**
100 * Build a rewriter pre-loaded with the users table schema.
101 */
102 protected function buildRewriter(?ShadowStore $store = null, ?TableDefinitionRegistry $registry = null): SqlRewriter
103 {
104 $store = $store ?? new ShadowStore();
105 $registry = $registry ?? new TableDefinitionRegistry();
106
107 $parser = $this->createSchemaParser();
108 $definition = $parser->parse($this->usersCreateTableSql());
109 if ($definition !== null) {
110 $registry->register('users', $definition);
111 }
112
113 return $this->createRewriter($store, $registry);
114 }
115
116 /**
117 * SELECT queries must return READ kind.
118 */
119 public function testSelectReturnsReadKind(): void
120 {
121 $rewriter = $this->buildRewriter();
122 $plan = $rewriter->rewrite($this->selectSql());
123
124 self::assertSame(QueryKind::READ, $plan->kind());
125 }
126
127 /**
128 * INSERT queries must return WRITE_SIMULATED with an InsertMutation (P-RW-1, P-RW-2).
129 */
130 public function testInsertReturnsWriteSimulatedWithMutation(): void
131 {
132 $rewriter = $this->buildRewriter();
133 $plan = $rewriter->rewrite($this->insertSql());
134
135 self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
136 self::assertNotNull($plan->mutation());
137 self::assertInstanceOf(InsertMutation::class, $plan->mutation());
138 self::assertSame('users', $plan->mutation()->tableName());
139 }
140
141 /**
142 * UPDATE queries must return WRITE_SIMULATED with an UpdateMutation (P-RW-1, P-RW-2).
143 */
144 public function testUpdateReturnsWriteSimulatedWithMutation(): void
145 {
146 $store = new ShadowStore();
147 $store->set('users', [['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']]);
148
149 $rewriter = $this->buildRewriter($store);
150 $plan = $rewriter->rewrite($this->updateSql());
151
152 self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
153 self::assertNotNull($plan->mutation());
154 self::assertInstanceOf(UpdateMutation::class, $plan->mutation());
155 self::assertSame('users', $plan->mutation()->tableName());
156 }
157
158 /**
159 * DELETE queries must return WRITE_SIMULATED with a DeleteMutation (P-RW-1, P-RW-2).
160 */
161 public function testDeleteReturnsWriteSimulatedWithMutation(): void
162 {
163 $store = new ShadowStore();
164 $store->set('users', [['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']]);
165
166 $rewriter = $this->buildRewriter($store);
167 $plan = $rewriter->rewrite($this->deleteSql());
168
169 self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
170 self::assertNotNull($plan->mutation());
171 self::assertInstanceOf(DeleteMutation::class, $plan->mutation());
172 self::assertSame('users', $plan->mutation()->tableName());
173 }
174
175 /**
176 * CREATE TABLE must return DDL_SIMULATED kind.
177 */
178 public function testCreateTableReturnsDdlSimulated(): void
179 {
180 $rewriter = $this->buildRewriter();
181 $plan = $rewriter->rewrite($this->createTableSql());
182
183 self::assertSame(QueryKind::DDL_SIMULATED, $plan->kind());
184 }
185
186 /**
187 * DROP TABLE must return DDL_SIMULATED kind.
188 */
189 public function testDropTableReturnsDdlSimulated(): void
190 {
191 $rewriter = $this->buildRewriter();
192 $plan = $rewriter->rewrite($this->dropTableSql());
193
194 self::assertSame(QueryKind::DDL_SIMULATED, $plan->kind());
195 }
196
197 /**
198 * Unsupported SQL must throw UnsupportedSqlException.
199 */
200 public function testUnsupportedSqlThrowsException(): void
201 {
202 $this->expectException(UnsupportedSqlException::class);
203
204 $rewriter = $this->buildRewriter();
205 $rewriter->rewrite($this->unsupportedSql());
206 }
207
208 /**
209 * Empty input must throw UnsupportedSqlException.
210 */
211 public function testEmptyInputThrowsException(): void
212 {
213 $this->expectException(UnsupportedSqlException::class);
214
215 $rewriter = $this->buildRewriter();
216 $rewriter->rewrite('');
217 }
218
219 /**
220 * Rewrite must be deterministic: same input, same output (P-RW-6).
221 */
222 public function testRewriteIsDeterministic(): void
223 {
224 $store = new ShadowStore();
225 $store->set('users', [['id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com']]);
226
227 $rewriter = $this->buildRewriter($store);
228
229 $plan1 = $rewriter->rewrite($this->selectSql());
230 $plan2 = $rewriter->rewrite($this->selectSql());
231
232 self::assertSame($plan1->sql(), $plan2->sql());
233 self::assertSame($plan1->kind(), $plan2->kind());
234 }
235
236 /**
237 * READ plans must have no mutation (P-RW-3).
238 */
239 public function testReadPlanHasNoMutation(): void
240 {
241 $rewriter = $this->buildRewriter();
242 $plan = $rewriter->rewrite($this->selectSql());
243
244 self::assertSame(QueryKind::READ, $plan->kind());
245 self::assertNull($plan->mutation());
246 }
247
248 /**
249 * WRITE_SIMULATED plans must have a non-null InsertMutation for INSERT (P-RW-2).
250 */
251 public function testWritePlanHasNonNullMutation(): void
252 {
253 $rewriter = $this->buildRewriter();
254 $plan = $rewriter->rewrite($this->insertSql());
255
256 self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
257 self::assertNotNull($plan->mutation());
258 self::assertInstanceOf(InsertMutation::class, $plan->mutation());
259 }
260
261 /**
262 * Rewrite output SQL must be non-empty and contain SELECT for read queries (P-RW-4).
263 */
264 public function testRewriteOutputIsNonEmpty(): void
265 {
266 $rewriter = $this->buildRewriter();
267 $plan = $rewriter->rewrite($this->selectSql());
268
269 self::assertNotEmpty($plan->sql());
270 self::assertStringContainsString('SELECT', strtoupper($plan->sql()));
271 }
272
273 /**
274 * INSERT rewrite output SQL must be a SELECT (result-select pattern) (P-RW-5).
275 */
276 public function testInsertRewriteOutputContainsSelect(): void
277 {
278 $rewriter = $this->buildRewriter();
279 $plan = $rewriter->rewrite($this->insertSql());
280
281 self::assertSame(QueryKind::WRITE_SIMULATED, $plan->kind());
282 self::assertMatchesRegularExpression(
283 '/^(?:WITH\b|SELECT\b)/i',
284 $plan->sql(),
285 'INSERT rewrite must produce a result-select query starting with SELECT or WITH...SELECT'
286 );
287 }
288}
289