SqlRenderer.php
PHP
Path: src/Parsing/SQL/SqlRenderer.php
<?php
namespace mini\Parsing\SQL;
use mini\Database\SqlDialect;
use mini\Parsing\SQL\AST\{
ASTNode,
SelectStatement,
WithStatement,
UnionNode,
SubqueryNode,
ColumnNode,
JoinNode,
IdentifierNode,
LiteralNode,
PlaceholderNode,
BinaryOperation,
UnaryOperation,
InOperation,
IsNullOperation,
LikeOperation,
BetweenOperation,
ExistsOperation,
FunctionCallNode,
CaseWhenNode,
WindowFunctionNode,
NiladicFunctionNode,
QuantifiedComparisonNode
};
/**
* Renders AST nodes back to SQL strings
*
* This is the inverse of SqlParser - it takes an AST and produces SQL.
* Used by PartialQuery to generate SQL from its internal AST representation.
*
* Usage:
* ```php
* $renderer = SqlRenderer::forDialect(SqlDialect::MySQL);
* $sql = $renderer->render($ast);
*
* // Or with params collection (for prepared statements):
* [$sql, $params] = $renderer->renderWithParams($ast);
* ```
*/
class SqlRenderer
{
private SqlDialect $dialect;
private function __construct(SqlDialect $dialect)
{
$this->dialect = $dialect;
}
/**
* Create a renderer for a specific SQL dialect
*/
public static function forDialect(SqlDialect $dialect = SqlDialect::Generic): self
{
return new self($dialect);
}
/**
* Collected params during renderWithParams()
* @var array<int, mixed>|null
*/
private ?array $collectingParams = null;
/**
* Render an AST node to SQL string
*
* @param ASTNode $node The AST node to render
* @return string The SQL string
*/
public function render(ASTNode $node): string
{
return $this->doRender($node);
}
/**
* Render an AST node to SQL string and collect bound parameter values
*
* Returns both the SQL (with ? placeholders) and an array of bound values
* in the order they appear. This ensures params are always correctly
* ordered to match their placeholders.
*
* @param ASTNode $node The AST node to render
* @return array{string, array<int, mixed>} [sql, params]
* @throws \RuntimeException If any placeholder is unbound
*/
public function renderWithParams(ASTNode $node): array
{
$this->collectingParams = [];
try {
$sql = $this->doRender($node);
return [$sql, $this->collectingParams];
} finally {
$this->collectingParams = null;
}
}
/**
* Render ORDER BY items to SQL string (without ORDER BY keywords)
*
* @param array<int, array{column: ASTNode, direction: string}> $orderBy
* @return string The rendered ORDER BY clause body
*/
public function renderOrderByItems(array $orderBy): string
{
$parts = [];
foreach ($orderBy as $order) {
$part = $this->doRender($order['column']);
if (isset($order['direction']) && strtoupper($order['direction']) === 'DESC') {
$part .= ' DESC';
}
$parts[] = $part;
}
return implode(', ', $parts);
}
private function doRender(ASTNode $node): string
{
return match (true) {
$node instanceof WithStatement => $this->renderWith($node),
$node instanceof SelectStatement => $this->renderSelect($node),
$node instanceof UnionNode => $this->renderUnion($node),
$node instanceof SubqueryNode => $this->renderSubquery($node),
$node instanceof ColumnNode => $this->renderColumn($node),
$node instanceof JoinNode => $this->renderJoin($node),
$node instanceof IdentifierNode => $this->renderIdentifier($node),
$node instanceof LiteralNode => $this->renderLiteral($node),
$node instanceof PlaceholderNode => $this->renderPlaceholder($node),
$node instanceof BinaryOperation => $this->renderBinary($node),
$node instanceof UnaryOperation => $this->renderUnary($node),
$node instanceof InOperation => $this->renderIn($node),
$node instanceof IsNullOperation => $this->renderIsNull($node),
$node instanceof LikeOperation => $this->renderLike($node),
$node instanceof BetweenOperation => $this->renderBetween($node),
$node instanceof ExistsOperation => $this->renderExists($node),
$node instanceof FunctionCallNode => $this->renderFunction($node),
$node instanceof CaseWhenNode => $this->renderCase($node),
$node instanceof WindowFunctionNode => $this->renderWindow($node),
$node instanceof NiladicFunctionNode => $node->name,
$node instanceof QuantifiedComparisonNode => $this->renderQuantified($node),
default => throw new \RuntimeException('Unknown AST node type: ' . get_class($node)),
};
}
private function renderWith(WithStatement $node): string
{
$sql = 'WITH ';
if ($node->recursive) {
$sql .= 'RECURSIVE ';
}
$cteParts = [];
foreach ($node->ctes as $cte) {
$name = $cte['name'];
$columns = $cte['columns'] ?? null;
$query = $this->render($cte['query']);
$cteDef = $name;
if ($columns) {
$cteDef .= ' (' . implode(', ', $columns) . ')';
}
$cteDef .= ' AS (' . $query . ')';
$cteParts[] = $cteDef;
}
$sql .= implode(', ', $cteParts);
$sql .= ' ' . $this->render($node->query);
return $sql;
}
private function renderSelect(SelectStatement $node): string
{
$sql = 'SELECT ';
if ($node->distinct) {
$sql .= 'DISTINCT ';
}
// Columns
$columnParts = [];
foreach ($node->columns as $col) {
$columnParts[] = $this->render($col);
}
$sql .= implode(', ', $columnParts);
// FROM
if ($node->from !== null) {
$sql .= ' FROM ';
if ($node->from instanceof SubqueryNode) {
$sql .= '(' . $this->render($node->from->query) . ')';
} else {
$sql .= $this->render($node->from);
}
if ($node->fromAlias !== null) {
$sql .= ' AS ' . $node->fromAlias;
}
}
// JOINs
foreach ($node->joins as $join) {
$sql .= ' ' . $this->renderJoin($join);
}
// WHERE
if ($node->where !== null) {
$sql .= ' WHERE ' . $this->render($node->where);
}
// GROUP BY
if ($node->groupBy !== null && !empty($node->groupBy)) {
$groupParts = array_map(fn($g) => $this->render($g), $node->groupBy);
$sql .= ' GROUP BY ' . implode(', ', $groupParts);
}
// HAVING
if ($node->having !== null) {
$sql .= ' HAVING ' . $this->render($node->having);
}
// ORDER BY
if ($node->orderBy !== null && !empty($node->orderBy)) {
$orderParts = [];
foreach ($node->orderBy as $order) {
$part = $this->render($order['column']);
if (isset($order['direction']) && strtoupper($order['direction']) === 'DESC') {
$part .= ' DESC';
}
$orderParts[] = $part;
}
$sql .= ' ORDER BY ' . implode(', ', $orderParts);
}
// LIMIT/OFFSET - dialect-specific
$sql .= $this->renderLimitOffset($node->limit, $node->offset);
return $sql;
}
/**
* Render LIMIT/OFFSET clause based on dialect
*/
private function renderLimitOffset(?ASTNode $limit, ?ASTNode $offset): string
{
if ($limit === null && $offset === null) {
return '';
}
// SQL Server uses OFFSET/FETCH syntax (requires ORDER BY)
if ($this->dialect === SqlDialect::SqlServer) {
$sql = '';
// OFFSET is required for FETCH in SQL Server
$sql .= ' OFFSET ' . ($offset !== null ? $this->render($offset) : '0') . ' ROWS';
if ($limit !== null) {
$sql .= ' FETCH NEXT ' . $this->render($limit) . ' ROWS ONLY';
}
return $sql;
}
// Standard LIMIT/OFFSET syntax (PostgreSQL, SQLite, MySQL, etc.)
$sql = '';
if ($limit !== null) {
$sql .= ' LIMIT ' . $this->render($limit);
}
if ($offset !== null) {
$sql .= ' OFFSET ' . $this->render($offset);
}
return $sql;
}
private function renderUnion(UnionNode $node): string
{
$op = $node->operator;
// Check dialect support for EXCEPT/INTERSECT
if (($op === 'EXCEPT' || $op === 'INTERSECT') && !$this->dialect->supportsExcept()) {
throw new \RuntimeException(
"{$op} is not supported by {$this->dialect->getName()}. " .
"Use alternative query patterns (e.g., NOT EXISTS for EXCEPT)."
);
}
// Render without parentheses for simple SELECTs
// Only add parentheses if needed for nested UNIONs
$left = $this->render($node->left);
$right = $this->render($node->right);
if ($node->all) {
$op .= ' ALL';
}
// Wrap in parens only if nested UNION
$needsLeftParen = $node->left instanceof UnionNode;
$needsRightParen = $node->right instanceof UnionNode;
$leftSql = $needsLeftParen ? "($left)" : $left;
$rightSql = $needsRightParen ? "($right)" : $right;
return "$leftSql $op $rightSql";
}
private function renderSubquery(SubqueryNode $node): string
{
return '(' . $this->render($node->query) . ')';
}
private function renderColumn(ColumnNode $node): string
{
$sql = $this->render($node->expression);
if ($node->alias !== null) {
$sql .= ' AS ' . $node->alias;
}
return $sql;
}
private function renderJoin(JoinNode $node): string
{
$sql = $node->joinType . ' JOIN ';
if ($node->table instanceof SubqueryNode) {
$sql .= '(' . $this->render($node->table->query) . ')';
} else {
$sql .= $this->render($node->table);
}
if ($node->alias !== null) {
$sql .= ' AS ' . $node->alias;
}
if ($node->condition !== null) {
$sql .= ' ON ' . $this->render($node->condition);
}
return $sql;
}
private function renderIdentifier(IdentifierNode $node): string
{
$parts = [];
foreach ($node->parts as $part) {
// Asterisk is the wildcard, not an identifier - don't quote
if ($part === '*') {
$parts[] = $part;
} elseif (preg_match('/[^a-zA-Z0-9_]/', $part)) {
// Quote if contains special characters (spaces, etc.)
// Use dialect-specific quoting (backticks for MySQL, brackets for SQL Server, etc.)
$parts[] = $this->dialect->quoteIdentifier($part);
} else {
$parts[] = $part;
}
}
return implode('.', $parts);
}
private function renderLiteral(LiteralNode $node): string
{
if ($node->value === null || $node->valueType === 'null') {
return 'NULL';
}
if ($node->valueType === 'number') {
return (string) $node->value;
}
// String - quote with single quotes, escape internal quotes
$escaped = str_replace("'", "''", (string) $node->value);
return "'" . $escaped . "'";
}
private function renderPlaceholder(PlaceholderNode $node): string
{
// If we're collecting params, add the bound value to the collection
if ($this->collectingParams !== null) {
if (!$node->isBound) {
throw new \RuntimeException(
'Unbound placeholder encountered during renderWithParams(). ' .
'All placeholders must have bound values.'
);
}
$this->collectingParams[] = $node->boundValue;
}
// Always emit positional placeholder when value is bound (normalize named to positional)
// This ensures SQL and params stay consistent
if ($node->isBound) {
return '?';
}
// Not bound - output original token
return $node->token;
}
private function renderBinary(BinaryOperation $node): string
{
$left = $this->render($node->left);
$right = $this->render($node->right);
$op = strtoupper($node->operator);
// Add parentheses around sub-expressions for safety
if ($node->left instanceof BinaryOperation) {
$left = "($left)";
}
if ($node->right instanceof BinaryOperation) {
$right = "($right)";
}
return "$left $op $right";
}
private function renderUnary(UnaryOperation $node): string
{
$expr = $this->render($node->expression);
$op = strtoupper($node->operator);
// NOT needs space, - doesn't
if ($op === 'NOT') {
return "NOT ($expr)";
}
return $op . $expr;
}
private function renderIn(InOperation $node): string
{
$left = $this->render($node->left);
$op = $node->negated ? 'NOT IN' : 'IN';
if ($node->isSubquery()) {
$values = $this->render($node->values);
} else {
$valueParts = array_map(fn($v) => $this->render($v), $node->values);
$values = '(' . implode(', ', $valueParts) . ')';
}
return "$left $op $values";
}
private function renderIsNull(IsNullOperation $node): string
{
$expr = $this->render($node->expression);
$op = $node->negated ? 'IS NOT NULL' : 'IS NULL';
return "$expr $op";
}
private function renderLike(LikeOperation $node): string
{
$left = $this->render($node->left);
$pattern = $this->render($node->pattern);
$op = $node->negated ? 'NOT LIKE' : 'LIKE';
return "$left $op $pattern";
}
private function renderBetween(BetweenOperation $node): string
{
$expr = $this->render($node->expression);
$low = $this->render($node->low);
$high = $this->render($node->high);
$op = $node->negated ? 'NOT BETWEEN' : 'BETWEEN';
return "$expr $op $low AND $high";
}
private function renderExists(ExistsOperation $node): string
{
$subquery = $this->render($node->subquery);
$op = $node->negated ? 'NOT EXISTS' : 'EXISTS';
return "$op $subquery";
}
private function renderFunction(FunctionCallNode $node): string
{
$name = strtoupper($node->name);
if (empty($node->arguments)) {
// Functions like COUNT(*) have no real arguments
return "$name()";
}
$distinct = $node->distinct ? 'DISTINCT ' : '';
$args = array_map(fn($a) => $this->render($a), $node->arguments);
return "$name($distinct" . implode(', ', $args) . ')';
}
private function renderCase(CaseWhenNode $node): string
{
$sql = 'CASE';
// Simple CASE has operand
if ($node->operand !== null) {
$sql .= ' ' . $this->render($node->operand);
}
foreach ($node->whenClauses as $when) {
$sql .= ' WHEN ' . $this->render($when['when']);
$sql .= ' THEN ' . $this->render($when['then']);
}
if ($node->elseResult !== null) {
$sql .= ' ELSE ' . $this->render($node->elseResult);
}
$sql .= ' END';
return $sql;
}
private function renderWindow(WindowFunctionNode $node): string
{
$sql = $this->render($node->function) . ' OVER (';
$overParts = [];
if (!empty($node->partitionBy)) {
$parts = array_map(fn($p) => $this->render($p), $node->partitionBy);
$overParts[] = 'PARTITION BY ' . implode(', ', $parts);
}
if (!empty($node->orderBy)) {
$orderParts = [];
foreach ($node->orderBy as $order) {
$part = $this->render($order['expr']);
if (isset($order['direction']) && strtoupper($order['direction']) === 'DESC') {
$part .= ' DESC';
}
$orderParts[] = $part;
}
$overParts[] = 'ORDER BY ' . implode(', ', $orderParts);
}
$sql .= implode(' ', $overParts) . ')';
return $sql;
}
private function renderQuantified(QuantifiedComparisonNode $node): string
{
$left = $this->render($node->left);
$subquery = $this->render($node->subquery);
return "$left {$node->operator} {$node->quantifier} $subquery";
}
/**
* Rename an identifier throughout an AST
*
* Creates a new AST with all occurrences of $oldName replaced with $newName.
* Used for CTE renaming when composing queries.
*
* Only renames table/CTE references (single-part identifiers that match exactly).
* Does not rename column qualifiers like `users.id` to `_cte_123.id` since
* the table alias should be preserved.
*
* @param ASTNode $node The AST to transform
* @param string $oldName The identifier name to find
* @param string $newName The new name to use
* @return ASTNode New AST with renamed identifiers
*/
public function renameIdentifier(ASTNode $node, string $oldName, string $newName): ASTNode
{
// For identifiers, check if it matches and rename
if ($node instanceof IdentifierNode) {
// Only rename single-part identifiers (table/CTE names)
// Multi-part identifiers like table.column keep their qualifier
if (count($node->parts) === 1 && $node->parts[0] === $oldName) {
return new IdentifierNode([$newName]);
}
return new IdentifierNode($node->parts);
}
// Recursively transform all other node types
return match (true) {
$node instanceof WithStatement => $this->renameInWith($node, $oldName, $newName),
$node instanceof SelectStatement => $this->renameInSelect($node, $oldName, $newName),
$node instanceof UnionNode => new UnionNode(
$this->renameIdentifier($node->left, $oldName, $newName),
$this->renameIdentifier($node->right, $oldName, $newName),
$node->all,
$node->operator
),
$node instanceof SubqueryNode => new SubqueryNode(
$this->renameIdentifier($node->query, $oldName, $newName)
),
$node instanceof ColumnNode => new ColumnNode(
$this->renameIdentifier($node->expression, $oldName, $newName),
$node->alias
),
$node instanceof JoinNode => $this->renameInJoin($node, $oldName, $newName),
$node instanceof LiteralNode => new LiteralNode($node->value, $node->valueType),
$node instanceof PlaceholderNode => $node->deepClone(),
$node instanceof BinaryOperation => new BinaryOperation(
$this->renameIdentifier($node->left, $oldName, $newName),
$node->operator,
$this->renameIdentifier($node->right, $oldName, $newName)
),
$node instanceof UnaryOperation => new UnaryOperation(
$node->operator,
$this->renameIdentifier($node->expression, $oldName, $newName)
),
$node instanceof InOperation => $this->renameInIn($node, $oldName, $newName),
$node instanceof IsNullOperation => new IsNullOperation(
$this->renameIdentifier($node->expression, $oldName, $newName),
$node->negated
),
$node instanceof LikeOperation => new LikeOperation(
$this->renameIdentifier($node->left, $oldName, $newName),
$this->renameIdentifier($node->pattern, $oldName, $newName),
$node->negated
),
$node instanceof BetweenOperation => new BetweenOperation(
$this->renameIdentifier($node->expression, $oldName, $newName),
$this->renameIdentifier($node->low, $oldName, $newName),
$this->renameIdentifier($node->high, $oldName, $newName),
$node->negated
),
$node instanceof ExistsOperation => new ExistsOperation(
new SubqueryNode($this->renameIdentifier($node->subquery->query, $oldName, $newName)),
$node->negated
),
$node instanceof FunctionCallNode => new FunctionCallNode(
$node->name,
array_map(fn($a) => $this->renameIdentifier($a, $oldName, $newName), $node->arguments),
$node->distinct
),
$node instanceof CaseWhenNode => $this->renameInCase($node, $oldName, $newName),
$node instanceof WindowFunctionNode => $this->renameInWindow($node, $oldName, $newName),
$node instanceof NiladicFunctionNode => new NiladicFunctionNode($node->name),
$node instanceof QuantifiedComparisonNode => new QuantifiedComparisonNode(
$this->renameIdentifier($node->left, $oldName, $newName),
$node->operator,
$node->quantifier,
new SubqueryNode($this->renameIdentifier($node->subquery->query, $oldName, $newName))
),
default => throw new \RuntimeException('Cannot rename in unknown AST node type: ' . get_class($node)),
};
}
private function renameInWith(WithStatement $node, string $oldName, string $newName): WithStatement
{
$ctes = [];
foreach ($node->ctes as $cte) {
$ctes[] = [
'name' => $cte['name'] === $oldName ? $newName : $cte['name'],
'columns' => $cte['columns'] ?? null,
'query' => $this->renameIdentifier($cte['query'], $oldName, $newName),
];
}
return new WithStatement(
$ctes,
$node->recursive,
$this->renameIdentifier($node->query, $oldName, $newName)
);
}
private function renameInSelect(SelectStatement $node, string $oldName, string $newName): SelectStatement
{
$new = new SelectStatement();
$new->distinct = $node->distinct;
$new->columns = array_map(
fn($c) => $this->renameIdentifier($c, $oldName, $newName),
$node->columns
);
if ($node->from instanceof SubqueryNode) {
$new->from = new SubqueryNode($this->renameIdentifier($node->from->query, $oldName, $newName));
} elseif ($node->from !== null) {
$new->from = $this->renameIdentifier($node->from, $oldName, $newName);
}
$new->fromAlias = $node->fromAlias;
$new->joins = array_map(
fn($j) => $this->renameInJoin($j, $oldName, $newName),
$node->joins
);
$new->where = $node->where !== null
? $this->renameIdentifier($node->where, $oldName, $newName)
: null;
if ($node->groupBy !== null) {
$new->groupBy = array_map(
fn($g) => $this->renameIdentifier($g, $oldName, $newName),
$node->groupBy
);
}
$new->having = $node->having !== null
? $this->renameIdentifier($node->having, $oldName, $newName)
: null;
if ($node->orderBy !== null) {
$new->orderBy = array_map(fn($o) => [
'column' => $this->renameIdentifier($o['column'], $oldName, $newName),
'direction' => $o['direction'] ?? 'ASC',
], $node->orderBy);
}
// Limit and offset are expressions, rename in case they reference tables (unlikely but safe)
$new->limit = $node->limit !== null
? $this->renameIdentifier($node->limit, $oldName, $newName)
: null;
$new->offset = $node->offset !== null
? $this->renameIdentifier($node->offset, $oldName, $newName)
: null;
return $new;
}
private function renameInJoin(JoinNode $node, string $oldName, string $newName): JoinNode
{
$table = $node->table instanceof SubqueryNode
? new SubqueryNode($this->renameIdentifier($node->table->query, $oldName, $newName))
: $this->renameIdentifier($node->table, $oldName, $newName);
return new JoinNode(
$node->joinType,
$table,
$node->condition !== null
? $this->renameIdentifier($node->condition, $oldName, $newName)
: null,
$node->alias
);
}
private function renameInIn(InOperation $node, string $oldName, string $newName): InOperation
{
if ($node->isSubquery()) {
return new InOperation(
$this->renameIdentifier($node->left, $oldName, $newName),
new SubqueryNode($this->renameIdentifier($node->values->query, $oldName, $newName)),
$node->negated
);
}
return new InOperation(
$this->renameIdentifier($node->left, $oldName, $newName),
array_map(fn($v) => $this->renameIdentifier($v, $oldName, $newName), $node->values),
$node->negated
);
}
private function renameInCase(CaseWhenNode $node, string $oldName, string $newName): CaseWhenNode
{
$whenClauses = array_map(fn($w) => [
'when' => $this->renameIdentifier($w['when'], $oldName, $newName),
'then' => $this->renameIdentifier($w['then'], $oldName, $newName),
], $node->whenClauses);
return new CaseWhenNode(
$node->operand !== null ? $this->renameIdentifier($node->operand, $oldName, $newName) : null,
$whenClauses,
$node->elseResult !== null ? $this->renameIdentifier($node->elseResult, $oldName, $newName) : null
);
}
private function renameInWindow(WindowFunctionNode $node, string $oldName, string $newName): WindowFunctionNode
{
$orderBy = array_map(fn($o) => [
'expr' => $this->renameIdentifier($o['expr'], $oldName, $newName),
'direction' => $o['direction'] ?? 'ASC',
], $node->orderBy);
return new WindowFunctionNode(
$this->renameIdentifier($node->function, $oldName, $newName),
array_map(fn($p) => $this->renameIdentifier($p, $oldName, $newName), $node->partitionBy),
$orderBy
);
}
}