packages/ztd-query-mysql/tests/Unit/Rewrite/MySqlQueryGuardTest.php

1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Rewrite;
6
7use PhpMyAdmin\SqlParser\Parser;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use RuntimeException;
12use ZtdQuery\Platform\MySql\Rewrite\MySqlQueryGuard;
13use ZtdQuery\Platform\MySql\Sql\MySqlParser;
14use ZtdQuery\Rewrite\QueryKind;
15
16#[UsesClass(\ZtdQuery\Platform\MySql\Sql\Diagnostic\DiagnosticKeywords::class)]
17#[UsesClass(\ZtdQuery\Platform\MySql\Sql\OptionalInsertIntoNormalizer::class)]
18#[UsesClass(\ZtdQuery\Platform\MySql\Rewrite\Classification\CteStatementKind::class)]
19#[CoversClass(MySqlQueryGuard::class)]
20#[UsesClass(\ZtdQuery\Platform\MySql\Sql\Diagnostic\MySqlReadOnlyDiagnosticStatement::class)]
21#[UsesClass(MySqlParser::class)]
22#[UsesClass(\ZtdQuery\Platform\MySql\Sql\MySqlLexerProfile::class)]
23class MySqlQueryGuardTest extends TestCase
24{
25    public function testClassifiesReadStatements(): void
26    {
27        $guard = new MySqlQueryGuard(new MySqlParser());
28        self::assertSame(QueryKind::READ, $guard->classify('SELECT * FROM users'));
29        self::assertSame(QueryKind::READ, $guard->classify('WITH cte AS (SELECT 1) SELECT * FROM users'));
30    }
31
32    public function testClassifiesSingleSelectExpressionsSplitByThirdPartyParser(): void
33    {
34        $guard = new MySqlQueryGuard(new MySqlParser());
35
36        self::assertSame(QueryKind::READ, $guard->classify('SELECT 1 EXCEPT SELECT 2'));
37        self::assertSame(QueryKind::READ, $guard->classify('SELECT 1 INTERSECT SELECT 2'));
38        self::assertSame(QueryKind::READ, $guard->classify('SELECT 1 WHERE CASE WHEN EXISTS(SELECT 1) THEN TRUE ELSE FALSE END'));
39        self::assertNull($guard->classify('SELECT 1; SELECT 2'));
40    }
41
42    public function testClassifiesInsertWithCompoundSelectAsOneWrite(): void
43    {
44        $guard = new MySqlQueryGuard(new MySqlParser());
45
46        self::assertSame(
47            QueryKind::WRITE_SIMULATED,
48            $guard->classify('INSERT INTO combined (id) SELECT id FROM archive UNION ALL SELECT id FROM current'),
49        );
50    }
51
52    public function testClassifiesWriteStatements(): void
53    {
54        $guard = new MySqlQueryGuard(new MySqlParser());
55        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('UPDATE users SET name = "A" WHERE id = 1'));
56        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('DELETE FROM users WHERE id = 1'));
57        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('INSERT INTO users (id, name) VALUES (1, "A")'));
58        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT 1) UPDATE users SET name = "B" WHERE id = 1'));
59        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH `tmp31862` AS( SELECT x862873 ) UPDATE tmp743270 SET tmp526670 = y32462'));
60    }
61
62    public function testReturnsNullForUnsupportedStatements(): void
63    {
64        $guard = new MySqlQueryGuard(new MySqlParser());
65        self::assertNull($guard->classify('DROP DATABASE test'));
66        self::assertNull($guard->classify('CREATE DATABASE test'));
67        self::assertNull($guard->classify('SELECT 1; SELECT 2'));
68    }
69
70    public function testClassifiesReadOnlyDiagnosticsAsRead(): void
71    {
72        $guard = new MySqlQueryGuard(new MySqlParser());
73
74        self::assertSame(QueryKind::READ, $guard->classify('EXPLAIN SELECT * FROM users'));
75        self::assertSame(QueryKind::READ, $guard->classify('EXPLAIN UPDATE users SET active = FALSE'));
76        self::assertSame(QueryKind::READ, $guard->classify('DESCRIBE users'));
77        self::assertSame(QueryKind::READ, $guard->classify('SHOW CREATE TABLE users'));
78        self::assertNull($guard->classify('EXPLAIN ANALYZE UPDATE users SET active = FALSE'));
79    }
80
81    public function testClassifiesDdlStatements(): void
82    {
83        $guard = new MySqlQueryGuard(new MySqlParser());
84        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('DROP TABLE users'));
85        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('CREATE TABLE demo (id INT)'));
86        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('ALTER TABLE users ADD COLUMN email VARCHAR(255)'));
87        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('ALTER TABLE users DROP COLUMN email'));
88        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('ALTER TABLE users MODIFY COLUMN name VARCHAR(500)'));
89        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('ALTER TABLE users CHANGE COLUMN name full_name VARCHAR(255)'));
90        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('ALTER TABLE users RENAME TO members'));
91    }
92
93    public function testAssertAllowedThrowsOnForbidden(): void
94    {
95        $guard = new MySqlQueryGuard(new MySqlParser());
96
97        self::expectException(RuntimeException::class);
98        self::expectExceptionMessage('ZTD Write Protection');
99
100        $guard->assertAllowed('DROP DATABASE test');
101    }
102
103    public function testAssertAllowedAcceptsParsedStatement(): void
104    {
105        $guard = new MySqlQueryGuard(new MySqlParser());
106        $parser = new Parser('SELECT 1');
107        $statement = $parser->statements[0];
108
109        $guard->assertAllowed($statement);
110
111        self::assertSame(QueryKind::READ, $guard->classify('SELECT 1'));
112    }
113
114    public function testClassifiesSelectIntoAsNull(): void
115    {
116        $guard = new MySqlQueryGuard(new MySqlParser());
117        self::assertNull($guard->classify('SELECT * INTO OUTFILE "/tmp/data" FROM users'));
118    }
119
120    public function testClassifiesTruncateAsWriteSimulated(): void
121    {
122        $guard = new MySqlQueryGuard(new MySqlParser());
123        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('TRUNCATE TABLE users'));
124    }
125
126    public function testClassifiesReplaceAsWriteSimulated(): void
127    {
128        $guard = new MySqlQueryGuard(new MySqlParser());
129        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify("REPLACE INTO users (id, name) VALUES (1, 'Alice')"));
130    }
131
132    public function testClassifiesLoadDataAsWriteSimulated(): void
133    {
134        $guard = new MySqlQueryGuard(new MySqlParser());
135
136        self::assertSame(
137            QueryKind::WRITE_SIMULATED,
138            $guard->classify("LOAD DATA LOCAL INFILE '/tmp/users.tsv' INTO TABLE users"),
139        );
140    }
141
142    public function testClassifiesEmptySqlAsNull(): void
143    {
144        $guard = new MySqlQueryGuard(new MySqlParser());
145        self::assertNull($guard->classify(''));
146    }
147
148    public function testClassifiesCreateIndexAsNull(): void
149    {
150        $guard = new MySqlQueryGuard(new MySqlParser());
151        self::assertNull($guard->classify('CREATE INDEX idx_name ON users (name)'));
152    }
153
154    public function testClassifiesDropIndexAsNull(): void
155    {
156        $guard = new MySqlQueryGuard(new MySqlParser());
157        self::assertNull($guard->classify('DROP INDEX idx_name ON users'));
158    }
159
160    public function testAssertAllowedDoesNotThrowForWrite(): void
161    {
162        $guard = new MySqlQueryGuard(new MySqlParser());
163        $guard->assertAllowed('INSERT INTO users (id) VALUES (1)');
164        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('INSERT INTO users (id) VALUES (1)'));
165    }
166
167    public function testAssertAllowedDoesNotThrowForDdl(): void
168    {
169        $guard = new MySqlQueryGuard(new MySqlParser());
170        $guard->assertAllowed('CREATE TABLE t (id INT)');
171        self::assertSame(QueryKind::DDL_SIMULATED, $guard->classify('CREATE TABLE t (id INT)'));
172    }
173
174    public function testClassifyWithStatementNoCteParser(): void
175    {
176        $guard = new MySqlQueryGuard(new MySqlParser());
177        self::assertSame(QueryKind::READ, $guard->classify('WITH cte AS (SELECT 1) SELECT * FROM cte'));
178    }
179
180    public function testClassifyWithInsertFallback(): void
181    {
182        $guard = new MySqlQueryGuard(new MySqlParser());
183        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT 1) INSERT INTO users SELECT * FROM cte'));
184    }
185
186    public function testClassifyWithDeleteFallback(): void
187    {
188        $guard = new MySqlQueryGuard(new MySqlParser());
189        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT 1) DELETE FROM users WHERE id IN (SELECT * FROM cte)'));
190    }
191
192    public function testClassifyWithSelectFallback(): void
193    {
194        $guard = new MySqlQueryGuard(new MySqlParser());
195        self::assertSame(QueryKind::READ, $guard->classify('WITH cte AS (SELECT 1) SELECT * FROM cte'));
196    }
197
198    public function testClassifyWithQuotedStringsFallback(): void
199    {
200        $guard = new MySqlQueryGuard(new MySqlParser());
201        $result = $guard->classify("WITH cte AS (SELECT 'SELECT') SELECT * FROM cte");
202        self::assertSame(QueryKind::READ, $result);
203    }
204
205    public function testClassifyMultipleStatementsReturnsNull(): void
206    {
207        $guard = new MySqlQueryGuard(new MySqlParser());
208        self::assertNull($guard->classify('SELECT 1; SELECT 2'));
209    }
210
211    public function testClassifyAlterWithoutTableReturnsNull(): void
212    {
213        $guard = new MySqlQueryGuard(new MySqlParser());
214        self::assertNull($guard->classify('ALTER DATABASE test DEFAULT CHARACTER SET utf8'));
215    }
216
217    public function testAssertAllowedThrowsForSelectInto(): void
218    {
219        $guard = new MySqlQueryGuard(new MySqlParser());
220        self::expectException(RuntimeException::class);
221        $guard->assertAllowed('SELECT * INTO OUTFILE "/tmp/data" FROM users');
222    }
223
224    public function testClassifyWithEscapedBackslashInStringFallback(): void
225    {
226        $guard = new MySqlQueryGuard(new MySqlParser());
227        $result = $guard->classify("WITH cte AS (SELECT 'it\\'s') SELECT * FROM cte");
228        self::assertSame(QueryKind::READ, $result);
229    }
230
231    public function testClassifyWithDoubleQuotedStringFallback(): void
232    {
233        $guard = new MySqlQueryGuard(new MySqlParser());
234        $result = $guard->classify('WITH cte AS (SELECT "DELETE") SELECT * FROM cte');
235        self::assertSame(QueryKind::READ, $result);
236    }
237
238    public function testClassifyWithBacktickQuotedIdentifierFallback(): void
239    {
240        $guard = new MySqlQueryGuard(new MySqlParser());
241        $result = $guard->classify('WITH cte AS (SELECT `UPDATE` FROM t) SELECT * FROM cte');
242        self::assertSame(QueryKind::READ, $result);
243    }
244
245    public function testClassifyWithNestedParensFallback(): void
246    {
247        $guard = new MySqlQueryGuard(new MySqlParser());
248        $result = $guard->classify('WITH cte AS (SELECT (1+2)) SELECT * FROM cte');
249        self::assertSame(QueryKind::READ, $result);
250    }
251
252    public function testClassifyWithFallbackNonAlphaAfterKeyword(): void
253    {
254        $guard = new MySqlQueryGuard(new MySqlParser());
255        $result = $guard->classify('WITH cte AS (SELECT 1) DELETE FROM users WHERE id = 1');
256        self::assertSame(QueryKind::WRITE_SIMULATED, $result);
257    }
258
259    public function testClassifyWithNullCteParserReturnsRead(): void
260    {
261        $guard = new MySqlQueryGuard(new MySqlParser());
262        $result = $guard->classify('WITH cte AS (SELECT 1) GRANT ALL ON users TO admin');
263        self::assertSame(QueryKind::READ, $result);
264    }
265
266    public function testClassifyDropViewReturnsNull(): void
267    {
268        $guard = new MySqlQueryGuard(new MySqlParser());
269        self::assertNull($guard->classify('DROP VIEW v'));
270    }
271
272    public function testClassifyCreateViewReturnsNull(): void
273    {
274        $guard = new MySqlQueryGuard(new MySqlParser());
275        self::assertNull($guard->classify('CREATE VIEW v AS SELECT 1'));
276    }
277
278    public function testClassifyStatementWithCreateNonTableReturnsNull(): void
279    {
280        $guard = new MySqlQueryGuard(new MySqlParser());
281        self::assertNull($guard->classify('CREATE INDEX idx ON t(c)'));
282    }
283
284    public function testClassifyStatementWithAlterNonTableReturnsNull(): void
285    {
286        $guard = new MySqlQueryGuard(new MySqlParser());
287        self::assertNull($guard->classify('ALTER DATABASE test CHARACTER SET utf8'));
288    }
289
290    public function testClassifyStatementDropNonTableReturnsNull(): void
291    {
292        $guard = new MySqlQueryGuard(new MySqlParser());
293        self::assertNull($guard->classify('DROP INDEX idx ON t'));
294    }
295
296    public function testClassifyWithFallbackDeleteInWith(): void
297    {
298        $guard = new MySqlQueryGuard(new MySqlParser());
299        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT 1) DELETE FROM users WHERE id = 1'));
300    }
301
302    public function testClassifyWithFallbackInsertInWith(): void
303    {
304        $guard = new MySqlQueryGuard(new MySqlParser());
305        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT 1) INSERT INTO users (id) VALUES (1)'));
306    }
307
308    public function testClassifyWithFallbackReplaceInWithReturnsNull(): void
309    {
310        $guard = new MySqlQueryGuard(new MySqlParser());
311        self::assertNull($guard->classify('WITH cte AS (SELECT 1) REPLACE INTO users (id) VALUES (1)'));
312    }
313
314    public function testClassifyWithFallbackTruncateInWithReturnsNull(): void
315    {
316        $guard = new MySqlQueryGuard(new MySqlParser());
317        self::assertNull($guard->classify('WITH cte AS (SELECT 1) TRUNCATE TABLE users'));
318    }
319
320    public function testClassifyWithFallbackCreateInWithReturnsNull(): void
321    {
322        $guard = new MySqlQueryGuard(new MySqlParser());
323        self::assertNull($guard->classify('WITH cte AS (SELECT 1) CREATE TABLE t (id INT)'));
324    }
325
326    public function testClassifyWithFallbackDropInWithReturnsNull(): void
327    {
328        $guard = new MySqlQueryGuard(new MySqlParser());
329        self::assertNull($guard->classify('WITH cte AS (SELECT 1) DROP TABLE users'));
330    }
331
332    public function testClassifyWithFallbackAlterInWithReturnsNull(): void
333    {
334        $guard = new MySqlQueryGuard(new MySqlParser());
335        self::assertNull($guard->classify('WITH cte AS (SELECT 1) ALTER TABLE users ADD COLUMN x INT'));
336    }
337
338    public function testClassifyWithFallbackQuotedStringContainingKeyword(): void
339    {
340        $guard = new MySqlQueryGuard(new MySqlParser());
341        self::assertSame(QueryKind::READ, $guard->classify("WITH cte AS (SELECT 'DELETE FROM x') SELECT * FROM users"));
342    }
343
344    public function testClassifyWithFallbackBacktickQuotedIdentifier(): void
345    {
346        $guard = new MySqlQueryGuard(new MySqlParser());
347        self::assertSame(QueryKind::READ, $guard->classify('WITH `DELETE` AS (SELECT 1) SELECT * FROM `DELETE`'));
348    }
349
350    public function testClassifyWithFallbackDoubleQuotedString(): void
351    {
352        $guard = new MySqlQueryGuard(new MySqlParser());
353        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('WITH cte AS (SELECT "test") UPDATE t SET a = 1'));
354    }
355
356    public function testClassifyWithFallbackEscapedQuoteInString(): void
357    {
358        $guard = new MySqlQueryGuard(new MySqlParser());
359        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify("WITH cte AS (SELECT 'it\\'s') UPDATE t SET a = 1"));
360    }
361
362    public function testClassifyWithFallbackLowercaseKeywords(): void
363    {
364        $guard = new MySqlQueryGuard(new MySqlParser());
365        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('with cte as (select 1) update t set a = 1'));
366    }
367
368    public function testClassifyStatementSelectIntoReturnsNull(): void
369    {
370        $guard = new MySqlQueryGuard(new MySqlParser());
371        self::assertNull($guard->classify('SELECT * INTO OUTFILE "/tmp/data" FROM users'));
372    }
373
374    public function testClassifyCreateNonTableDoesNotFallThroughToAlter(): void
375    {
376        $guard = new MySqlQueryGuard(new MySqlParser());
377        $result = $guard->classify('CREATE INDEX idx ON t(c)');
378        self::assertNull($result);
379    }
380
381    public function testClassifyDropNonTableDoesNotFallThroughToAlter(): void
382    {
383        $guard = new MySqlQueryGuard(new MySqlParser());
384        $result = $guard->classify('DROP INDEX idx ON t');
385        self::assertNull($result);
386    }
387
388    public function testClassifyAlterNonTableDoesNotFallThroughToWith(): void
389    {
390        $guard = new MySqlQueryGuard(new MySqlParser());
391        $result = $guard->classify('ALTER DATABASE test CHARACTER SET utf8');
392        self::assertNull($result);
393    }
394
395    public function testClassifyTruncateIsWriteSimulated(): void
396    {
397        $guard = new MySqlQueryGuard(new MySqlParser());
398        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify('TRUNCATE TABLE users'));
399    }
400
401    public function testClassifyReplaceIsWriteSimulated(): void
402    {
403        $guard = new MySqlQueryGuard(new MySqlParser());
404        self::assertSame(QueryKind::WRITE_SIMULATED, $guard->classify("REPLACE INTO users (id, name) VALUES (1, 'Alice')"));
405    }
406
407    public function testAssertAllowedThrowsForUnsupportedSql(): void
408    {
409        $guard = new MySqlQueryGuard(new MySqlParser());
410        self::expectException(RuntimeException::class);
411        $guard->assertAllowed('DROP DATABASE test');
412    }
413
414    public function testAssertAllowedDoesNotThrowForSelect(): void
415    {
416        $guard = new MySqlQueryGuard(new MySqlParser());
417        $guard->assertAllowed('SELECT 1');
418        self::assertSame(QueryKind::READ, $guard->classify('SELECT 1'));
419    }
420
421    public function testAssertAllowedWithParsedStatement(): void
422    {
423        $guard = new MySqlQueryGuard(new MySqlParser());
424        $parser = new Parser('SELECT * FROM users');
425        $guard->assertAllowed($parser->statements[0]);
426        self::assertSame(QueryKind::READ, $guard->classify('SELECT * FROM users'));
427    }
428
429    public function testClassifyStatementReturnsReadForParsedSelectDirectly(): void
430    {
431        $guard = new MySqlQueryGuard(new MySqlParser());
432        $mySqlParser = new MySqlParser();
433        $stmts = $mySqlParser->parse('SELECT 1');
434        self::assertSame(QueryKind::READ, $guard->classifyStatement($stmts[0]));
435    }
436
437    public function testClassifyStatementReturnsNullForCreateNonTableDirectly(): void
438    {
439        $guard = new MySqlQueryGuard(new MySqlParser());
440        $parser = new Parser('CREATE INDEX idx ON t(c)');
441        $result = $guard->classifyStatement($parser->statements[0]);
442        self::assertNull($result);
443    }
444
445    public function testClassifyStatementReturnsNullForDropNonTableDirectly(): void
446    {
447        $guard = new MySqlQueryGuard(new MySqlParser());
448        $parser = new Parser('DROP INDEX idx ON t');
449        $result = $guard->classifyStatement($parser->statements[0]);
450        self::assertNull($result);
451    }
452
453    public function testClassifyStatementReturnsNullForAlterNonTableDirectly(): void
454    {
455        $guard = new MySqlQueryGuard(new MySqlParser());
456        $parser = new Parser('ALTER DATABASE test CHARACTER SET utf8');
457        $result = $guard->classifyStatement($parser->statements[0]);
458        self::assertNull($result);
459    }
460
461    public function testClassifyWithFallbackNestedSubquerySkipsKeywordsInParens(): void
462    {
463        $guard = new MySqlQueryGuard(new MySqlParser());
464        self::assertSame(
465            QueryKind::READ,
466            $guard->classify('WITH cte AS (SELECT 1, (SELECT 2)) SELECT * FROM cte')
467        );
468    }
469
470    public function testClassifyWithFallbackCaseSensitiveKeywordHandling(): void
471    {
472        $guard = new MySqlQueryGuard(new MySqlParser());
473        self::assertSame(
474            QueryKind::WRITE_SIMULATED,
475            $guard->classify('with cte as (select 1) update t set a = 1')
476        );
477    }
478
479    public function testClassifyWithFallbackSingleQuoteWithBackslashEscape(): void
480    {
481        $guard = new MySqlQueryGuard(new MySqlParser());
482        self::assertSame(
483            QueryKind::WRITE_SIMULATED,
484            $guard->classify("WITH cte AS (SELECT 'it\\'s a UPDATE') DELETE FROM t WHERE id = 1")
485        );
486    }
487
488    public function testClassifyWithFallbackBacktickNotEscapedByBackslash(): void
489    {
490        $guard = new MySqlQueryGuard(new MySqlParser());
491        self::assertSame(
492            QueryKind::READ,
493            $guard->classify('WITH `DELETE` AS (SELECT 1) SELECT * FROM `DELETE`')
494        );
495    }
496
497    public function testClassifyWithFallbackReplaceFallsToWriteSimulated(): void
498    {
499        $guard = new MySqlQueryGuard(new MySqlParser());
500        self::assertSame(
501            QueryKind::WRITE_SIMULATED,
502            $guard->classify('WITH `t` AS( SELECT 1 ) REPLACE INTO users (id) VALUES (1)')
503        );
504    }
505
506    public function testClassifyWithFallbackTruncateFallsToWriteSimulated(): void
507    {
508        $guard = new MySqlQueryGuard(new MySqlParser());
509        self::assertSame(
510            QueryKind::WRITE_SIMULATED,
511            $guard->classify('WITH `t` AS( SELECT 1 ) TRUNCATE TABLE users')
512        );
513    }
514
515    public function testClassifyWithFallbackCreateFallsToDdlSimulated(): void
516    {
517        $guard = new MySqlQueryGuard(new MySqlParser());
518        self::assertSame(
519            QueryKind::DDL_SIMULATED,
520            $guard->classify('WITH `t` AS( SELECT 1 ) CREATE TABLE t2 (id INT)')
521        );
522    }
523
524    public function testClassifyWithFallbackDropFallsToDdlSimulated(): void
525    {
526        $guard = new MySqlQueryGuard(new MySqlParser());
527        self::assertSame(
528            QueryKind::DDL_SIMULATED,
529            $guard->classify('WITH `t` AS( SELECT 1 ) DROP TABLE users')
530        );
531    }
532
533    public function testClassifyWithFallbackAlterFallsToDdlSimulated(): void
534    {
535        $guard = new MySqlQueryGuard(new MySqlParser());
536        self::assertSame(
537            QueryKind::DDL_SIMULATED,
538            $guard->classify('WITH `t` AS( SELECT 1 ) ALTER TABLE users ADD COLUMN x INT')
539        );
540    }
541
542    public function testClassifyWithFallbackSelectAfterCteFallsToRead(): void
543    {
544        $guard = new MySqlQueryGuard(new MySqlParser());
545        self::assertSame(
546            QueryKind::READ,
547            $guard->classify('WITH `t` AS( SELECT 1 ) SELECT * FROM users')
548        );
549    }
550
551    public function testClassifyWithFallbackUnrecognizedKeywordFallsThrough(): void
552    {
553        $guard = new MySqlQueryGuard(new MySqlParser());
554        self::assertSame(
555            QueryKind::READ,
556            $guard->classify('WITH `t` AS( SELECT 1 ) GRANT ALL ON t TO admin')
557        );
558    }
559
560    public function testSelectClassifiesAsRead(): void
561    {
562        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify('SELECT id, name FROM users WHERE id = 1');
563        self::assertSame(QueryKind::READ, $kind);
564    }
565
566    public function testInsertClassifiesAsWriteSimulated(): void
567    {
568        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify("INSERT INTO users (id, name) VALUES (1, 'Alice')");
569        self::assertSame(QueryKind::WRITE_SIMULATED, $kind);
570    }
571
572    public function testUpdateClassifiesAsWriteSimulated(): void
573    {
574        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify("UPDATE users SET name = 'Bob' WHERE id = 1");
575        self::assertSame(QueryKind::WRITE_SIMULATED, $kind);
576    }
577
578    public function testDeleteClassifiesAsWriteSimulated(): void
579    {
580        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify('DELETE FROM users WHERE id = 1');
581        self::assertSame(QueryKind::WRITE_SIMULATED, $kind);
582    }
583
584    public function testCreateTableClassifiesAsDdlSimulated(): void
585    {
586        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify('CREATE TABLE orders (id INT PRIMARY KEY, amount DECIMAL(10,2))');
587        self::assertSame(QueryKind::DDL_SIMULATED, $kind);
588    }
589
590    public function testDropTableClassifiesAsDdlSimulated(): void
591    {
592        $kind = (new MySqlQueryGuard(new MySqlParser()))->classify('DROP TABLE orders');
593        self::assertSame(QueryKind::DDL_SIMULATED, $kind);
594    }
595
596    public function testClassifyReturnsNullForGarbageInput(): void
597    {
598        self::assertNull((new MySqlQueryGuard(new MySqlParser()))->classify('NOT VALID SQL %%% @@@'));
599    }
600}
601