diff --git a/README.md b/README.md index 23e584b..188f091 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,11 @@ composer require utopia-php/query - [Serializing an AST](#serializing-an-ast) - [Walking and Rewriting](#walking-and-rewriting) - [Builder Round-Trip](#builder-round-trip) -- [Wire Protocol Parsers](#wire-protocol-parsers) - - [SQL Parser](#sql-parser) - - [MySQL Parser](#mysql-parser) - - [PostgreSQL Parser](#postgresql-parser) - - [MongoDB Parser](#mongodb-parser) +- [Wire Protocol Classifiers](#wire-protocol-classifiers) + - [SQL Classifier](#sql-classifier) + - [MySQL Classifier](#mysql-classifier) + - [PostgreSQL Classifier](#postgresql-classifier) + - [MongoDB Classifier](#mongodb-classifier) - [Compiler Interface](#compiler-interface) - [Contributing](#contributing) - [License](#license) @@ -2627,7 +2627,7 @@ Column types map to BSON types: `string` → `string`, `integer`/`bigInteger` ## SQL Tokenizer and AST -Everything above generates SQL. This layer goes the other way: it takes existing SQL text and turns it into an inspectable, rewritable tree. Use it to validate columns against an allow-list, inject tenant predicates into queries you did not author, rename tables, or translate a statement from one dialect's quoting to another's. +Everything above generates SQL. This layer goes the other way: it takes existing SQL text and turns it into an inspectable, rewritable tree. (For merely deciding whether a query reads or writes, without building a tree, see [Wire Protocol Classifiers](#wire-protocol-classifiers).) Use it to validate columns against an allow-list, inject tenant predicates into queries you did not author, rename tables, or translate a statement from one dialect's quoting to another's. ```php use Utopia\Query\Tokenizer\Tokenizer; @@ -2735,27 +2735,29 @@ $rebuilt = Builder::fromAst($ast); // static $rebuilt->build()->query; // SELECT `id`, `name` FROM `users` WHERE `status` IN (?) ``` -## Wire Protocol Parsers +## Wire Protocol Classifiers -The `Parser` interface classifies raw database traffic into query types (`Read`, `Write`, `TransactionBegin`, `TransactionEnd`, `Transaction`, `Unknown`). This is useful for connection proxies, audit logging, and read/write splitting. +The `Classifier` interface sorts raw database traffic into query types (`Read`, `Write`, `TransactionBegin`, `TransactionEnd`, `Transaction`, `Unknown`). This is useful for connection proxies, audit logging, and read/write splitting. ```php -use Utopia\Query\Parser; +use Utopia\Query\Classifier; use Utopia\Query\Type; ``` -### SQL Parser +> **Not a parser.** A classifier reads a message's leading keyword (or, for document protocols, its first command name) and looks it up — it never builds a structure. To parse SQL text into a syntax tree you can inspect and rewrite, use [`AST\Parser`](#sql-tokenizer-and-ast) instead. -The abstract `Parser\SQL` class provides keyword-based classification for SQL dialects: +### SQL Classifier + +The abstract `Classifier\SQL` class provides keyword-based classification for SQL dialects: ```php -use Utopia\Query\Parser\SQL; +use Utopia\Query\Classifier\SQL; // Classify SQL text directly -$type = $parser->classifySQL('SELECT * FROM users'); // Type::Read -$type = $parser->classifySQL('INSERT INTO users ...'); // Type::Write -$type = $parser->classifySQL('BEGIN'); // Type::TransactionBegin -$type = $parser->classifySQL('COMMIT'); // Type::TransactionEnd +$type = $classifier->classifySQL('SELECT * FROM users'); // Type::Read +$type = $classifier->classifySQL('INSERT INTO users ...'); // Type::Write +$type = $classifier->classifySQL('BEGIN'); // Type::TransactionBegin +$type = $classifier->classifySQL('COMMIT'); // Type::TransactionEnd ``` Read keywords: `SELECT`, `SHOW`, `DESCRIBE`, `DESC`, `EXPLAIN`, `WITH` (when followed by a read), `TABLE`, `VALUES`. @@ -2768,41 +2770,41 @@ Anything else — including `RENAME`, `REPLACE`, `LOAD`, `MERGE`, and `EXECUTE` Special handling: `COPY` is classified based on direction (`FROM STDIN` = Write, `TO STDOUT` = Read). -### MySQL Parser +### MySQL Classifier Parses MySQL wire protocol binary packets: ```php -use Utopia\Query\Parser\MySQL; +use Utopia\Query\Classifier\MySQL; -$parser = new MySQL(); -$type = $parser->parse($rawPacketData); // Type::Read, Write, TransactionBegin, etc. +$classifier = new MySQL(); +$type = $classifier->classify($rawPacketData); // Type::Read, Write, TransactionBegin, etc. ``` Recognizes `COM_QUERY` (`0x03`, classified via its SQL text), `COM_STMT_PREPARE` (`0x16`), `COM_STMT_EXECUTE` (`0x17`), `COM_STMT_SEND_LONG_DATA` (`0x18`), `COM_STMT_CLOSE` (`0x19`), and `COM_STMT_RESET` (`0x1A`). The prepared-statement commands are routed to the primary. -### PostgreSQL Parser +### PostgreSQL Classifier Parses PostgreSQL wire protocol messages: ```php -use Utopia\Query\Parser\PostgreSQL; +use Utopia\Query\Classifier\PostgreSQL; -$parser = new PostgreSQL(); -$type = $parser->parse($rawMessageData); // Type::Read, Write, TransactionBegin, etc. +$classifier = new PostgreSQL(); +$type = $classifier->classify($rawMessageData); // Type::Read, Write, TransactionBegin, etc. ``` Handles message types `Q` (simple query, classified via its SQL text), `P` (parse), `B` (bind), and `E` (execute). Other message types, including terminate and startup messages, return `Type::Unknown`. -### MongoDB Parser +### MongoDB Classifier Parses MongoDB OP_MSG binary protocol messages: ```php -use Utopia\Query\Parser\MongoDB; +use Utopia\Query\Classifier\MongoDB; -$parser = new MongoDB(); -$type = $parser->parse($rawOpMsgData); // Type::Read, Write, TransactionBegin, etc. +$classifier = new MongoDB(); +$type = $classifier->classify($rawOpMsgData); // Type::Read, Write, TransactionBegin, etc. ``` Extracts the command name from BSON documents and classifies: diff --git a/src/Query/AST/Parser.php b/src/Query/AST/Parser.php index ba62ffb..194f1d9 100644 --- a/src/Query/AST/Parser.php +++ b/src/Query/AST/Parser.php @@ -27,6 +27,16 @@ use Utopia\Query\Tokenizer\Token; use Utopia\Query\Tokenizer\TokenType; +/** + * Recursive-descent SQL parser. + * + * Consumes filtered {@see Token}s from a {@see \Utopia\Query\Tokenizer\Tokenizer} + * and builds a {@see Select} tree that can be inspected, rewritten via + * {@see Walker} and {@see Visitor}, and re-serialized by {@see Serializer}. + * + * Not to be confused with {@see \Utopia\Query\Classifier}, which only reads a + * message's leading keyword to route it to a primary or replica. + */ class Parser { private const int MAX_DEPTH = 256; diff --git a/src/Query/Classifier.php b/src/Query/Classifier.php new file mode 100644 index 0000000..7169030 --- /dev/null +++ b/src/Query/Classifier.php @@ -0,0 +1,24 @@ +parser = new MongoDB(); + $this->classifier = new MongoDB(); } /** @@ -74,91 +74,91 @@ private function encodeBsonDocument(array $doc): string public function testFindCommand(): void { $data = $this->buildOpMsg(['find' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testAggregateCommand(): void { $data = $this->buildOpMsg(['aggregate' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testCountCommand(): void { $data = $this->buildOpMsg(['count' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testDistinctCommand(): void { $data = $this->buildOpMsg(['distinct' => 'users', 'key' => 'name', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testListCollectionsCommand(): void { $data = $this->buildOpMsg(['listCollections' => 1, '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testListDatabasesCommand(): void { $data = $this->buildOpMsg(['listDatabases' => 1, '$db' => 'admin']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testListIndexesCommand(): void { $data = $this->buildOpMsg(['listIndexes' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testDbStatsCommand(): void { $data = $this->buildOpMsg(['dbStats' => 1, '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testCollStatsCommand(): void { $data = $this->buildOpMsg(['collStats' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testExplainCommand(): void { $data = $this->buildOpMsg(['explain' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testGetMoreCommand(): void { $data = $this->buildOpMsg(['getMore' => 12345, '$db' => 'mydb']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testServerStatusCommand(): void { $data = $this->buildOpMsg(['serverStatus' => 1, '$db' => 'admin']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testPingCommand(): void { $data = $this->buildOpMsg(['ping' => 1, '$db' => 'admin']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testHelloCommand(): void { $data = $this->buildOpMsg(['hello' => 1, '$db' => 'admin']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testIsMasterCommand(): void { $data = $this->buildOpMsg(['isMaster' => 1, '$db' => 'admin']); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } // -- Write Commands -- @@ -166,61 +166,61 @@ public function testIsMasterCommand(): void public function testInsertCommand(): void { $data = $this->buildOpMsg(['insert' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testUpdateCommand(): void { $data = $this->buildOpMsg(['update' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testDeleteCommand(): void { $data = $this->buildOpMsg(['delete' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testFindAndModifyCommand(): void { $data = $this->buildOpMsg(['findAndModify' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testCreateCommand(): void { $data = $this->buildOpMsg(['create' => 'new_collection', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testDropCommand(): void { $data = $this->buildOpMsg(['drop' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testCreateIndexesCommand(): void { $data = $this->buildOpMsg(['createIndexes' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testDropIndexesCommand(): void { $data = $this->buildOpMsg(['dropIndexes' => 'users', '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testDropDatabaseCommand(): void { $data = $this->buildOpMsg(['dropDatabase' => 1, '$db' => 'mydb']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } public function testRenameCollectionCommand(): void { $data = $this->buildOpMsg(['renameCollection' => 'users', '$db' => 'admin']); - $this->assertSame(Type::Write, $this->parser->parse($data)); + $this->assertSame(Type::Write, $this->classifier->classify($data)); } // -- Transaction Commands -- @@ -228,19 +228,19 @@ public function testRenameCollectionCommand(): void public function testStartTransaction(): void { $data = $this->buildOpMsg(['find' => 'users', '$db' => 'mydb', 'startTransaction' => true]); - $this->assertSame(Type::TransactionBegin, $this->parser->parse($data)); + $this->assertSame(Type::TransactionBegin, $this->classifier->classify($data)); } public function testCommitTransaction(): void { $data = $this->buildOpMsg(['commitTransaction' => 1, '$db' => 'admin']); - $this->assertSame(Type::TransactionEnd, $this->parser->parse($data)); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($data)); } public function testAbortTransaction(): void { $data = $this->buildOpMsg(['abortTransaction' => 1, '$db' => 'admin']); - $this->assertSame(Type::TransactionEnd, $this->parser->parse($data)); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($data)); } public function testStartTransactionFlagOnNonEligibleCommandIsIgnored(): void @@ -250,7 +250,7 @@ public function testStartTransactionFlagOnNonEligibleCommandIsIgnored(): void // as TransactionBegin — ping is not transaction-eligible, and the // parser skips the scan entirely on the hot path. $data = $this->buildOpMsg(['ping' => 1, '$db' => 'admin', 'startTransaction' => true]); - $this->assertSame(Type::Read, $this->parser->parse($data)); + $this->assertSame(Type::Read, $this->classifier->classify($data)); } public function testStartTransactionFlagOnEligibleCommands(): void @@ -259,7 +259,7 @@ public function testStartTransactionFlagOnEligibleCommands(): void $data = $this->buildOpMsg([$command => 'users', '$db' => 'mydb', 'startTransaction' => true]); $this->assertSame( Type::TransactionBegin, - $this->parser->parse($data), + $this->classifier->classify($data), \sprintf('Command %s with startTransaction should be TransactionBegin', $command), ); } @@ -269,7 +269,7 @@ public function testStartTransactionFlagOnEligibleCommands(): void public function testTooShortPacket(): void { - $this->assertSame(Type::Unknown, $this->parser->parse("\x00\x00\x00\x00")); + $this->assertSame(Type::Unknown, $this->classifier->classify("\x00\x00\x00\x00")); } public function testWrongOpcode(): void @@ -282,13 +282,13 @@ public function testWrongOpcode(): void . \pack('V', 0) . \pack('V', 2004); // wrong opcode - $this->assertSame(Type::Unknown, $this->parser->parse($header . $body)); + $this->assertSame(Type::Unknown, $this->classifier->classify($header . $body)); } public function testUnknownCommand(): void { $data = $this->buildOpMsg(['customCommand' => 1, '$db' => 'mydb']); - $this->assertSame(Type::Unknown, $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $this->classifier->classify($data)); } public function testEmptyBsonDocument(): void @@ -301,7 +301,7 @@ public function testEmptyBsonDocument(): void . \pack('V', 0) . \pack('V', 2013); - $this->assertSame(Type::Unknown, $this->parser->parse($header . $body)); + $this->assertSame(Type::Unknown, $this->classifier->classify($header . $body)); } public function testMalformedBsonStringLengthDoesNotCrash(): void @@ -330,7 +330,7 @@ public function testMalformedBsonStringLengthDoesNotCrash(): void // The hasBsonKey scan for startTransaction must bail safely // (returning false), and the first-key command lookup is 'foo', // which is unknown — so classification is Unknown. - $this->assertSame(Type::Unknown, $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $this->classifier->classify($data)); } public function testMalformedBsonBinaryLengthDoesNotCrash(): void @@ -352,7 +352,7 @@ public function testMalformedBsonBinaryLengthDoesNotCrash(): void $data = $header . $body; - $this->assertSame(Type::Unknown, $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $this->classifier->classify($data)); } public function testMalformedBsonNestedDocumentLengthDoesNotCrash(): void @@ -378,7 +378,7 @@ public function testMalformedBsonNestedDocumentLengthDoesNotCrash(): void $data = $header . $body; - $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $result = $this->withStrictErrors(fn () => $this->classifier->classify($data)); $this->assertSame(Type::Unknown, $result); } @@ -404,7 +404,7 @@ public function testMalformedBsonOuterDocumentLengthDoesNotCrash(): void // docLen before scanning keys. With no valid classification available, // the parser returns Unknown. The important guarantee is no crash / // out-of-bounds read, so we run under strict error handling. - $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $result = $this->withStrictErrors(fn () => $this->classifier->classify($data)); $this->assertSame(Type::Unknown, $result); } @@ -428,7 +428,7 @@ public function testMalformedBsonRegexRunsToEofWithoutCrash(): void $data = $header . $body; // No crash; first key 'rx' is unknown → Unknown. - $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $result = $this->withStrictErrors(fn () => $this->classifier->classify($data)); $this->assertSame(Type::Unknown, $result); } @@ -455,7 +455,7 @@ public function testMalformedBsonDbPointerLengthDoesNotCrash(): void // First key 'ref' is unknown → Unknown. Important: no crash while // hasBsonKey walks the malformed DBPointer. - $result = $this->withStrictErrors(fn () => $this->parser->parse($data)); + $result = $this->withStrictErrors(fn () => $this->classifier->classify($data)); $this->assertSame(Type::Unknown, $result); } @@ -484,14 +484,12 @@ private function withStrictErrors(callable $callback): mixed } } - public function testClassifySqlReturnsUnknown(): void + public function testDoesNotExposeSqlHelpers(): void { - $this->assertSame(Type::Unknown, $this->parser->classifySQL('SELECT * FROM users')); - } + $methods = \get_class_methods($this->classifier); - public function testExtractKeywordReturnsEmpty(): void - { - $this->assertSame('', $this->parser->extractKeyword('SELECT')); + $this->assertNotContains('classifySQL', $methods); + $this->assertNotContains('extractKeyword', $methods); } // -- Performance -- @@ -508,7 +506,7 @@ public function testParsePerformance(): void $start = \hrtime(true); for ($i = 0; $i < $iterations; $i++) { - $this->parser->parse($data); + $this->classifier->classify($data); } $elapsed = (\hrtime(true) - $start) / 1_000_000_000; $perQuery = ($elapsed / $iterations) * 1_000_000; @@ -516,7 +514,7 @@ public function testParsePerformance(): void $this->assertLessThan( 2.0, $perQuery, - \sprintf('MongoDB parse took %.3f us/query (target: < 2.0 us)', $perQuery) + \sprintf('MongoDB classify took %.3f us/query (target: < 2.0 us)', $perQuery) ); } @@ -540,7 +538,7 @@ public function testTransactionScanPerformance(): void $start = \hrtime(true); for ($i = 0; $i < $iterations; $i++) { - $this->parser->parse($data); + $this->classifier->classify($data); } $elapsed = (\hrtime(true) - $start) / 1_000_000_000; $perQuery = ($elapsed / $iterations) * 1_000_000; diff --git a/tests/Query/Parser/MySQLTest.php b/tests/Query/Classifier/MySQLTest.php similarity index 56% rename from tests/Query/Parser/MySQLTest.php rename to tests/Query/Classifier/MySQLTest.php index bf9c422..70c9642 100644 --- a/tests/Query/Parser/MySQLTest.php +++ b/tests/Query/Classifier/MySQLTest.php @@ -1,19 +1,19 @@ parser = new MySQL(); + $this->classifier = new MySQL(); } /** @@ -57,115 +57,115 @@ private function buildStmtExecute(int $stmtId): string public function testSelectQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SELECT * FROM users WHERE id = 1'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('SELECT * FROM users WHERE id = 1'))); } public function testSelectLowercase(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('select id from users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('select id from users'))); } public function testShowQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SHOW DATABASES'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('SHOW DATABASES'))); } public function testDescribeQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESCRIBE users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('DESCRIBE users'))); } public function testDescQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESC users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('DESC users'))); } public function testExplainQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('EXPLAIN SELECT * FROM users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('EXPLAIN SELECT * FROM users'))); } // -- Write Queries -- public function testInsertQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); } public function testUpdateQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); } public function testDeleteQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DELETE FROM users WHERE id = 1'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('DELETE FROM users WHERE id = 1'))); } public function testCreateTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); } public function testDropTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DROP TABLE test'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('DROP TABLE test'))); } public function testAlterTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('ALTER TABLE users ADD COLUMN email VARCHAR(255)'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('ALTER TABLE users ADD COLUMN email VARCHAR(255)'))); } public function testTruncate(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('TRUNCATE TABLE users'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('TRUNCATE TABLE users'))); } // -- Transaction Commands -- public function testBeginTransaction(): void { - $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('BEGIN'))); + $this->assertSame(Type::TransactionBegin, $this->classifier->classify($this->buildQuery('BEGIN'))); } public function testStartTransaction(): void { - $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('START TRANSACTION'))); + $this->assertSame(Type::TransactionBegin, $this->classifier->classify($this->buildQuery('START TRANSACTION'))); } public function testCommit(): void { - $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('COMMIT'))); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($this->buildQuery('COMMIT'))); } public function testRollback(): void { - $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('ROLLBACK'))); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($this->buildQuery('ROLLBACK'))); } public function testSetCommand(): void { - $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('SET autocommit = 0'))); + $this->assertSame(Type::Transaction, $this->classifier->classify($this->buildQuery('SET autocommit = 0'))); } // -- Prepared Statement Protocol -- public function testStmtPrepareRoutesToWrite(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildStmtPrepare('SELECT * FROM users WHERE id = ?'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildStmtPrepare('SELECT * FROM users WHERE id = ?'))); } public function testStmtExecuteRoutesToWrite(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildStmtExecute(1))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildStmtExecute(1))); } // -- Edge Cases -- public function testTooShortPacket(): void { - $this->assertSame(Type::Unknown, $this->parser->parse("\x00\x00")); + $this->assertSame(Type::Unknown, $this->classifier->classify("\x00\x00")); } public function testUnknownCommand(): void @@ -173,7 +173,7 @@ public function testUnknownCommand(): void $header = \pack('V', 1); $header[3] = "\x00"; $data = $header . "\x01"; // COM_QUIT - $this->assertSame(Type::Unknown, $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $this->classifier->classify($data)); } // -- Performance -- @@ -190,7 +190,7 @@ public function testParsePerformance(): void $start = \hrtime(true); for ($i = 0; $i < $iterations; $i++) { - $this->parser->parse($data); + $this->classifier->classify($data); } $elapsed = (\hrtime(true) - $start) / 1_000_000_000; $perQuery = ($elapsed / $iterations) * 1_000_000; diff --git a/tests/Query/Parser/PostgreSQLTest.php b/tests/Query/Classifier/PostgreSQLTest.php similarity index 51% rename from tests/Query/Parser/PostgreSQLTest.php rename to tests/Query/Classifier/PostgreSQLTest.php index 1b3b179..70b26d2 100644 --- a/tests/Query/Parser/PostgreSQLTest.php +++ b/tests/Query/Classifier/PostgreSQLTest.php @@ -1,19 +1,19 @@ parser = new PostgreSQL(); + $this->classifier = new PostgreSQL(); } /** @@ -64,171 +64,171 @@ private function buildExecute(): string public function testSelectQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SELECT * FROM users WHERE id = 1'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('SELECT * FROM users WHERE id = 1'))); } public function testSelectLowercase(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('select id, name from users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('select id, name from users'))); } public function testSelectMixedCase(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SeLeCt * FROM users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('SeLeCt * FROM users'))); } public function testShowQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('SHOW TABLES'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('SHOW TABLES'))); } public function testDescribeQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('DESCRIBE users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('DESCRIBE users'))); } public function testExplainQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('EXPLAIN SELECT * FROM users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('EXPLAIN SELECT * FROM users'))); } public function testTableQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery('TABLE users'))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery('TABLE users'))); } public function testValuesQuery(): void { - $this->assertSame(Type::Read, $this->parser->parse($this->buildQuery("VALUES (1, 'a'), (2, 'b')"))); + $this->assertSame(Type::Read, $this->classifier->classify($this->buildQuery("VALUES (1, 'a'), (2, 'b')"))); } // -- Write Queries -- public function testInsertQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery("INSERT INTO users (name) VALUES ('test')"))); } public function testUpdateQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery("UPDATE users SET name = 'test' WHERE id = 1"))); } public function testDeleteQuery(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DELETE FROM users WHERE id = 1'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('DELETE FROM users WHERE id = 1'))); } public function testCreateTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('CREATE TABLE test (id INT PRIMARY KEY)'))); } public function testDropTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('DROP TABLE IF EXISTS test'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('DROP TABLE IF EXISTS test'))); } public function testAlterTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('ALTER TABLE users ADD COLUMN email TEXT'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('ALTER TABLE users ADD COLUMN email TEXT'))); } public function testTruncate(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('TRUNCATE TABLE users'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('TRUNCATE TABLE users'))); } public function testGrant(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('GRANT SELECT ON users TO readonly'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('GRANT SELECT ON users TO readonly'))); } public function testRevoke(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('REVOKE ALL ON users FROM public'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('REVOKE ALL ON users FROM public'))); } public function testLockTable(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('LOCK TABLE users IN ACCESS EXCLUSIVE MODE'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('LOCK TABLE users IN ACCESS EXCLUSIVE MODE'))); } public function testCall(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery('CALL my_procedure()'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery('CALL my_procedure()'))); } public function testDo(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildQuery("DO \$\$ BEGIN RAISE NOTICE 'hello'; END \$\$"))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildQuery("DO \$\$ BEGIN RAISE NOTICE 'hello'; END \$\$"))); } // -- Transaction Commands -- public function testBeginTransaction(): void { - $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('BEGIN'))); + $this->assertSame(Type::TransactionBegin, $this->classifier->classify($this->buildQuery('BEGIN'))); } public function testStartTransaction(): void { - $this->assertSame(Type::TransactionBegin, $this->parser->parse($this->buildQuery('START TRANSACTION'))); + $this->assertSame(Type::TransactionBegin, $this->classifier->classify($this->buildQuery('START TRANSACTION'))); } public function testCommit(): void { - $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('COMMIT'))); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($this->buildQuery('COMMIT'))); } public function testRollback(): void { - $this->assertSame(Type::TransactionEnd, $this->parser->parse($this->buildQuery('ROLLBACK'))); + $this->assertSame(Type::TransactionEnd, $this->classifier->classify($this->buildQuery('ROLLBACK'))); } public function testSavepoint(): void { - $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('SAVEPOINT sp1'))); + $this->assertSame(Type::Transaction, $this->classifier->classify($this->buildQuery('SAVEPOINT sp1'))); } public function testReleaseSavepoint(): void { - $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery('RELEASE SAVEPOINT sp1'))); + $this->assertSame(Type::Transaction, $this->classifier->classify($this->buildQuery('RELEASE SAVEPOINT sp1'))); } public function testSetCommand(): void { - $this->assertSame(Type::Transaction, $this->parser->parse($this->buildQuery("SET search_path TO 'public'"))); + $this->assertSame(Type::Transaction, $this->classifier->classify($this->buildQuery("SET search_path TO 'public'"))); } // -- Extended Query Protocol -- public function testParseMessageRoutesToWrite(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildParse('stmt1', 'SELECT * FROM users'))); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildParse('stmt1', 'SELECT * FROM users'))); } public function testBindMessageRoutesToWrite(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildBind())); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildBind())); } public function testExecuteMessageRoutesToWrite(): void { - $this->assertSame(Type::Write, $this->parser->parse($this->buildExecute())); + $this->assertSame(Type::Write, $this->classifier->classify($this->buildExecute())); } // -- Edge Cases -- public function testTooShortPacket(): void { - $this->assertSame(Type::Unknown, $this->parser->parse('Q')); + $this->assertSame(Type::Unknown, $this->classifier->classify('Q')); } public function testUnknownMessageType(): void { $data = 'X' . \pack('N', 5) . "\x00"; - $this->assertSame(Type::Unknown, $this->parser->parse($data)); + $this->assertSame(Type::Unknown, $this->classifier->classify($data)); } // -- Performance -- @@ -245,7 +245,7 @@ public function testParsePerformance(): void $start = \hrtime(true); for ($i = 0; $i < $iterations; $i++) { - $this->parser->parse($data); + $this->classifier->classify($data); } $elapsed = (\hrtime(true) - $start) / 1_000_000_000; $perQuery = ($elapsed / $iterations) * 1_000_000; diff --git a/tests/Query/Parser/SQLTest.php b/tests/Query/Classifier/SQLTest.php similarity index 65% rename from tests/Query/Parser/SQLTest.php rename to tests/Query/Classifier/SQLTest.php index f00ad54..1d19d9c 100644 --- a/tests/Query/Parser/SQLTest.php +++ b/tests/Query/Classifier/SQLTest.php @@ -1,95 +1,95 @@ parser = new PostgreSQL(); + $this->classifier = new PostgreSQL(); } // -- classifySQL Edge Cases -- public function testClassifyLeadingWhitespace(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL(" \t\n SELECT * FROM users")); + $this->assertSame(Type::Read, $this->classifier->classifySQL(" \t\n SELECT * FROM users")); } public function testClassifyLeadingLineComment(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL("-- this is a comment\nSELECT * FROM users")); + $this->assertSame(Type::Read, $this->classifier->classifySQL("-- this is a comment\nSELECT * FROM users")); } public function testClassifyLeadingBlockComment(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL("/* block comment */ SELECT * FROM users")); + $this->assertSame(Type::Read, $this->classifier->classifySQL("/* block comment */ SELECT * FROM users")); } public function testClassifyMultipleComments(): void { $sql = "-- line comment\n/* block comment */\n -- another line\n SELECT 1"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyNestedBlockComment(): void { $sql = "/* outer /* inner */ SELECT 1"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyEmptyQuery(): void { - $this->assertSame(Type::Unknown, $this->parser->classifySQL('')); + $this->assertSame(Type::Unknown, $this->classifier->classifySQL('')); } public function testClassifyWhitespaceOnly(): void { - $this->assertSame(Type::Unknown, $this->parser->classifySQL(" \t\n ")); + $this->assertSame(Type::Unknown, $this->classifier->classifySQL(" \t\n ")); } public function testClassifyCommentOnly(): void { - $this->assertSame(Type::Unknown, $this->parser->classifySQL('-- just a comment')); + $this->assertSame(Type::Unknown, $this->classifier->classifySQL('-- just a comment')); } public function testClassifySelectWithParenthesis(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL('SELECT(1)')); + $this->assertSame(Type::Read, $this->classifier->classifySQL('SELECT(1)')); } public function testClassifySelectWithSemicolon(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL('SELECT;')); + $this->assertSame(Type::Read, $this->classifier->classifySQL('SELECT;')); } // -- COPY Direction -- public function testClassifyCopyTo(): void { - $this->assertSame(Type::Read, $this->parser->classifySQL('COPY users TO STDOUT')); + $this->assertSame(Type::Read, $this->classifier->classifySQL('COPY users TO STDOUT')); } public function testClassifyCopyFrom(): void { - $this->assertSame(Type::Write, $this->parser->classifySQL("COPY users FROM '/tmp/data.csv'")); + $this->assertSame(Type::Write, $this->classifier->classifySQL("COPY users FROM '/tmp/data.csv'")); } public function testClassifyCopyAmbiguous(): void { - $this->assertSame(Type::Write, $this->parser->classifySQL('COPY users')); + $this->assertSame(Type::Write, $this->classifier->classifySQL('COPY users')); } // -- CTE (WITH) -- @@ -97,44 +97,44 @@ public function testClassifyCopyAmbiguous(): void public function testClassifyCteWithSelect(): void { $sql = 'WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users'; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteWithInsert(): void { $sql = 'WITH new_data AS (SELECT 1 AS id) INSERT INTO users SELECT * FROM new_data'; - $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Write, $this->classifier->classifySQL($sql)); } public function testClassifyCteWithUpdate(): void { $sql = 'WITH src AS (SELECT id FROM staging) UPDATE users SET active = true FROM src WHERE users.id = src.id'; - $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Write, $this->classifier->classifySQL($sql)); } public function testClassifyCteWithDelete(): void { $sql = 'WITH old AS (SELECT id FROM users WHERE created_at < now()) DELETE FROM users WHERE id IN (SELECT id FROM old)'; - $this->assertSame(Type::Write, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Write, $this->classifier->classifySQL($sql)); } public function testClassifyCteRecursiveSelect(): void { $sql = 'WITH RECURSIVE tree AS (SELECT id, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree'; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteNoFinalKeyword(): void { $sql = 'WITH x AS (SELECT 1)'; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteInsertKeywordInsideStringLiteralTreatedAsRead(): void { // The inner INSERT is inside a string literal and must not influence classification. $sql = "WITH foo AS (SELECT 'INSERT INTO dangerous VALUES (1)' AS payload FROM t) SELECT * FROM foo"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteCloseParenInsideStringLiteralDoesNotBreakDepth(): void @@ -143,65 +143,65 @@ public function testClassifyCteCloseParenInsideStringLiteralDoesNotBreakDepth(): // If literals were ignored, the parser would see depth go to 0 early and // mis-classify on the trailing 'DELETE' token inside the literal. $sql = "WITH foo AS (SELECT ') DELETE FROM users' AS payload FROM t) SELECT * FROM foo"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteDeleteKeywordInsideBlockCommentIsIgnored(): void { $sql = "WITH foo AS (SELECT 1 FROM t) /* DELETE FROM users */ SELECT * FROM foo"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteInsertKeywordInsideLineCommentIsIgnored(): void { $sql = "WITH foo AS (SELECT 1 FROM t)\n-- INSERT INTO dangerous\nSELECT * FROM foo"; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteKeywordInsideDoubleQuotedIdentifierIsIgnored(): void { // A quoted identifier literally named "DELETE FROM x" is a valid identifier. $sql = 'WITH foo AS (SELECT 1 FROM "DELETE FROM x") SELECT * FROM foo'; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } public function testClassifyCteKeywordInsideDollarQuotedStringIsIgnored(): void { // Dollar-quoted strings must be skipped end-to-end. $sql = 'WITH foo AS (SELECT $body$INSERT INTO dangerous$body$ FROM t) SELECT * FROM foo'; - $this->assertSame(Type::Read, $this->parser->classifySQL($sql)); + $this->assertSame(Type::Read, $this->classifier->classifySQL($sql)); } // -- extractKeyword -- public function testExtractKeywordSimple(): void { - $this->assertSame('SELECT', $this->parser->extractKeyword('SELECT * FROM users')); + $this->assertSame('SELECT', $this->classifier->extractKeyword('SELECT * FROM users')); } public function testExtractKeywordLowercase(): void { - $this->assertSame('INSERT', $this->parser->extractKeyword('insert into users')); + $this->assertSame('INSERT', $this->classifier->extractKeyword('insert into users')); } public function testExtractKeywordWithWhitespace(): void { - $this->assertSame('DELETE', $this->parser->extractKeyword(" \t\n DELETE FROM users")); + $this->assertSame('DELETE', $this->classifier->extractKeyword(" \t\n DELETE FROM users")); } public function testExtractKeywordWithComments(): void { - $this->assertSame('UPDATE', $this->parser->extractKeyword("-- comment\nUPDATE users SET x = 1")); + $this->assertSame('UPDATE', $this->classifier->extractKeyword("-- comment\nUPDATE users SET x = 1")); } public function testExtractKeywordEmpty(): void { - $this->assertSame('', $this->parser->extractKeyword('')); + $this->assertSame('', $this->classifier->extractKeyword('')); } public function testExtractKeywordParenthesized(): void { - $this->assertSame('SELECT', $this->parser->extractKeyword('SELECT(1)')); + $this->assertSame('SELECT', $this->classifier->extractKeyword('SELECT(1)')); } // -- Performance -- @@ -225,7 +225,7 @@ public function testClassifySqlPerformance(): void $start = \hrtime(true); for ($i = 0; $i < $iterations; $i++) { - $this->parser->classifySQL($queries[$i % \count($queries)]); + $this->classifier->classifySQL($queries[$i % \count($queries)]); } $elapsed = (\hrtime(true) - $start) / 1_000_000_000; $perQuery = ($elapsed / $iterations) * 1_000_000; diff --git a/tests/Query/Regression/SecurityRegressionTest.php b/tests/Query/Regression/SecurityRegressionTest.php index bf7dff9..0480bbb 100644 --- a/tests/Query/Regression/SecurityRegressionTest.php +++ b/tests/Query/Regression/SecurityRegressionTest.php @@ -6,11 +6,11 @@ use Utopia\Query\Builder\JoinBuilder; use Utopia\Query\Builder\MySQL as MySQLBuilder; use Utopia\Query\Builder\PostgreSQL as PostgreSQLBuilder; +use Utopia\Query\Classifier\MongoDB as MongoDBClassifier; +use Utopia\Query\Classifier\MySQL as MySQLClassifier; +use Utopia\Query\Classifier\PostgreSQL as PostgreSQLClassifier; use Utopia\Query\Exception\ValidationException; use Utopia\Query\Method; -use Utopia\Query\Parser\MongoDB as MongoDBParser; -use Utopia\Query\Parser\MySQL as MySQLParser; -use Utopia\Query\Parser\PostgreSQL as PostgreSQLParser; use Utopia\Query\Query; use Utopia\Query\Schema\Index; use Utopia\Query\Schema\MySQL as MySQLSchema; @@ -75,30 +75,30 @@ public function testCreatePartitionRejectsCommentInjection(): void public function testExtractKeywordIgnoresKeywordInsideStringLiteral(): void { - $parser = new MySQLParser(); + $classifier = new MySQLClassifier(); // The keyword hidden inside the quoted string must not leak out. // Pre-fix naive byte-scan would see "DELETE" as the first word after // the SELECT in position, but extractKeyword should still report SELECT. - $this->assertSame('SELECT', $parser->extractKeyword("SELECT 'DELETE FROM users' AS x")); + $this->assertSame('SELECT', $classifier->extractKeyword("SELECT 'DELETE FROM users' AS x")); } public function testExtractKeywordIgnoresKeywordInsideBlockComment(): void { - $parser = new MySQLParser(); + $classifier = new MySQLClassifier(); - $this->assertSame('SELECT', $parser->extractKeyword('/* DELETE FROM users */ SELECT 1')); + $this->assertSame('SELECT', $classifier->extractKeyword('/* DELETE FROM users */ SELECT 1')); } public function testCteClassifierIgnoresKeywordHiddenInStringLiteral(): void { - $parser = new PostgreSQLParser(); + $classifier = new PostgreSQLClassifier(); // Pre-fix: a naive byte-scan could match INSERT inside the string and // misclassify as Write. With the state machine, the quoted literal is // skipped and the outer SELECT is the classifying keyword (Read). $sql = "WITH x AS (SELECT 'INSERT INTO users VALUES(1)' AS s) SELECT * FROM x"; - $this->assertSame(Type::Read, $parser->classifySQL($sql)); + $this->assertSame(Type::Read, $classifier->classifySQL($sql)); } public function testMongoBuilderRejectsDollarPrefixedFieldNameInPush(): void @@ -310,10 +310,10 @@ public function testExtractFirstBsonKeyRejectsOutOfBoundsDocLength(): void . \pack('V', 2013); $data = $header . $body; - $parser = new MongoDBParser(); + $classifier = new MongoDBClassifier(); // Malformed packet must not produce a classification — extractFirstBsonKey // must bail on the out-of-bounds docLen instead of scanning past it. - $this->assertSame(Type::Unknown, $parser->parse($data)); + $this->assertSame(Type::Unknown, $classifier->classify($data)); } public function testQuoteRejectsNullByteInIdentifier(): void