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