From 05c35bc97ae549830efb20d2a4d06a91c5856e11 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 13 Aug 2026 23:42:55 +1200 Subject: [PATCH] refactor: rename Utopia\Query\Parser to Classifier Two unrelated subsystems both called themselves a parser, so any file needing both had to alias one: Utopia\Query\Parser raw wire bytes -> Type Utopia\Query\AST\Parser Token[] -> AST\Statement\Select Only the second one parses. The first reads a message's leading keyword (or, for OP_MSG, its first command name) and looks it up in a table -- it never builds a structure. Its own method was already called classifySQL(), and its return type is a classification enum, so the name was the odd part, not the behaviour. Utopia\Query\Parser -> Utopia\Query\Classifier Utopia\Query\Parser\{SQL,MySQL, PostgreSQL,MongoDB} -> Utopia\Query\Classifier\{...} Classifier::parse() -> Classifier::classify() classifySQL() and extractKeyword() keep their names: they take SQL text rather than wire bytes, so they are a genuinely different entry point from classify(). Classifier\MongoDB carried no-op classifySQL() and extractKeyword() stubs -- returning Type::Unknown and '' with a "not applicable" comment -- even though the interface never required them. MongoDB does not speak SQL, so per the previous commits they are gone rather than answering meaninglessly. Two tests that asserted only those no-op returns are replaced with one asserting the methods are absent. Both classes now carry a docblock pointing at the other, and the README section is renamed with a note on the distinction plus links each way. Breaking: Classifier is a public interface an external proxy would implement. Pre-1.0. Co-Authored-By: Claude Opus 5 --- README.md | 58 +++++----- src/Query/AST/Parser.php | 10 ++ src/Query/Classifier.php | 24 ++++ src/Query/{Parser => Classifier}/MongoDB.php | 24 +--- src/Query/{Parser => Classifier}/MySQL.php | 4 +- .../{Parser => Classifier}/PostgreSQL.php | 4 +- src/Query/{Parser => Classifier}/SQL.php | 6 +- src/Query/Parser.php | 20 ---- .../{Parser => Classifier}/MongoDBTest.php | 104 +++++++++--------- .../{Parser => Classifier}/MySQLTest.php | 54 ++++----- .../{Parser => Classifier}/PostgreSQLTest.php | 74 ++++++------- .../Query/{Parser => Classifier}/SQLTest.php | 74 ++++++------- .../Regression/SecurityRegressionTest.php | 22 ++-- 13 files changed, 238 insertions(+), 240 deletions(-) create mode 100644 src/Query/Classifier.php rename src/Query/{Parser => Classifier}/MongoDB.php (96%) rename src/Query/{Parser => Classifier}/MySQL.php (95%) rename src/Query/{Parser => Classifier}/PostgreSQL.php (94%) rename src/Query/{Parser => Classifier}/SQL.php (99%) delete mode 100644 src/Query/Parser.php rename tests/Query/{Parser => Classifier}/MongoDBTest.php (81%) rename tests/Query/{Parser => Classifier}/MySQLTest.php (56%) rename tests/Query/{Parser => Classifier}/PostgreSQLTest.php (51%) rename tests/Query/{Parser => Classifier}/SQLTest.php (65%) 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