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