packages/ztd-query-core/tests/Fake/FakeSqlRewriter.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Fake;
6
7use ZtdQuery\Exception\UnsupportedSqlException;
8use ZtdQuery\Rewrite\MultiRewritePlan;
9use ZtdQuery\Rewrite\QueryKind;
10use ZtdQuery\Rewrite\RewritePlan;
11use ZtdQuery\Rewrite\SqlRewriter;
12use ZtdQuery\Schema\TableDefinition;
13use ZtdQuery\Schema\TableDefinitionRegistry;
14use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
15use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
16use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
17use ZtdQuery\Shadow\Mutation\Table\CreateTableMutation;
18use ZtdQuery\Shadow\Mutation\Table\DropTableMutation;
19use ZtdQuery\Shadow\Mutation\Table\TruncateMutation;
20use ZtdQuery\Shadow\ShadowStore;
21use ZtdQuery\Sql\TransactionStatement;
22
23/**
24 * Fake SqlRewriter that classifies SQL via regex and builds result-select queries.
25 *
26 * Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, DROP TABLE, TRUNCATE.
27 * Uses FakeSqlTransformer for CTE injection on SELECT queries.
28 *
29 * @phpstan-import-type Row from TableDefinition
30 */
31final class FakeSqlRewriter implements SqlRewriter
32{
33    /**
34     * Transaction statement.
35     *
36     * @param string $sql
37     * @return ?TransactionStatement
38     */
39    public function transactionStatement(string $sql): ?TransactionStatement
40    {
41        return null;
42    }
43
44    private ShadowStore $shadowStore;
45
46    private TableDefinitionRegistry $registry;
47
48    private FakeSqlTransformer $transformer;
49
50    private FakeSchemaParser $schemaParser;
51
52    /**
53     * Binds the instance to what it will work from.
54     *
55     * @param ShadowStore $shadowStore
56     * @param TableDefinitionRegistry $registry
57     */
58    public function __construct(
59        ShadowStore $shadowStore,
60        TableDefinitionRegistry $registry
61    ) {
62        $this->shadowStore = $shadowStore;
63        $this->registry = $registry;
64        $this->transformer = new FakeSqlTransformer();
65        $this->schemaParser = new FakeSchemaParser();
66    }
67
68    /**
69     * Rewrite.
70     *
71     * @param string $sql
72     * @return RewritePlan
73     */
74    public function rewrite(string $sql): RewritePlan
75    {
76        $trimmed = trim($sql);
77
78        if ($trimmed === '') {
79            throw new UnsupportedSqlException($sql, 'Empty');
80        }
81
82        $kind = $this->classify($trimmed);
83
84        if ($kind === null) {
85            throw new UnsupportedSqlException($sql, 'Unsupported');
86        }
87
88        return match ($kind) {
89            QueryKind::READ => $this->rewriteSelect($trimmed),
90            QueryKind::WRITE_SIMULATED => $this->rewriteWrite($trimmed),
91            QueryKind::DDL_SIMULATED => $this->rewriteDdl($trimmed),
92            QueryKind::SKIPPED => new RewritePlan($trimmed, QueryKind::SKIPPED),
93        };
94    }
95
96    /**
97     * Rewrite multiple.
98     *
99     * @param string $sql
100     * @return MultiRewritePlan
101     */
102    public function rewriteMultiple(string $sql): MultiRewritePlan
103    {
104        $statements = $this->splitStatements($sql);
105
106        $plans = [];
107        foreach ($statements as $stmt) {
108            $plans[] = $this->rewrite($stmt);
109        }
110
111        return new MultiRewritePlan($plans);
112    }
113
114    /**
115     * Split statements.
116     *
117     * @param string $sql
118     */
119    public function splitStatements(string $sql): array
120    {
121        return array_values(array_filter(
122            array_map('trim', explode(';', $sql)),
123            static fn (string $s): bool => $s !== ''
124        ));
125    }
126
127    /**
128     * Classify.
129     *
130     * @param string $sql
131     * @return ?QueryKind
132     */
133    public function classify(string $sql): ?QueryKind
134    {
135        $upper = strtoupper(ltrim($sql));
136
137        if (str_starts_with($upper, 'SELECT') || str_starts_with($upper, '(SELECT')) {
138            return QueryKind::READ;
139        }
140        if (str_starts_with($upper, 'INSERT')) {
141            return QueryKind::WRITE_SIMULATED;
142        }
143        if (str_starts_with($upper, 'UPDATE')) {
144            return QueryKind::WRITE_SIMULATED;
145        }
146        if (str_starts_with($upper, 'DELETE')) {
147            return QueryKind::WRITE_SIMULATED;
148        }
149        if (str_starts_with($upper, 'TRUNCATE')) {
150            return QueryKind::WRITE_SIMULATED;
151        }
152        if (str_starts_with($upper, 'REPLACE')) {
153            return QueryKind::WRITE_SIMULATED;
154        }
155        if (str_starts_with($upper, 'CREATE TABLE')) {
156            return QueryKind::DDL_SIMULATED;
157        }
158        if (str_starts_with($upper, 'DROP TABLE')) {
159            return QueryKind::DDL_SIMULATED;
160        }
161        return null;
162    }
163
164    /**
165     * Rewrite select.
166     *
167     * @param string $sql
168     * @return RewritePlan
169     */
170    public function rewriteSelect(string $sql): RewritePlan
171    {
172        $tables = $this->buildShadowContext();
173
174        if ($tables !== []) {
175            $sql = $this->transformer->transform($sql, $tables);
176        }
177
178        return new RewritePlan($sql, QueryKind::READ);
179    }
180
181    /**
182     * @throws UnsupportedSqlException When the statement is not one this fake simulates
183     */
184    public function rewriteWrite(string $sql): RewritePlan
185    {
186        $upper = strtoupper(ltrim($sql));
187
188        if (str_starts_with($upper, 'INSERT') || str_starts_with($upper, 'REPLACE')) {
189            return $this->rewriteInsert($sql);
190        }
191        if (str_starts_with($upper, 'UPDATE')) {
192            return $this->rewriteUpdate($sql);
193        }
194        if (str_starts_with($upper, 'DELETE')) {
195            return $this->rewriteDelete($sql);
196        }
197        if (str_starts_with($upper, 'TRUNCATE')) {
198            return $this->rewriteTruncate($sql);
199        }
200
201        throw new UnsupportedSqlException($sql, 'Unsupported write');
202    }
203
204    /**
205     * Rewrite insert.
206     *
207     * @param string $sql
208     * @return RewritePlan
209     */
210    public function rewriteInsert(string $sql): RewritePlan
211    {
212        $tableName = $this->extractTableFromInsert($sql);
213        $definition = $tableName !== null ? $this->registry->get($tableName) : null;
214        $primaryKeys = $definition !== null ? $definition->primaryKeys : [];
215
216        $mutation = new InsertMutation($tableName ?? 'unknown', $primaryKeys);
217
218        $resultSql = $this->buildInsertResultSelect($sql, $tableName, $definition);
219
220        return new RewritePlan($resultSql, QueryKind::WRITE_SIMULATED, $mutation);
221    }
222
223    /**
224     * Rewrite update.
225     *
226     * @param string $sql
227     * @return RewritePlan
228     */
229    public function rewriteUpdate(string $sql): RewritePlan
230    {
231        $tableName = $this->extractTableFromUpdate($sql);
232        $definition = $tableName !== null ? $this->registry->get($tableName) : null;
233        $primaryKeys = $definition !== null ? $definition->primaryKeys : [];
234
235        $mutation = new UpdateMutation($tableName ?? 'unknown', $primaryKeys);
236
237        $columns = $definition !== null ? $definition->columns : [];
238        $resultSql = 'SELECT ' . ($columns !== [] ? implode(', ', $columns) : '*') . ' FROM ' . ($tableName ?? 'unknown');
239
240        $tables = $this->buildShadowContext();
241        if ($tables !== []) {
242            $resultSql = $this->transformer->transform($resultSql, $tables);
243        }
244
245        return new RewritePlan($resultSql, QueryKind::WRITE_SIMULATED, $mutation);
246    }
247
248    /**
249     * Rewrite delete.
250     *
251     * @param string $sql
252     * @return RewritePlan
253     */
254    public function rewriteDelete(string $sql): RewritePlan
255    {
256        $tableName = $this->extractTableFromDelete($sql);
257        $definition = $tableName !== null ? $this->registry->get($tableName) : null;
258        $primaryKeys = $definition !== null ? $definition->primaryKeys : [];
259
260        $mutation = new DeleteMutation($tableName ?? 'unknown', $primaryKeys);
261
262        $columns = $definition !== null ? $definition->columns : [];
263        $resultSql = 'SELECT ' . ($columns !== [] ? implode(', ', $columns) : '*') . ' FROM ' . ($tableName ?? 'unknown');
264
265        $tables = $this->buildShadowContext();
266        if ($tables !== []) {
267            $resultSql = $this->transformer->transform($resultSql, $tables);
268        }
269
270        return new RewritePlan($resultSql, QueryKind::WRITE_SIMULATED, $mutation);
271    }
272
273    /**
274     * Rewrite truncate.
275     *
276     * @param string $sql
277     * @return RewritePlan
278     */
279    public function rewriteTruncate(string $sql): RewritePlan
280    {
281        if (preg_match('/TRUNCATE\s+(?:TABLE\s+)?[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
282            $tableName = $m[1];
283        } else {
284            $tableName = 'unknown';
285        }
286
287        $mutation = new TruncateMutation($tableName);
288
289        return new RewritePlan('SELECT 1 WHERE FALSE', QueryKind::WRITE_SIMULATED, $mutation);
290    }
291
292    /**
293     * @throws UnsupportedSqlException When the statement is not one this fake simulates
294     */
295    public function rewriteDdl(string $sql): RewritePlan
296    {
297        $upper = strtoupper(ltrim($sql));
298
299        if (str_starts_with($upper, 'CREATE TABLE')) {
300            $definition = $this->schemaParser->parse($sql);
301            $tableName = $this->extractTableFromCreate($sql) ?? 'unknown';
302            $mutation = new CreateTableMutation(
303                $tableName,
304                $definition ?? new TableDefinition([], [], [], [], []),
305                $this->registry,
306                $sql,
307            );
308
309            return new RewritePlan('SELECT 1 WHERE FALSE', QueryKind::DDL_SIMULATED, $mutation);
310        }
311
312        if (str_starts_with($upper, 'DROP TABLE')) {
313            $tableName = $this->extractTableFromDrop($sql) ?? 'unknown';
314            $mutation = new DropTableMutation($tableName, $this->registry, $sql);
315
316            return new RewritePlan('SELECT 1 WHERE FALSE', QueryKind::DDL_SIMULATED, $mutation);
317        }
318
319        throw new UnsupportedSqlException($sql, 'Unsupported DDL');
320    }
321
322    /**
323     * @return array<string, array{rows: array<int, Row>, columns: array<int, string>, columnTypes: array<string, \ZtdQuery\Schema\ColumnDeclaration>}>
324     */
325    public function buildShadowContext(): array
326    {
327        $tables = [];
328        foreach ($this->shadowStore->getAll() as $tableName => $rows) {
329            $definition = $this->registry->get($tableName);
330            if ($definition === null) {
331                continue;
332            }
333
334            $tables[$tableName] = [
335                'rows' => $rows,
336                'columns' => $definition->columns,
337                'columnTypes' => $definition->typedColumns,
338            ];
339        }
340
341        return $tables;
342    }
343
344    /**
345     * Reads table from insert.
346     *
347     * @param string $sql
348     * @return ?string
349     */
350    public function extractTableFromInsert(string $sql): ?string
351    {
352        if (preg_match('/INSERT\s+(?:IGNORE\s+)?INTO\s+[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
353            return $m[1];
354        }
355
356        return null;
357    }
358
359    /**
360     * Reads table from update.
361     *
362     * @param string $sql
363     * @return ?string
364     */
365    public function extractTableFromUpdate(string $sql): ?string
366    {
367        if (preg_match('/UPDATE\s+[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
368            return $m[1];
369        }
370
371        return null;
372    }
373
374    /**
375     * Reads table from delete.
376     *
377     * @param string $sql
378     * @return ?string
379     */
380    public function extractTableFromDelete(string $sql): ?string
381    {
382        if (preg_match('/DELETE\s+FROM\s+[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
383            return $m[1];
384        }
385
386        return null;
387    }
388
389    /**
390     * Reads table from create.
391     *
392     * @param string $sql
393     * @return ?string
394     */
395    public function extractTableFromCreate(string $sql): ?string
396    {
397        if (preg_match('/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
398            return $m[1];
399        }
400
401        return null;
402    }
403
404    /**
405     * Reads table from drop.
406     *
407     * @param string $sql
408     * @return ?string
409     */
410    public function extractTableFromDrop(string $sql): ?string
411    {
412        if (preg_match('/DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`"\']?(\w+)[`"\']?/i', $sql, $m) === 1) {
413            return $m[1];
414        }
415
416        return null;
417    }
418
419    /**
420     * Builds insert result select.
421     *
422     * @param string $sql
423     * @param ?string $tableName
424     * @param ?TableDefinition $definition
425     * @return string
426     */
427    public function buildInsertResultSelect(string $sql, ?string $tableName, ?TableDefinition $definition): string
428    {
429        $columns = $definition !== null ? $definition->columns : [];
430        $resultSql = 'SELECT ' . ($columns !== [] ? implode(', ', $columns) : '*') . ' FROM ' . ($tableName ?? 'unknown');
431
432        $tables = $this->buildShadowContext();
433        if ($tables !== []) {
434            $resultSql = $this->transformer->transform($resultSql, $tables);
435        }
436
437        return $resultSql;
438    }
439
440    /**
441     * Empty result select.
442     *
443     * @return string
444     */
445    public function emptyResultSelect(): string
446    {
447        return 'SELECT 1 WHERE FALSE';
448    }
449}
450