diff --git a/documentation/components/libs/pg-query.md b/documentation/components/libs/pg-query.md index 7ccf2adbd..738aeae6f 100644 --- a/documentation/components/libs/pg-query.md +++ b/documentation/components/libs/pg-query.md @@ -4,11 +4,6 @@ PostgreSQL Query Parser library provides strongly-typed AST (Abstract Syntax Tree) parsing for PostgreSQL SQL queries using the [libpg_query](https://github.com/pganalyze/libpg_query) library through a PHP extension. -This library wraps the low-level extension functions and provides: -- Strongly-typed AST nodes generated from protobuf definitions -- A `Parser` class for object-oriented access -- DSL helper functions for convenient usage - ## Requirements This library requires the `pg_query` PHP extension. See [pg-query-ext documentation](/documentation/components/extensions/pg-query-ext.md) for installation instructions. @@ -19,169 +14,181 @@ This library requires the `pg_query` PHP extension. See [pg-query-ext documentat composer require flow-php/pg-query:~--FLOW_PHP_VERSION-- ``` -## Usage - -### Using the Parser Class +## Quick Start ```php parse('SELECT id, name FROM users WHERE active = true'); +// Get all tables +foreach ($query->tables() as $table) { + echo $table->name(); // 'users', 'orders' + echo $table->alias(); // 'u', 'o' +} -// Access the AST -foreach ($result->getStmts() as $stmt) { - $node = $stmt->getStmt(); - $selectStmt = $node->getSelectStmt(); - // Work with strongly-typed AST nodes... +// Get all columns +foreach ($query->columns() as $column) { + echo $column->name(); // 'id', 'name', 'id', 'user_id' + echo $column->table(); // 'u', 'u', 'u', 'o' +} + +// Get columns for specific table +$userColumns = $query->columns('u'); + +// Get all function calls +foreach ($query->functions() as $func) { + echo $func->name(); // function name + echo $func->schema(); // schema if qualified (e.g., 'pg_catalog') } ``` -### Using DSL Functions +## Parser Class ```php parse('SELECT * FROM users WHERE id = 1'); -// Generate fingerprint -$fingerprint = pg_fingerprint('SELECT id FROM users WHERE id = 1'); +// Generate fingerprint (same for structurally equivalent queries) +$fingerprint = $parser->fingerprint('SELECT * FROM users WHERE id = 1'); -// Normalize query -$normalized = pg_normalize('SELECT * FROM users WHERE id = 1'); +// Normalize query (replace literals with positional parameters) +$normalized = $parser->normalize("SELECT * FROM users WHERE name = 'John'"); +// Returns: SELECT * FROM users WHERE name = $1 + +// Normalize also handles Doctrine-style named parameters +$normalized = $parser->normalize('SELECT * FROM users WHERE id = :id'); +// Returns: SELECT * FROM users WHERE id = $1 // Split multiple statements +$statements = $parser->split('SELECT 1; SELECT 2;'); +// Returns: ['SELECT 1', ' SELECT 2'] +``` + +## DSL Functions + +```php +` | +| `columns(?string $tableName)` | Get columns, optionally filtered by table/alias | `array` | +| `functions()` | Get all function calls | `array` | +| `traverse(NodeVisitor ...$visitors)` | Traverse AST with custom visitors | `void` | +| `raw()` | Access underlying protobuf ParseResult | `ParseResult` | -### Query Parsing +## Custom AST Traversal -Parse PostgreSQL SQL into a strongly-typed AST: +For advanced use cases, you can traverse the AST with custom visitors: ```php parse('SELECT id, name FROM users WHERE active = true ORDER BY name'); +use function Flow\PgQuery\DSL\pg_parse; -foreach ($result->getStmts() as $stmt) { - $selectStmt = $stmt->getStmt()->getSelectStmt(); +class ColumnCounter implements NodeVisitor +{ + public int $count = 0; - // Access FROM clause - foreach ($selectStmt->getFromClause() as $fromItem) { - $rangeVar = $fromItem->getRangeVar(); - echo "Table: " . $rangeVar->getRelname() . "\n"; + public static function nodeClass(): string + { + return ColumnRef::class; + } + + public function enter(object $node): ?int + { + $this->count++; + return null; } - // Access target list (SELECT columns) - foreach ($selectStmt->getTargetList() as $target) { - $columnRef = $target->getResTarget()->getVal()->getColumnRef(); - // Process column references... + public function leave(object $node): ?int + { + return null; } } -``` -### Query Fingerprinting +$query = pg_parse('SELECT id, name, email FROM users'); -Generate unique fingerprints for structurally equivalent queries. This is useful for grouping similar queries regardless of their literal values: +$counter = new ColumnCounter(); +$query->traverse($counter); -```php -count; // 3 +``` -use Flow\PgQuery\Parser; +### NodeVisitor Interface -$parser = new Parser(); +```php +interface NodeVisitor +{ + public const DONT_TRAVERSE_CHILDREN = 1; + public const STOP_TRAVERSAL = 2; -// These queries produce the same fingerprint -$fp1 = $parser->fingerprint('SELECT * FROM users WHERE id = 1'); -$fp2 = $parser->fingerprint('SELECT * FROM users WHERE id = 999'); + /** @return class-string */ + public static function nodeClass(): string; -var_dump($fp1 === $fp2); // true + public function enter(object $node): ?int; + public function leave(object $node): ?int; +} ``` -### Query Normalization - -Replace literal values with parameter placeholders: - -```php -normalize("SELECT * FROM users WHERE name = 'John' AND age = 25"); -// Returns: SELECT * FROM users WHERE name = $1 AND age = $2 -``` +- `ColumnRefCollector` - collects all `ColumnRef` nodes +- `FuncCallCollector` - collects all `FuncCall` nodes +- `RangeVarCollector` - collects all `RangeVar` nodes -### Statement Splitting +## Raw AST Access -Split a string containing multiple SQL statements: +For full control, access the protobuf AST directly: ```php split('SELECT 1; SELECT 2; SELECT 3'); -// Returns: ['SELECT 1', ' SELECT 2', ' SELECT 3'] -``` +foreach ($query->raw()->getStmts() as $stmt) { + $select = $stmt->getStmt()->getSelectStmt(); -## API Reference - -### Parser Class + // Access FROM clause + foreach ($select->getFromClause() as $from) { + echo $from->getRangeVar()->getRelname(); + } -| Method | Description | Returns | -|--------|-------------|---------| -| `parse(string $sql)` | Parse SQL into AST | `ParseResult` | -| `fingerprint(string $sql)` | Generate query fingerprint | `?string` | -| `normalize(string $sql)` | Normalize query with placeholders | `?string` | -| `split(string $sql)` | Split multiple statements | `array` | - -### DSL Functions - -| Function | Description | Returns | -|----------|-------------|---------| -| `pg_parser()` | Create a new Parser instance | `Parser` | -| `pg_parse(string $sql)` | Parse SQL into AST | `ParseResult` | -| `pg_fingerprint(string $sql)` | Generate query fingerprint | `?string` | -| `pg_normalize(string $sql)` | Normalize query | `?string` | -| `pg_split(string $sql)` | Split statements | `array` | - -## AST Node Types - -The library includes 343 strongly-typed AST node classes generated from PostgreSQL's protobuf definitions. All classes are in the `Flow\PgQuery\Protobuf\AST` namespace. - -Common node types include: -- `SelectStmt` - SELECT statement -- `InsertStmt` - INSERT statement -- `UpdateStmt` - UPDATE statement -- `DeleteStmt` - DELETE statement -- `ColumnRef` - Column reference -- `A_Expr` - Expression node -- `FuncCall` - Function call -- `JoinExpr` - JOIN expression -- `RangeVar` - Table/view reference + // Access WHERE clause + $where = $select->getWhereClause(); + // ... +} +``` ## Exception Handling @@ -189,8 +196,7 @@ Common node types include: parse('INVALID SQL SYNTAX HERE'); + $parser->parse('INVALID SQL'); } catch (ParserException $e) { echo "Parse error: " . $e->getMessage(); } @@ -213,4 +219,4 @@ For optimal protobuf parsing performance, install the `ext-protobuf` PHP extensi pecl install protobuf ``` -The library will work without it using the pure PHP implementation from `google/protobuf`, but the native extension provides significantly better performance for AST deserialization. +The library works without it using the pure PHP implementation from `google/protobuf`, but the native extension provides significantly better performance. diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/NodeVisitor.php b/src/lib/pg-query/src/Flow/PgQuery/AST/NodeVisitor.php new file mode 100644 index 000000000..6ffb3b038 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/NodeVisitor.php @@ -0,0 +1,60 @@ +columnRef->getFields(); + + if ($fields === null || \count($fields) === 0) { + return null; + } + + $fieldCount = \count($fields); + $columnField = $fields[$fieldCount - 1]; + + $star = $columnField->getAStar(); + + if ($star !== null) { + return '*'; + } + + $stringNode = $columnField->getString(); + + if ($stringNode !== null) { + return $stringNode->getSval(); + } + + return null; + } + + public function raw() : ColumnRef + { + return $this->columnRef; + } + + public function table() : ?string + { + $fields = $this->columnRef->getFields(); + + if ($fields === null || \count($fields) <= 1) { + return null; + } + + $tableField = $fields[0]; + $tableString = $tableField->getString(); + + if ($tableString !== null) { + return $tableString->getSval(); + } + + return null; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/FunctionCall.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/FunctionCall.php new file mode 100644 index 000000000..064934d95 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/FunctionCall.php @@ -0,0 +1,57 @@ +funcCall->getFuncname(); + + if ($funcname === null || \count($funcname) === 0) { + return null; + } + + $funcnameCount = \count($funcname); + $nameNode = $funcname[$funcnameCount - 1]; + $stringNode = $nameNode->getString(); + + if ($stringNode !== null) { + return $stringNode->getSval(); + } + + return null; + } + + public function raw() : FuncCall + { + return $this->funcCall; + } + + public function schema() : ?string + { + $funcname = $this->funcCall->getFuncname(); + + if ($funcname === null || \count($funcname) <= 1) { + return null; + } + + $schemaNode = $funcname[0]; + $schemaString = $schemaNode->getString(); + + if ($schemaString !== null) { + return $schemaString->getSval(); + } + + return null; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/Table.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/Table.php new file mode 100644 index 000000000..5711c7252 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Nodes/Table.php @@ -0,0 +1,45 @@ +rangeVar->getAlias(); + + if ($alias === null) { + return null; + } + + $aliasname = $alias->getAliasname(); + + return $aliasname !== '' ? $aliasname : null; + } + + public function name() : string + { + return $this->rangeVar->getRelname(); + } + + public function raw() : RangeVar + { + return $this->rangeVar; + } + + public function schema() : ?string + { + $schemaname = $this->rangeVar->getSchemaname(); + + return $schemaname !== '' ? $schemaname : null; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Traverser.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Traverser.php new file mode 100644 index 000000000..90f2fe8a0 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Traverser.php @@ -0,0 +1,477 @@ +> + */ + private readonly array $visitors; + + public function __construct(NodeVisitor ...$visitors) + { + $indexed = []; + + foreach ($visitors as $visitor) { + $nodeClass = $visitor::nodeClass(); + $indexed[$nodeClass][] = $visitor; + } + + $this->visitors = $indexed; + } + + /** + * Traverse a ParseResult. + */ + public function traverse(ParseResult $parseResult) : void + { + $this->stopTraversal = false; + + foreach ($parseResult->getStmts() as $rawStmt) { + $stmt = $rawStmt->getStmt(); + + if ($stmt !== null && !$this->traverseNode($stmt)) { + return; + } + } + } + + /** + * Extract inner node objects from the protobuf Node wrapper. + * + * @return array + */ + private function extractInnerNodes(Node $node) : array + { + $nodes = []; + + if (($inner = $node->getSelectStmt()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getInsertStmt()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getUpdateStmt()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getDeleteStmt()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getRangeVar()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getColumnRef()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getFuncCall()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getAExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getBoolExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getJoinExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getResTarget()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getSortBy()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getTypeCast()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getCoalesceExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getCaseExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getCaseWhen()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getNullTest()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getSubLink()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getCommonTableExpr()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getRangeSubselect()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getRangeFunction()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getAlias()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getAConst()) !== null) { + $nodes[] = $inner; + } + + if (($inner = $node->getAStar()) !== null) { + $nodes[] = $inner; + } + + return $nodes; + } + + private function traverseNode(Node $node) : bool + { + if ($this->stopTraversal) { + return false; + } + + $traverseChildren = true; + + $innerNodes = $this->extractInnerNodes($node); + + foreach ($innerNodes as $innerNode) { + $nodeClass = $innerNode::class; + + if (isset($this->visitors[$nodeClass])) { + foreach ($this->visitors[$nodeClass] as $visitor) { + $result = $visitor->enter($innerNode); + + if ($result === NodeVisitor::STOP_TRAVERSAL) { + $this->stopTraversal = true; + + return false; + } + + if ($result === NodeVisitor::DONT_TRAVERSE_CHILDREN) { + $traverseChildren = false; + } + } + } + } + + if ($traverseChildren) { + $this->traverseNodeChildren($node); + } + + foreach ($innerNodes as $innerNode) { + $nodeClass = $innerNode::class; + + if (isset($this->visitors[$nodeClass])) { + foreach ($this->visitors[$nodeClass] as $visitor) { + $result = $visitor->leave($innerNode); + + if ($result === NodeVisitor::STOP_TRAVERSAL) { + $this->stopTraversal = true; + + return false; + } + } + } + } + + return true; + } + + private function traverseNodeChildren(Node $node) : void + { + if ($this->stopTraversal) { + return; + } + + $selectStmt = $node->getSelectStmt(); + + if ($selectStmt !== null) { + $this->traverseRepeatedField($selectStmt->getFromClause()); + $this->traverseRepeatedField($selectStmt->getTargetList()); + $this->traverseRepeatedField($selectStmt->getGroupClause()); + $this->traverseRepeatedField($selectStmt->getSortClause()); + $this->traverseRepeatedField($selectStmt->getDistinctClause()); + $this->traverseRepeatedField($selectStmt->getWindowClause()); + $this->traverseRepeatedField($selectStmt->getValuesLists()); + $this->traverseRepeatedField($selectStmt->getLockingClause()); + + if ($selectStmt->getWhereClause() !== null) { + $this->traverseNode($selectStmt->getWhereClause()); + } + + if ($selectStmt->getHavingClause() !== null) { + $this->traverseNode($selectStmt->getHavingClause()); + } + + if ($selectStmt->getLimitOffset() !== null) { + $this->traverseNode($selectStmt->getLimitOffset()); + } + + if ($selectStmt->getLimitCount() !== null) { + $this->traverseNode($selectStmt->getLimitCount()); + } + + if ($selectStmt->getLarg() !== null) { + $larg = new Node(); + $larg->setSelectStmt($selectStmt->getLarg()); + $this->traverseNode($larg); + } + + if ($selectStmt->getRarg() !== null) { + $rarg = new Node(); + $rarg->setSelectStmt($selectStmt->getRarg()); + $this->traverseNode($rarg); + } + + $withClause = $selectStmt->getWithClause(); + + if ($withClause !== null) { + $this->traverseRepeatedField($withClause->getCtes()); + } + } + + $insertStmt = $node->getInsertStmt(); + + if ($insertStmt !== null) { + if ($insertStmt->getRelation() !== null) { + $relationNode = new Node(); + $relationNode->setRangeVar($insertStmt->getRelation()); + $this->traverseNode($relationNode); + } + $this->traverseRepeatedField($insertStmt->getCols()); + $this->traverseRepeatedField($insertStmt->getReturningList()); + + if ($insertStmt->getSelectStmt() !== null) { + $this->traverseNode($insertStmt->getSelectStmt()); + } + + $withClause = $insertStmt->getWithClause(); + + if ($withClause !== null) { + $this->traverseRepeatedField($withClause->getCtes()); + } + } + + $updateStmt = $node->getUpdateStmt(); + + if ($updateStmt !== null) { + if ($updateStmt->getRelation() !== null) { + $relationNode = new Node(); + $relationNode->setRangeVar($updateStmt->getRelation()); + $this->traverseNode($relationNode); + } + $this->traverseRepeatedField($updateStmt->getTargetList()); + $this->traverseRepeatedField($updateStmt->getFromClause()); + $this->traverseRepeatedField($updateStmt->getReturningList()); + + if ($updateStmt->getWhereClause() !== null) { + $this->traverseNode($updateStmt->getWhereClause()); + } + + $withClause = $updateStmt->getWithClause(); + + if ($withClause !== null) { + $this->traverseRepeatedField($withClause->getCtes()); + } + } + + $deleteStmt = $node->getDeleteStmt(); + + if ($deleteStmt !== null) { + if ($deleteStmt->getRelation() !== null) { + $relationNode = new Node(); + $relationNode->setRangeVar($deleteStmt->getRelation()); + $this->traverseNode($relationNode); + } + $this->traverseRepeatedField($deleteStmt->getUsingClause()); + $this->traverseRepeatedField($deleteStmt->getReturningList()); + + if ($deleteStmt->getWhereClause() !== null) { + $this->traverseNode($deleteStmt->getWhereClause()); + } + + $withClause = $deleteStmt->getWithClause(); + + if ($withClause !== null) { + $this->traverseRepeatedField($withClause->getCtes()); + } + } + + $joinExpr = $node->getJoinExpr(); + + if ($joinExpr !== null) { + if ($joinExpr->getLarg() !== null) { + $this->traverseNode($joinExpr->getLarg()); + } + + if ($joinExpr->getRarg() !== null) { + $this->traverseNode($joinExpr->getRarg()); + } + + if ($joinExpr->getQuals() !== null) { + $this->traverseNode($joinExpr->getQuals()); + } + $this->traverseRepeatedField($joinExpr->getUsingClause()); + } + + $subLink = $node->getSubLink(); + + if ($subLink !== null && $subLink->getSubselect() !== null) { + $this->traverseNode($subLink->getSubselect()); + } + + $rangeSubselect = $node->getRangeSubselect(); + + if ($rangeSubselect !== null && $rangeSubselect->getSubquery() !== null) { + $this->traverseNode($rangeSubselect->getSubquery()); + } + + $cte = $node->getCommonTableExpr(); + + if ($cte !== null && $cte->getCtequery() !== null) { + $this->traverseNode($cte->getCtequery()); + } + + $resTarget = $node->getResTarget(); + + if ($resTarget !== null && $resTarget->getVal() !== null) { + $this->traverseNode($resTarget->getVal()); + } + + $funcCall = $node->getFuncCall(); + + if ($funcCall !== null) { + $this->traverseRepeatedField($funcCall->getArgs()); + $this->traverseRepeatedField($funcCall->getAggOrder()); + + if ($funcCall->getAggFilter() !== null) { + $this->traverseNode($funcCall->getAggFilter()); + } + } + + $aExpr = $node->getAExpr(); + + if ($aExpr !== null) { + if ($aExpr->getLexpr() !== null) { + $this->traverseNode($aExpr->getLexpr()); + } + + if ($aExpr->getRexpr() !== null) { + $this->traverseNode($aExpr->getRexpr()); + } + } + + $boolExpr = $node->getBoolExpr(); + + if ($boolExpr !== null) { + $this->traverseRepeatedField($boolExpr->getArgs()); + } + + $caseExpr = $node->getCaseExpr(); + + if ($caseExpr !== null) { + $this->traverseRepeatedField($caseExpr->getArgs()); + + if ($caseExpr->getArg() !== null) { + $this->traverseNode($caseExpr->getArg()); + } + + if ($caseExpr->getDefresult() !== null) { + $this->traverseNode($caseExpr->getDefresult()); + } + } + + $caseWhen = $node->getCaseWhen(); + + if ($caseWhen !== null) { + if ($caseWhen->getExpr() !== null) { + $this->traverseNode($caseWhen->getExpr()); + } + + if ($caseWhen->getResult() !== null) { + $this->traverseNode($caseWhen->getResult()); + } + } + + $coalesceExpr = $node->getCoalesceExpr(); + + if ($coalesceExpr !== null) { + $this->traverseRepeatedField($coalesceExpr->getArgs()); + } + + $nullTest = $node->getNullTest(); + + if ($nullTest !== null && $nullTest->getArg() !== null) { + $this->traverseNode($nullTest->getArg()); + } + + $typeCast = $node->getTypeCast(); + + if ($typeCast !== null && $typeCast->getArg() !== null) { + $this->traverseNode($typeCast->getArg()); + } + + $sortBy = $node->getSortBy(); + + if ($sortBy !== null && $sortBy->getNode() !== null) { + $this->traverseNode($sortBy->getNode()); + } + + $rangeFunction = $node->getRangeFunction(); + + if ($rangeFunction !== null) { + $this->traverseRepeatedField($rangeFunction->getFunctions()); + } + } + + /** + * @param null|iterable $field + */ + private function traverseRepeatedField(?iterable $field) : void + { + if ($field === null) { + return; + } + + foreach ($field as $node) { + if ($this->stopTraversal) { + return; + } + + $this->traverseNode($node); + } + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/ColumnRefCollector.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/ColumnRefCollector.php new file mode 100644 index 000000000..78d3013e8 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/ColumnRefCollector.php @@ -0,0 +1,50 @@ + + */ + private array $columnRefs = []; + + public static function nodeClass() : string + { + return ColumnRef::class; + } + + public function enter(object $node) : ?int + { + /** @var ColumnRef $node */ + $this->columnRefs[] = $node; + + return null; + } + + /** + * @return array + */ + public function getColumnRefs() : array + { + return $this->columnRefs; + } + + public function leave(object $node) : ?int + { + return null; + } + + public function reset() : void + { + $this->columnRefs = []; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/FuncCallCollector.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/FuncCallCollector.php new file mode 100644 index 000000000..6c1771d15 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/FuncCallCollector.php @@ -0,0 +1,50 @@ + + */ + private array $funcCalls = []; + + public static function nodeClass() : string + { + return FuncCall::class; + } + + public function enter(object $node) : ?int + { + /** @var FuncCall $node */ + $this->funcCalls[] = $node; + + return null; + } + + /** + * @return array + */ + public function getFuncCalls() : array + { + return $this->funcCalls; + } + + public function leave(object $node) : ?int + { + return null; + } + + public function reset() : void + { + $this->funcCalls = []; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/RangeVarCollector.php b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/RangeVarCollector.php new file mode 100644 index 000000000..08546af8a --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/AST/Visitors/RangeVarCollector.php @@ -0,0 +1,50 @@ + + */ + private array $rangeVars = []; + + public static function nodeClass() : string + { + return RangeVar::class; + } + + public function enter(object $node) : ?int + { + /** @var RangeVar $node */ + $this->rangeVars[] = $node; + + return null; + } + + /** + * @return array + */ + public function getRangeVars() : array + { + return $this->rangeVars; + } + + public function leave(object $node) : ?int + { + return null; + } + + public function reset() : void + { + $this->rangeVars = []; + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/DSL/functions.php b/src/lib/pg-query/src/Flow/PgQuery/DSL/functions.php index de4c3c073..5e9159bce 100644 --- a/src/lib/pg-query/src/Flow/PgQuery/DSL/functions.php +++ b/src/lib/pg-query/src/Flow/PgQuery/DSL/functions.php @@ -4,15 +4,14 @@ namespace Flow\PgQuery\DSL; -use Flow\PgQuery\Parser; -use Flow\PgQuery\Protobuf\AST\ParseResult; +use Flow\PgQuery\{ParsedQuery, Parser}; function pg_parser() : Parser { return new Parser(); } -function pg_parse(string $sql) : ParseResult +function pg_parse(string $sql) : ParsedQuery { return (new Parser())->parse($sql); } diff --git a/src/lib/pg-query/src/Flow/PgQuery/NamedParameterNormalizer.php b/src/lib/pg-query/src/Flow/PgQuery/NamedParameterNormalizer.php new file mode 100644 index 000000000..c8c2a4546 --- /dev/null +++ b/src/lib/pg-query/src/Flow/PgQuery/NamedParameterNormalizer.php @@ -0,0 +1,60 @@ + Map of parameter names to their positional index (1-based) + */ + public function extractParameters(string $sql) : array + { + $parameters = []; + $position = 1; + + preg_replace_callback( + '/(? + */ + public function columns(?string $tableName = null) : array + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseResult); + + $columns = []; + + foreach ($collector->getColumnRefs() as $columnRef) { + $column = new Column($columnRef); + + if ($column->name() === null) { + continue; + } + + if ($tableName !== null && $column->table() !== $tableName) { + continue; + } + + $columns[] = $column; + } + + return $columns; + } + + /** + * @return array + */ + public function functions() : array + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseResult); + + $functions = []; + + foreach ($collector->getFuncCalls() as $funcCall) { + $function = new FunctionCall($funcCall); + + if ($function->name() === null) { + continue; + } + + $functions[] = $function; + } + + return $functions; + } + + public function raw() : ParseResult + { + return $this->parseResult; + } + + /** + * @return array + */ + public function tables() : array + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseResult); + + $tables = []; + + foreach ($collector->getRangeVars() as $rangeVar) { + $table = new Table($rangeVar); + + if ($table->name() === '') { + continue; + } + + $tables[] = $table; + } + + return $tables; + } + + public function traverse(NodeVisitor ...$visitors) : void + { + $traverser = new Traverser(...$visitors); + $traverser->traverse($this->parseResult); + } +} diff --git a/src/lib/pg-query/src/Flow/PgQuery/Parser.php b/src/lib/pg-query/src/Flow/PgQuery/Parser.php index ed1a22bf4..d196ce690 100644 --- a/src/lib/pg-query/src/Flow/PgQuery/Parser.php +++ b/src/lib/pg-query/src/Flow/PgQuery/Parser.php @@ -25,12 +25,12 @@ public function fingerprint(string $sql) : ?string public function normalize(string $sql) : ?string { - $result = pg_query_normalize($sql); + $result = pg_query_normalize((new NamedParameterNormalizer())->normalize($sql)); return $result === false ? null : $result; } - public function parse(string $sql) : ParseResult + public function parse(string $sql) : ParsedQuery { try { $json = pg_query_parse($sql); @@ -41,7 +41,7 @@ public function parse(string $sql) : ParseResult $result = new ParseResult(); $result->mergeFromJsonString($json); - return $result; + return new ParsedQuery($result); } /** diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/ColumnTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/ColumnTest.php new file mode 100644 index 000000000..e362db786 --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/ColumnTest.php @@ -0,0 +1,125 @@ +extractColumnRef('SELECT id FROM users'); + + $column = new Column($columnRef); + + self::assertSame('id', $column->name()); + } + + public function test_name_returns_star_for_table_qualified_wildcard() : void + { + $columnRef = $this->extractColumnRef('SELECT u.* FROM users u'); + + $column = new Column($columnRef); + + self::assertSame('*', $column->name()); + } + + public function test_name_returns_star_for_wildcard() : void + { + $columnRef = $this->extractColumnRef('SELECT * FROM users'); + + $column = new Column($columnRef); + + self::assertSame('*', $column->name()); + } + + public function test_raw_returns_underlying_column_ref() : void + { + $columnRef = $this->extractColumnRef('SELECT id FROM users'); + + $column = new Column($columnRef); + + self::assertSame($columnRef, $column->raw()); + } + + public function test_table_returns_null_for_unqualified_column() : void + { + $columnRef = $this->extractColumnRef('SELECT id FROM users'); + + $column = new Column($columnRef); + + self::assertNull($column->table()); + } + + public function test_table_returns_null_for_unqualified_wildcard() : void + { + $columnRef = $this->extractColumnRef('SELECT * FROM users'); + + $column = new Column($columnRef); + + self::assertNull($column->table()); + } + + public function test_table_returns_table_name_for_qualified_column() : void + { + $columnRef = $this->extractColumnRef('SELECT u.id FROM users u'); + + $column = new Column($columnRef); + + self::assertSame('u', $column->table()); + } + + public function test_table_returns_table_name_for_qualified_wildcard() : void + { + $columnRef = $this->extractColumnRef('SELECT u.* FROM users u'); + + $column = new Column($columnRef); + + self::assertSame('u', $column->table()); + } + + private function extractColumnRef(string $sql) : ColumnRef + { + $parseResult = $this->parseQuery($sql); + $stmts = $parseResult->getStmts(); + + $stmt = $stmts[0]->getStmt(); + self::assertNotNull($stmt); + + $selectStmt = $stmt->getSelectStmt(); + self::assertNotNull($selectStmt); + + $targetList = $selectStmt->getTargetList(); + $resTarget = $targetList[0]->getResTarget(); + self::assertNotNull($resTarget); + + $val = $resTarget->getVal(); + self::assertNotNull($val); + + $columnRef = $val->getColumnRef(); + self::assertNotNull($columnRef); + + return $columnRef; + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/FunctionCallTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/FunctionCallTest.php new file mode 100644 index 000000000..61d168e0c --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/FunctionCallTest.php @@ -0,0 +1,98 @@ +extractFuncCall('SELECT count(*) FROM users'); + + $function = new FunctionCall($funcCall); + + self::assertSame('count', $function->name()); + } + + public function test_name_returns_function_name_for_schema_qualified_function() : void + { + $funcCall = $this->extractFuncCall('SELECT pg_catalog.now()'); + + $function = new FunctionCall($funcCall); + + self::assertSame('now', $function->name()); + } + + public function test_raw_returns_underlying_func_call() : void + { + $funcCall = $this->extractFuncCall('SELECT count(*) FROM users'); + + $function = new FunctionCall($funcCall); + + self::assertSame($funcCall, $function->raw()); + } + + public function test_schema_returns_null_for_unqualified_function() : void + { + $funcCall = $this->extractFuncCall('SELECT count(*) FROM users'); + + $function = new FunctionCall($funcCall); + + self::assertNull($function->schema()); + } + + public function test_schema_returns_schema_name_for_qualified_function() : void + { + $funcCall = $this->extractFuncCall('SELECT pg_catalog.now()'); + + $function = new FunctionCall($funcCall); + + self::assertSame('pg_catalog', $function->schema()); + } + + private function extractFuncCall(string $sql) : FuncCall + { + $parseResult = $this->parseQuery($sql); + $stmts = $parseResult->getStmts(); + + $stmt = $stmts[0]->getStmt(); + self::assertNotNull($stmt); + + $selectStmt = $stmt->getSelectStmt(); + self::assertNotNull($selectStmt); + + $targetList = $selectStmt->getTargetList(); + $resTarget = $targetList[0]->getResTarget(); + self::assertNotNull($resTarget); + + $val = $resTarget->getVal(); + self::assertNotNull($val); + + $funcCall = $val->getFuncCall(); + self::assertNotNull($funcCall); + + return $funcCall; + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/TableTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/TableTest.php new file mode 100644 index 000000000..e8be3584a --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Nodes/TableTest.php @@ -0,0 +1,110 @@ +extractRangeVar('SELECT * FROM users AS u'); + + $table = new Table($rangeVar); + + self::assertSame('u', $table->alias()); + } + + public function test_alias_returns_null_when_no_alias() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM users'); + + $table = new Table($rangeVar); + + self::assertNull($table->alias()); + } + + public function test_name_returns_table_name() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM users'); + + $table = new Table($rangeVar); + + self::assertSame('users', $table->name()); + } + + public function test_name_returns_table_name_for_schema_qualified_table() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM public.users'); + + $table = new Table($rangeVar); + + self::assertSame('users', $table->name()); + } + + public function test_raw_returns_underlying_range_var() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM users'); + + $table = new Table($rangeVar); + + self::assertSame($rangeVar, $table->raw()); + } + + public function test_schema_returns_null_for_unqualified_table() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM users'); + + $table = new Table($rangeVar); + + self::assertNull($table->schema()); + } + + public function test_schema_returns_schema_name_for_qualified_table() : void + { + $rangeVar = $this->extractRangeVar('SELECT * FROM public.users'); + + $table = new Table($rangeVar); + + self::assertSame('public', $table->schema()); + } + + private function extractRangeVar(string $sql) : RangeVar + { + $parseResult = $this->parseQuery($sql); + $stmts = $parseResult->getStmts(); + + $stmt = $stmts[0]->getStmt(); + self::assertNotNull($stmt); + + $selectStmt = $stmt->getSelectStmt(); + self::assertNotNull($selectStmt); + + $fromClause = $selectStmt->getFromClause(); + $rangeVar = $fromClause[0]->getRangeVar(); + self::assertNotNull($rangeVar); + + return $rangeVar; + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/TraverserTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/TraverserTest.php new file mode 100644 index 000000000..2123f012b --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/TraverserTest.php @@ -0,0 +1,340 @@ +parseQuery('SELECT id, name FROM users'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getColumnRefs()); + } + + public function test_column_ref_collector_from_join_condition() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM users u JOIN orders o ON u.id = o.user_id'); + $traverser->traverse($result); + + self::assertCount(3, $collector->getColumnRefs()); + } + + public function test_column_ref_collector_from_order_by() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT id FROM users ORDER BY name'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getColumnRefs()); + } + + public function test_column_ref_collector_from_subquery() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM (SELECT id FROM users) sub'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getColumnRefs()); + } + + public function test_column_ref_collector_from_where_clause() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT 1 FROM users WHERE active = true AND status = 1'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getColumnRefs()); + } + + public function test_column_ref_collector_with_table_qualifier() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT u.id, u.name FROM users u'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getColumnRefs()); + + foreach ($collector->getColumnRefs() as $ref) { + $fields = $ref->getFields(); + self::assertCount(2, $fields); + } + } + + public function test_dont_traverse_children() : void + { + $visitor = new class implements NodeVisitor { + public int $nodeCount = 0; + + public static function nodeClass() : string + { + return SelectStmt::class; + } + + public function enter(object $node) : int + { + $this->nodeCount++; + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + public function leave(object $node) : ?int + { + return null; + } + }; + + $traverser = new Traverser($visitor); + $result = $this->parseQuery('SELECT id, name FROM users'); + $traverser->traverse($result); + + self::assertSame(1, $visitor->nodeCount); + } + + public function test_func_call_collector() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT COUNT(*), MAX(id) FROM users'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getFuncCalls()); + } + + public function test_func_call_collector_nested() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT UPPER(TRIM(name)) FROM users'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getFuncCalls()); + } + + public function test_func_call_collector_with_schema() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT pg_catalog.now()'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getFuncCalls()); + + $funcname = $collector->getFuncCalls()[0]->getFuncname(); + self::assertCount(2, $funcname); + } + + public function test_multiple_visitors() : void + { + $columnCollector = new ColumnRefCollector(); + $funcCollector = new FuncCallCollector(); + $rangeVarCollector = new RangeVarCollector(); + + $traverser = new Traverser($columnCollector, $funcCollector, $rangeVarCollector); + + $result = $this->parseQuery('SELECT COUNT(id), name FROM users WHERE active = true'); + $traverser->traverse($result); + + self::assertCount(3, $columnCollector->getColumnRefs()); + self::assertCount(1, $funcCollector->getFuncCalls()); + self::assertCount(1, $rangeVarCollector->getRangeVars()); + } + + public function test_range_var_collector() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM users'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_range_var_collector_from_cte() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('WITH active AS (SELECT * FROM users WHERE active = true) SELECT * FROM active'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getRangeVars()); + + $tableNames = \array_map(fn ($rv) => $rv->getRelname(), $collector->getRangeVars()); + self::assertContains('users', $tableNames); + self::assertContains('active', $tableNames); + } + + public function test_range_var_collector_from_delete() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('DELETE FROM users WHERE id = 1'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_range_var_collector_from_insert() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('INSERT INTO users (name) VALUES (\'john\')'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_range_var_collector_from_join() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM users u JOIN orders o ON u.id = o.user_id'); + $traverser->traverse($result); + + self::assertCount(2, $collector->getRangeVars()); + } + + public function test_range_var_collector_from_subquery() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM (SELECT * FROM users) sub'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_range_var_collector_from_update() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('UPDATE users SET name = \'john\' WHERE id = 1'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_range_var_collector_with_alias() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM users AS u'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + self::assertNotNull($collector->getRangeVars()[0]->getAlias()); + self::assertSame('u', $collector->getRangeVars()[0]->getAlias()->getAliasname()); + } + + public function test_range_var_collector_with_schema() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + + $result = $this->parseQuery('SELECT * FROM public.users'); + $traverser->traverse($result); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + self::assertSame('public', $collector->getRangeVars()[0]->getSchemaname()); + } + + public function test_stop_traversal() : void + { + $visitor = new class implements NodeVisitor { + public int $nodeCount = 0; + + public static function nodeClass() : string + { + return ColumnRef::class; + } + + public function enter(object $node) : ?int + { + $this->nodeCount++; + + if ($this->nodeCount >= 2) { + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + + public function leave(object $node) : ?int + { + return null; + } + }; + + $traverser = new Traverser($visitor); + $result = $this->parseQuery('SELECT id, name, email FROM users'); + $traverser->traverse($result); + + self::assertSame(2, $visitor->nodeCount); + } + + public function test_traverser_without_visitors() : void + { + $traverser = new Traverser(); + $result = $this->parseQuery('SELECT id FROM users'); + + $traverser->traverse($result); + + $this->expectNotToPerformAssertions(); + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/ColumnRefCollectorTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/ColumnRefCollectorTest.php new file mode 100644 index 000000000..e4e9474c4 --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/ColumnRefCollectorTest.php @@ -0,0 +1,129 @@ +traverse($this->parseQuery('SELECT id FROM users ORDER BY name')); + + self::assertCount(2, $collector->getColumnRefs()); + } + + public function test_collects_column_from_where_clause() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT 1 FROM users WHERE active = true')); + + self::assertCount(1, $collector->getColumnRefs()); + } + + public function test_collects_columns_from_join_condition() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users u JOIN orders o ON u.id = o.user_id')); + + self::assertCount(3, $collector->getColumnRefs()); + } + + public function test_collects_columns_from_select() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT id, name, email FROM users')); + + self::assertCount(3, $collector->getColumnRefs()); + } + + public function test_collects_columns_from_subquery() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM (SELECT id, name FROM users) sub')); + + self::assertCount(3, $collector->getColumnRefs()); + } + + public function test_collects_table_qualified_columns() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT u.id, u.name FROM users u')); + + self::assertCount(2, $collector->getColumnRefs()); + + foreach ($collector->getColumnRefs() as $columnRef) { + self::assertCount(2, $columnRef->getFields()); + } + } + + public function test_enter_returns_null() : void + { + $collector = new ColumnRefCollector(); + $columnRef = new ColumnRef(); + + self::assertNull($collector->enter($columnRef)); + } + + public function test_get_column_refs_returns_empty_array_initially() : void + { + $collector = new ColumnRefCollector(); + + self::assertSame([], $collector->getColumnRefs()); + } + + public function test_leave_returns_null() : void + { + $collector = new ColumnRefCollector(); + $columnRef = new ColumnRef(); + + self::assertNull($collector->leave($columnRef)); + } + + public function test_node_class_returns_column_ref_class() : void + { + self::assertSame(ColumnRef::class, ColumnRefCollector::nodeClass()); + } + + public function test_reset_clears_collected_column_refs() : void + { + $collector = new ColumnRefCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT id, name FROM users')); + + self::assertCount(2, $collector->getColumnRefs()); + + $collector->reset(); + + self::assertSame([], $collector->getColumnRefs()); + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/FuncCallCollectorTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/FuncCallCollectorTest.php new file mode 100644 index 000000000..900f06b9e --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/FuncCallCollectorTest.php @@ -0,0 +1,120 @@ +traverse($this->parseQuery('SELECT COUNT(*), SUM(amount), AVG(price) FROM orders')); + + self::assertCount(3, $collector->getFuncCalls()); + } + + public function test_collects_functions_from_select() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT now()')); + + self::assertCount(1, $collector->getFuncCalls()); + } + + public function test_collects_functions_from_where_clause() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users WHERE created_at > now()')); + + self::assertCount(1, $collector->getFuncCalls()); + } + + public function test_collects_nested_functions() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT UPPER(TRIM(LOWER(name))) FROM users')); + + self::assertCount(3, $collector->getFuncCalls()); + } + + public function test_collects_schema_qualified_functions() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT pg_catalog.now(), pg_catalog.current_user()')); + + self::assertCount(2, $collector->getFuncCalls()); + + foreach ($collector->getFuncCalls() as $funcCall) { + self::assertCount(2, $funcCall->getFuncname()); + } + } + + public function test_enter_returns_null() : void + { + $collector = new FuncCallCollector(); + $funcCall = new FuncCall(); + + self::assertNull($collector->enter($funcCall)); + } + + public function test_get_func_calls_returns_empty_array_initially() : void + { + $collector = new FuncCallCollector(); + + self::assertSame([], $collector->getFuncCalls()); + } + + public function test_leave_returns_null() : void + { + $collector = new FuncCallCollector(); + $funcCall = new FuncCall(); + + self::assertNull($collector->leave($funcCall)); + } + + public function test_node_class_returns_func_call_class() : void + { + self::assertSame(FuncCall::class, FuncCallCollector::nodeClass()); + } + + public function test_reset_clears_collected_func_calls() : void + { + $collector = new FuncCallCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT COUNT(*), MAX(id) FROM users')); + + self::assertCount(2, $collector->getFuncCalls()); + + $collector->reset(); + + self::assertSame([], $collector->getFuncCalls()); + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/RangeVarCollectorTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/RangeVarCollectorTest.php new file mode 100644 index 000000000..f7400c011 --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/AST/Visitors/RangeVarCollectorTest.php @@ -0,0 +1,170 @@ +traverse($this->parseQuery('WITH active AS (SELECT * FROM users WHERE active = true) SELECT * FROM active')); + + self::assertCount(2, $collector->getRangeVars()); + + $tableNames = \array_map(fn (RangeVar $rv) => $rv->getRelname(), $collector->getRangeVars()); + self::assertContains('users', $tableNames); + self::assertContains('active', $tableNames); + } + + public function test_collects_table_from_delete() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('DELETE FROM users WHERE id = 1')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_collects_table_from_insert() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('INSERT INTO users (name) VALUES (\'john\')')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_collects_table_from_select() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_collects_table_from_subquery() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM (SELECT * FROM users) sub')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_collects_table_from_update() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('UPDATE users SET name = \'john\' WHERE id = 1')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + } + + public function test_collects_table_with_alias() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users AS u')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + self::assertNotNull($collector->getRangeVars()[0]->getAlias()); + self::assertSame('u', $collector->getRangeVars()[0]->getAlias()->getAliasname()); + } + + public function test_collects_table_with_schema() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM public.users')); + + self::assertCount(1, $collector->getRangeVars()); + self::assertSame('users', $collector->getRangeVars()[0]->getRelname()); + self::assertSame('public', $collector->getRangeVars()[0]->getSchemaname()); + } + + public function test_collects_tables_from_join() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users u JOIN orders o ON u.id = o.user_id')); + + self::assertCount(2, $collector->getRangeVars()); + + $tableNames = \array_map(fn (RangeVar $rv) => $rv->getRelname(), $collector->getRangeVars()); + self::assertContains('users', $tableNames); + self::assertContains('orders', $tableNames); + } + + public function test_enter_returns_null() : void + { + $collector = new RangeVarCollector(); + $rangeVar = new RangeVar(); + + self::assertNull($collector->enter($rangeVar)); + } + + public function test_get_range_vars_returns_empty_array_initially() : void + { + $collector = new RangeVarCollector(); + + self::assertSame([], $collector->getRangeVars()); + } + + public function test_leave_returns_null() : void + { + $collector = new RangeVarCollector(); + $rangeVar = new RangeVar(); + + self::assertNull($collector->leave($rangeVar)); + } + + public function test_node_class_returns_range_var_class() : void + { + self::assertSame(RangeVar::class, RangeVarCollector::nodeClass()); + } + + public function test_reset_clears_collected_range_vars() : void + { + $collector = new RangeVarCollector(); + $traverser = new Traverser($collector); + $traverser->traverse($this->parseQuery('SELECT * FROM users u JOIN orders o ON u.id = o.user_id')); + + self::assertCount(2, $collector->getRangeVars()); + + $collector->reset(); + + self::assertSame([], $collector->getRangeVars()); + } + + private function parseQuery(string $sql) : ParseResult + { + /** @var string $json */ + $json = \pg_query_parse($sql); + $result = new ParseResult(); + $result->mergeFromJsonString($json); + + return $result; + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/NamedParameterNormalizerTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/NamedParameterNormalizerTest.php new file mode 100644 index 000000000..764f51574 --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/NamedParameterNormalizerTest.php @@ -0,0 +1,112 @@ + 1, 'name' => 2, 'status' => 3], + $normalizer->extractParameters('SELECT * FROM users WHERE id = :id AND name = :name AND status = :status') + ); + } + + public function test_extract_parameters_reuses_position_for_same_parameter() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + ['id' => 1], + $normalizer->extractParameters('SELECT * FROM users WHERE id = :id OR parent_id = :id') + ); + } + + public function test_extract_parameters_single() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame(['id' => 1], $normalizer->extractParameters('SELECT * FROM users WHERE id = :id')); + } + + public function test_extract_parameters_without_parameters() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame([], $normalizer->extractParameters('SELECT * FROM users')); + } + + public function test_normalize_multiple_parameters() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + 'SELECT * FROM users WHERE id = $1 AND name = $2 AND status = $3', + $normalizer->normalize('SELECT * FROM users WHERE id = :id AND name = :name AND status = :status') + ); + } + + public function test_normalize_parameters_with_underscores() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + 'SELECT * FROM users WHERE created_at > $1 AND created_at < $2', + $normalizer->normalize('SELECT * FROM users WHERE created_at > :start_date AND created_at < :end_date') + ); + } + + public function test_normalize_preserves_postgresql_type_casts() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + 'SELECT *::text FROM users WHERE id = $1', + $normalizer->normalize('SELECT *::text FROM users WHERE id = :id') + ); + } + + public function test_normalize_preserves_string_literals_with_colons() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + "SELECT * FROM users WHERE time = '12:30:00'", + $normalizer->normalize("SELECT * FROM users WHERE time = '12:30:00'") + ); + } + + public function test_normalize_reuses_position_for_same_parameter() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + 'SELECT * FROM users WHERE id = $1 OR parent_id = $1', + $normalizer->normalize('SELECT * FROM users WHERE id = :id OR parent_id = :id') + ); + } + + public function test_normalize_single_parameter() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame( + 'SELECT * FROM users WHERE id = $1', + $normalizer->normalize('SELECT * FROM users WHERE id = :id') + ); + } + + public function test_normalize_without_parameters() : void + { + $normalizer = new NamedParameterNormalizer(); + + self::assertSame('SELECT * FROM users', $normalizer->normalize('SELECT * FROM users')); + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParsedQueryTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParsedQueryTest.php new file mode 100644 index 000000000..23952ed58 --- /dev/null +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParsedQueryTest.php @@ -0,0 +1,271 @@ +parser = new Parser(); + } + + public function test_columns_filtered_by_table() : void + { + $result = $this->parser->parse('SELECT u.id, o.order_date FROM users u JOIN orders o ON u.id = o.user_id'); + + $userColumns = $result->columns('u'); + $orderColumns = $result->columns('o'); + + self::assertCount(2, $userColumns); + $userColumnNames = \array_map(fn (Column $c) => $c->name(), $userColumns); + self::assertContains('id', $userColumnNames); + + self::assertCount(2, $orderColumns); + $orderColumnNames = \array_map(fn (Column $c) => $c->name(), $orderColumns); + self::assertContains('order_date', $orderColumnNames); + self::assertContains('user_id', $orderColumnNames); + } + + public function test_columns_from_select() : void + { + $result = $this->parser->parse('SELECT id, name FROM users'); + + $columns = $result->columns(); + + self::assertCount(2, $columns); + + $columnNames = \array_map(fn (Column $c) => $c->name(), $columns); + self::assertContains('id', $columnNames); + self::assertContains('name', $columnNames); + } + + public function test_columns_from_where_clause() : void + { + $result = $this->parser->parse('SELECT 1 FROM users WHERE active = true AND name LIKE \'%john%\''); + + $columns = $result->columns(); + + $columnNames = \array_map(fn (Column $c) => $c->name(), $columns); + self::assertContains('active', $columnNames); + self::assertContains('name', $columnNames); + } + + public function test_columns_with_star() : void + { + $result = $this->parser->parse('SELECT * FROM users'); + + $columns = $result->columns(); + + self::assertCount(1, $columns); + self::assertSame('*', $columns[0]->name()); + self::assertNull($columns[0]->table()); + } + + public function test_columns_with_table_qualified_star() : void + { + $result = $this->parser->parse('SELECT u.* FROM users u'); + + $columns = $result->columns(); + + self::assertCount(1, $columns); + self::assertSame('*', $columns[0]->name()); + self::assertSame('u', $columns[0]->table()); + } + + public function test_columns_with_table_qualifier() : void + { + $result = $this->parser->parse('SELECT u.id, u.name FROM users u'); + + $columns = $result->columns(); + + self::assertCount(2, $columns); + + foreach ($columns as $column) { + self::assertSame('u', $column->table()); + } + } + + public function test_functions_from_select() : void + { + $result = $this->parser->parse('SELECT COUNT(*), SUM(amount) FROM orders'); + + $functions = $result->functions(); + + self::assertCount(2, $functions); + + $functionNames = \array_map(fn (FunctionCall $f) => $f->name(), $functions); + self::assertContains('count', $functionNames); + self::assertContains('sum', $functionNames); + } + + public function test_functions_nested() : void + { + $result = $this->parser->parse('SELECT UPPER(CONCAT(first_name, last_name)) FROM users'); + + $functions = $result->functions(); + + self::assertCount(2, $functions); + + $functionNames = \array_map(fn (FunctionCall $f) => $f->name(), $functions); + self::assertContains('upper', $functionNames); + self::assertContains('concat', $functionNames); + } + + public function test_functions_with_schema() : void + { + $result = $this->parser->parse('SELECT pg_catalog.now()'); + + $functions = $result->functions(); + + self::assertCount(1, $functions); + self::assertSame('now', $functions[0]->name()); + self::assertSame('pg_catalog', $functions[0]->schema()); + } + + public function test_raw_returns_parse_result() : void + { + $result = $this->parser->parse('SELECT 1'); + + self::assertInstanceOf(ParseResult::class, $result->raw()); + } + + public function test_tables_from_cte() : void + { + $result = $this->parser->parse('WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users'); + + $tables = $result->tables(); + + self::assertCount(2, $tables); + + $tableNames = \array_map(fn (Table $t) => $t->name(), $tables); + self::assertContains('users', $tableNames); + self::assertContains('active_users', $tableNames); + } + + public function test_tables_from_delete() : void + { + $result = $this->parser->parse('DELETE FROM users WHERE id = 1'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('users', $tables[0]->name()); + } + + public function test_tables_from_insert() : void + { + $result = $this->parser->parse('INSERT INTO users (name) VALUES (\'john\')'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('users', $tables[0]->name()); + } + + public function test_tables_from_join() : void + { + $result = $this->parser->parse('SELECT * FROM users u JOIN orders o ON u.id = o.user_id'); + + $tables = $result->tables(); + + self::assertCount(2, $tables); + + $tableNames = \array_map(fn (Table $t) => $t->name(), $tables); + self::assertContains('users', $tableNames); + self::assertContains('orders', $tableNames); + } + + public function test_tables_from_simple_select() : void + { + $result = $this->parser->parse('SELECT * FROM users'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertInstanceOf(Table::class, $tables[0]); + self::assertSame('users', $tables[0]->name()); + self::assertNull($tables[0]->schema()); + self::assertNull($tables[0]->alias()); + } + + public function test_tables_from_subquery() : void + { + $result = $this->parser->parse('SELECT * FROM (SELECT * FROM orders) AS sub'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('orders', $tables[0]->name()); + } + + public function test_tables_from_update() : void + { + $result = $this->parser->parse('UPDATE users SET name = \'john\' WHERE id = 1'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('users', $tables[0]->name()); + } + + public function test_tables_with_alias() : void + { + $result = $this->parser->parse('SELECT * FROM users AS u'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('users', $tables[0]->name()); + self::assertSame('u', $tables[0]->alias()); + } + + public function test_tables_with_schema() : void + { + $result = $this->parser->parse('SELECT * FROM public.users'); + + $tables = $result->tables(); + + self::assertCount(1, $tables); + self::assertSame('users', $tables[0]->name()); + self::assertSame('public', $tables[0]->schema()); + } + + public function test_traverse_with_multiple_visitors() : void + { + $result = $this->parser->parse('SELECT COUNT(id), name FROM users WHERE active = true'); + + $columnCollector = new ColumnRefCollector(); + $funcCollector = new FuncCallCollector(); + $rangeVarCollector = new RangeVarCollector(); + + $result->traverse($columnCollector, $funcCollector, $rangeVarCollector); + + self::assertCount(3, $columnCollector->getColumnRefs()); + self::assertCount(1, $funcCollector->getFuncCalls()); + self::assertCount(1, $rangeVarCollector->getRangeVars()); + } + + public function test_traverse_with_single_visitor() : void + { + $result = $this->parser->parse('SELECT id, name FROM users'); + + $collector = new ColumnRefCollector(); + $result->traverse($collector); + + self::assertCount(2, $collector->getColumnRefs()); + } +} diff --git a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParserTest.php b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParserTest.php index be0b637f6..faa63c7e9 100644 --- a/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParserTest.php +++ b/src/lib/pg-query/tests/Flow/PgQuery/Tests/Unit/ParserTest.php @@ -4,8 +4,7 @@ namespace Flow\PgQuery\Tests\Unit; -use Flow\PgQuery\Parser; -use Flow\PgQuery\Protobuf\AST\ParseResult; +use Flow\PgQuery\{ParsedQuery, Parser}; use PHPUnit\Framework\TestCase; final class ParserTest extends TestCase @@ -55,6 +54,16 @@ public function test_normalize_multiple_values() : void self::assertStringContainsString('$2', $normalized); } + public function test_normalize_with_named_parameters() : void + { + $parser = new Parser(); + $normalized = $parser->normalize('SELECT * FROM users WHERE id = :id AND name = :name'); + + self::assertIsString($normalized); + self::assertStringContainsString('$1', $normalized); + self::assertStringContainsString('$2', $normalized); + } + public function test_parse_invalid_sql_throws_exception() : void { $parser = new Parser(); @@ -70,8 +79,8 @@ public function test_parse_multiple_statements() : void $parser = new Parser(); $result = $parser->parse('SELECT 1; SELECT 2'); - self::assertInstanceOf(ParseResult::class, $result); - self::assertCount(2, $result->getStmts()); + self::assertInstanceOf(ParsedQuery::class, $result); + self::assertCount(2, $result->raw()->getStmts()); } public function test_parse_select_with_columns() : void @@ -79,8 +88,8 @@ public function test_parse_select_with_columns() : void $parser = new Parser(); $result = $parser->parse('SELECT id, name FROM users'); - self::assertInstanceOf(ParseResult::class, $result); - self::assertCount(1, $result->getStmts()); + self::assertInstanceOf(ParsedQuery::class, $result); + self::assertCount(1, $result->raw()->getStmts()); } public function test_parse_select_with_where() : void @@ -88,8 +97,8 @@ public function test_parse_select_with_where() : void $parser = new Parser(); $result = $parser->parse('SELECT * FROM users WHERE active = true'); - self::assertInstanceOf(ParseResult::class, $result); - self::assertCount(1, $result->getStmts()); + self::assertInstanceOf(ParsedQuery::class, $result); + self::assertCount(1, $result->raw()->getStmts()); } public function test_parse_simple_select() : void @@ -97,8 +106,8 @@ public function test_parse_simple_select() : void $parser = new Parser(); $result = $parser->parse('SELECT 1'); - self::assertInstanceOf(ParseResult::class, $result); - self::assertCount(1, $result->getStmts()); + self::assertInstanceOf(ParsedQuery::class, $result); + self::assertCount(1, $result->raw()->getStmts()); } public function test_split() : void