SqlParser.php

PHP

Path: src/Parsing/SQL/SqlParser.php

<?php

namespace mini\Parsing\SQL;

use mini\Parsing\SQL\AST\{
    ASTNode,
    SelectStatement,
    InsertStatement,
    UpdateStatement,
    DeleteStatement,
    CreateTableStatement,
    CreateIndexStatement,
    DropTableStatement,
    DropIndexStatement,
    ColumnDefinition,
    TableConstraint,
    IndexColumn,
    ColumnNode,
    FunctionCallNode,
    WindowFunctionNode,
    UnaryOperation,
    BinaryOperation,
    InOperation,
    IsNullOperation,
    LikeOperation,
    BetweenOperation,
    SubqueryNode,
    LiteralNode,
    IdentifierNode,
    PlaceholderNode,
    JoinNode,
    CaseWhenNode,
    WithStatement
};

/**
 * SQL Parser - Recursive Descent Implementation
 *
 * Features:
 * - Generates an AST (Abstract Syntax Tree)
 * - Supports SELECT, INSERT, UPDATE, DELETE
 * - Supports WHERE, ORDER BY, LIMIT
 * - Supports IN (LIST) and IN (SELECT ...) recursively
 * - Supports Function Calls (e.g. COUNT(*), MAX(col))
 * - Supports dotted identifiers (table.column)
 * - Supports Placeholders (? and :name)
 * - Supports Negative Numbers (-5)
 * - Rich Error Reporting (Line numbers + Visual pointers)
 */
class SqlParser
{
    private string $sql;
    private array $tokens;
    private int $pos = 0;

    /** @var array<string, ASTNode> Static cache of parsed ASTs keyed by SQL string */
    private static array $astCache = [];

    /** Maximum number of cached ASTs (simple LRU via array_shift when exceeded) */
    private const CACHE_MAX_SIZE = 256;

    /**
     * Parse SQL string into AST
     *
     * @param string $sql SQL query to parse
     * @return SelectStatement|InsertStatement|UpdateStatement|DeleteStatement
     * @throws SqlSyntaxException
     */
    public function parse(string $sql): ASTNode
    {
        $this->sql = $sql;
        $lexer = new SqlLexer($sql);
        $this->tokens = $lexer->tokenize();
        $this->pos = 0;

        // Handle WITH clause (CTEs)
        if ($this->current()['type'] === SqlLexer::T_WITH) {
            return $this->parseWithStatement();
        }

        $token = $this->current();
        $stmt = match($token['type']) {
            SqlLexer::T_SELECT => $this->parseSelectOrUnion(),
            SqlLexer::T_INSERT => $this->parseInsertStatement(),
            SqlLexer::T_REPLACE => $this->parseReplaceStatement(),
            SqlLexer::T_UPDATE => $this->parseUpdateStatement(),
            SqlLexer::T_DELETE => $this->parseDeleteStatement(),
            SqlLexer::T_CREATE => $this->parseCreateStatement(),
            SqlLexer::T_DROP => $this->parseDropStatement(),
            default => throw new SqlSyntaxException(
                "Unexpected start of query: " . $token['type'],
                $this->sql,
                $token['pos']
            )
        };

        if ($this->current()['type'] !== SqlLexer::T_EOF) {
            throw new SqlSyntaxException(
                "Unexpected trailing input",
                $this->sql,
                $this->current()['pos']
            );
        }

        return $stmt;
    }

    /**
     * Parse a SQL expression fragment into AST
     *
     * Use this for parsing WHERE clause fragments like:
     * - "userId = ?"
     * - "age > 18 AND status = 'active'"
     * - "name LIKE '%john%'"
     *
     * @param string $expression SQL expression to parse
     * @return ASTNode The parsed expression AST
     * @throws SqlSyntaxException
     */
    public function parseExpressionFragment(string $expression): ASTNode
    {
        $this->sql = $expression;
        $lexer = new SqlLexer($expression);
        $this->tokens = $lexer->tokenize();
        $this->pos = 0;

        $ast = $this->parseExpression();

        if ($this->current()['type'] !== SqlLexer::T_EOF) {
            throw new SqlSyntaxException(
                "Unexpected trailing input in expression",
                $this->sql,
                $this->current()['pos']
            );
        }

        return $ast;
    }

    /**
     * Parse an ORDER BY clause fragment into AST
     *
     * Use this for parsing ORDER BY specifications like:
     * - "name"
     * - "name DESC"
     * - "created_at DESC, name ASC"
     *
     * @param string $orderBy ORDER BY specification to parse
     * @return array<int, array{column: ASTNode, direction: string}> Parsed order items
     * @throws SqlSyntaxException
     */
    public function parseOrderByFragment(string $orderBy): array
    {
        $this->sql = $orderBy;
        $lexer = new SqlLexer($orderBy);
        $this->tokens = $lexer->tokenize();
        $this->pos = 0;

        $result = [];
        do {
            $expr = $this->parseExpression();
            $direction = 'ASC';
            if ($this->match(SqlLexer::T_DESC)) {
                $direction = 'DESC';
            } elseif ($this->match(SqlLexer::T_ASC)) {
                $direction = 'ASC';
            }
            $result[] = ['column' => $expr, 'direction' => $direction];
        } while ($this->match(SqlLexer::T_COMMA));

        if ($this->current()['type'] !== SqlLexer::T_EOF) {
            throw new SqlSyntaxException(
                "Unexpected trailing input in ORDER BY",
                $this->sql,
                $this->current()['pos']
            );
        }

        return $result;
    }

    private function current(): array
    {
        return $this->tokens[$this->pos];
    }

    private function peek(): array
    {
        if (isset($this->tokens[$this->pos + 1])) {
            return $this->tokens[$this->pos + 1];
        }
        return ['type' => SqlLexer::T_EOF, 'pos' => strlen($this->sql)];
    }

    private function match(string $type): bool
    {
        if ($this->current()['type'] === $type) {
            $this->pos++;
            return true;
        }
        return false;
    }

    private function expect(string $type): array
    {
        if ($this->match($type)) {
            return $this->tokens[$this->pos - 1];
        }

        $curr = $this->current();
        throw new SqlSyntaxException(
            "Expected token $type but found " . $curr['type'] . " (" . $curr['value'] . ")",
            $this->sql,
            $curr['pos']
        );
    }

    private function expectOp(string $opValue): array
    {
        $token = $this->current();
        if ($token['type'] === SqlLexer::T_OP && $token['value'] === $opValue) {
            $this->pos++;
            return $token;
        }

        throw new SqlSyntaxException(
            "Expected operator '$opValue' but found " . $token['value'],
            $this->sql,
            $token['pos']
        );
    }

    // --- Statement Parsers ---

    private function parseSelectStatement(): SelectStatement
    {
        $this->expect(SqlLexer::T_SELECT);
        $stmt = new SelectStatement();

        // Handle DISTINCT
        if ($this->match(SqlLexer::T_DISTINCT)) {
            $stmt->distinct = true;
        }

        $stmt->columns = $this->parseColumnList();

        // FROM is optional (for SELECT 1, SELECT expression, etc.)
        if ($this->match(SqlLexer::T_FROM)) {
            // Check for derived table: (SELECT ...)
            if ($this->current()['type'] === SqlLexer::T_LPAREN) {
                $stmt->from = $this->parseDerivedTable();
                // Derived tables require an alias
                if ($this->match(SqlLexer::T_AS)) {
                    $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                    $stmt->fromAlias = $aliasToken['value'];
                } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                    $stmt->fromAlias = $this->current()['value'];
                    $this->pos++;
                } else {
                    throw new \RuntimeException("Derived table requires an alias");
                }
            } else {
                $stmt->from = $this->parseIdentifier();

                // Optional table alias
                if ($this->match(SqlLexer::T_AS)) {
                    $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                    $stmt->fromAlias = $aliasToken['value'];
                } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                    // Implicit alias (without AS)
                    $stmt->fromAlias = $this->current()['value'];
                    $this->pos++;
                }
            }

            // Parse comma-separated tables as implicit CROSS JOINs
            while ($this->match(SqlLexer::T_COMMA)) {
                $table = $this->parseIdentifier();
                $alias = null;
                if ($this->match(SqlLexer::T_AS)) {
                    $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                    $alias = $aliasToken['value'];
                } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                    // Implicit alias (keywords have their own token types)
                    $alias = $this->current()['value'];
                    $this->pos++;
                }
                $stmt->joins[] = new AST\JoinNode('CROSS', $table, null, $alias);
            }

            // Parse explicit JOINs
            while ($this->isJoinStart()) {
                $stmt->joins[] = $this->parseJoin();
            }
        } else {
            $stmt->from = null;
        }

        if ($this->match(SqlLexer::T_WHERE)) {
            $stmt->where = $this->parseExpression();
        }

        // GROUP BY
        if ($this->match(SqlLexer::T_GROUP)) {
            $this->expect(SqlLexer::T_BY);
            $stmt->groupBy = [];
            do {
                $stmt->groupBy[] = $this->parseExpression();
            } while ($this->match(SqlLexer::T_COMMA));
        }

        // HAVING (only valid after GROUP BY)
        if ($this->match(SqlLexer::T_HAVING)) {
            $stmt->having = $this->parseExpression();
        }

        if ($this->match(SqlLexer::T_ORDER)) {
            $this->expect(SqlLexer::T_BY);
            $stmt->orderBy = [];
            do {
                $expr = $this->parseExpression();
                $direction = 'ASC';
                if ($this->match(SqlLexer::T_DESC)) {
                    $direction = 'DESC';
                } elseif ($this->match(SqlLexer::T_ASC)) {
                    $direction = 'ASC';
                }

                $stmt->orderBy[] = ['column' => $expr, 'direction' => $direction];
            } while ($this->match(SqlLexer::T_COMMA));
        }

        // Parse LIMIT/OFFSET - supports two syntaxes:
        // 1. MySQL/PostgreSQL: LIMIT n [OFFSET m]
        // 2. SQL:2008: OFFSET n {ROW|ROWS} [FETCH {FIRST|NEXT} m {ROW|ROWS} ONLY]
        if ($this->match(SqlLexer::T_LIMIT)) {
            $stmt->limit = $this->parseNumberOrPlaceholder('LIMIT');

            if ($this->match(SqlLexer::T_OFFSET)) {
                $stmt->offset = $this->parseNumberOrPlaceholder('OFFSET');
            }
        } elseif ($this->match(SqlLexer::T_OFFSET)) {
            // Two syntaxes:
            // - Simple: OFFSET n (PostgreSQL/SQLite style)
            // - SQL:2008: OFFSET n {ROW|ROWS} [FETCH {FIRST|NEXT} m {ROW|ROWS} ONLY]
            $stmt->offset = $this->parseNumberOrPlaceholder('OFFSET');

            // ROW/ROWS is optional - if present, we may have FETCH clause
            if ($this->match(SqlLexer::T_ROW) || $this->match(SqlLexer::T_ROWS)) {
                // SQL:2008 style - check for optional FETCH
                if ($this->match(SqlLexer::T_FETCH)) {
                    if (!$this->match(SqlLexer::T_FIRST) && !$this->match(SqlLexer::T_NEXT)) {
                        throw new SqlSyntaxException(
                            "FETCH requires FIRST or NEXT",
                            $this->sql,
                            $this->current()['pos'] ?? strlen($this->sql)
                        );
                    }

                    $stmt->limit = $this->parseNumberOrPlaceholder('FETCH');

                    if (!$this->match(SqlLexer::T_ROW) && !$this->match(SqlLexer::T_ROWS)) {
                        throw new SqlSyntaxException(
                            "FETCH requires ROW or ROWS",
                            $this->sql,
                            $this->current()['pos'] ?? strlen($this->sql)
                        );
                    }

                    $this->expect(SqlLexer::T_ONLY);
                }
            }
        } elseif ($this->match(SqlLexer::T_FETCH)) {
            // SQL:2008 without OFFSET: FETCH {FIRST|NEXT} n {ROW|ROWS} ONLY
            if (!$this->match(SqlLexer::T_FIRST) && !$this->match(SqlLexer::T_NEXT)) {
                throw new SqlSyntaxException(
                    "FETCH requires FIRST or NEXT",
                    $this->sql,
                    $this->current()['pos'] ?? strlen($this->sql)
                );
            }

            $stmt->limit = $this->parseNumberOrPlaceholder('FETCH');

            if (!$this->match(SqlLexer::T_ROW) && !$this->match(SqlLexer::T_ROWS)) {
                throw new SqlSyntaxException(
                    "FETCH requires ROW or ROWS",
                    $this->sql,
                    $this->current()['pos'] ?? strlen($this->sql)
                );
            }

            $this->expect(SqlLexer::T_ONLY);
        }

        return $stmt;
    }

    /**
     * Parse a query that may start with WITH or SELECT, and may include UNION/INTERSECT/EXCEPT
     * Used for subqueries in derived tables, IN clauses, EXISTS, etc.
     */
    private function parseSelectOrUnion(): ASTNode
    {
        // Handle WITH clause (CTE) in subquery context
        if ($this->current()['type'] === SqlLexer::T_WITH) {
            return $this->parseWithBody();
        }

        $stmt = $this->parseSelectStatement();

        while (true) {
            $operator = null;
            if ($this->match(SqlLexer::T_UNION)) {
                $operator = 'UNION';
            } elseif ($this->match(SqlLexer::T_INTERSECT)) {
                $operator = 'INTERSECT';
            } elseif ($this->match(SqlLexer::T_EXCEPT)) {
                $operator = 'EXCEPT';
            } else {
                break;
            }
            $all = $this->match(SqlLexer::T_ALL);
            $right = $this->parseSelectStatement();
            $stmt = new AST\UnionNode($stmt, $right, $all, $operator);
        }

        return $stmt;
    }

    /**
     * Parse WITH statement body (CTEs + main query) without EOF validation
     * Used for WITH inside subqueries where EOF check is inappropriate
     */
    private function parseWithBody(): WithStatement
    {
        $this->expect(SqlLexer::T_WITH);

        $recursive = $this->match(SqlLexer::T_RECURSIVE);

        $ctes = [];
        do {
            $cte = $this->parseCteDefinition();
            $ctes[] = $cte;
        } while ($this->match(SqlLexer::T_COMMA));

        // Parse the main query
        if ($this->current()['type'] !== SqlLexer::T_SELECT) {
            throw new SqlSyntaxException(
                "Expected SELECT after WITH clause",
                $this->sql,
                $this->current()['pos']
            );
        }

        $mainQuery = $this->parseSelectOrUnion();

        return new WithStatement($ctes, $recursive, $mainQuery);
    }

    private function parseInsertStatement(): InsertStatement
    {
        $this->expect(SqlLexer::T_INSERT);

        // Check for INSERT OR REPLACE
        $replace = false;
        if ($this->match(SqlLexer::T_OR)) {
            $this->expect(SqlLexer::T_REPLACE);
            $replace = true;
        }

        $this->expect(SqlLexer::T_INTO);

        return $this->parseInsertBody($replace);
    }

    private function parseReplaceStatement(): InsertStatement
    {
        $this->expect(SqlLexer::T_REPLACE);
        $this->expect(SqlLexer::T_INTO);

        return $this->parseInsertBody(true);
    }

    private function parseInsertBody(bool $replace): InsertStatement
    {
        $stmt = new InsertStatement();
        $stmt->replace = $replace;
        $stmt->table = $this->parseIdentifier();

        if ($this->match(SqlLexer::T_LPAREN)) {
            $stmt->columns = [];
            do {
                $stmt->columns[] = $this->parseIdentifier();
            } while ($this->match(SqlLexer::T_COMMA));
            $this->expect(SqlLexer::T_RPAREN);
        }

        // INSERT INTO table SELECT ... syntax
        if ($this->current()['type'] === SqlLexer::T_SELECT) {
            $stmt->select = $this->parseSelectStatement();
            return $stmt;
        }

        $this->expect(SqlLexer::T_VALUES);

        do {
            $this->expect(SqlLexer::T_LPAREN);
            $values = [];
            do {
                $values[] = $this->parseExpression();
            } while ($this->match(SqlLexer::T_COMMA));
            $this->expect(SqlLexer::T_RPAREN);

            $stmt->values[] = $values;
        } while ($this->match(SqlLexer::T_COMMA));

        return $stmt;
    }

    private function parseUpdateStatement(): UpdateStatement
    {
        $this->expect(SqlLexer::T_UPDATE);
        $stmt = new UpdateStatement();

        $stmt->table = $this->parseIdentifier();

        $this->expect(SqlLexer::T_SET);
        do {
            $col = $this->parseIdentifier();
            $this->expectOp('=');
            $val = $this->parseExpression();
            $stmt->updates[] = ['column' => $col, 'value' => $val];
        } while ($this->match(SqlLexer::T_COMMA));

        if ($this->match(SqlLexer::T_WHERE)) {
            $stmt->where = $this->parseExpression();
        }

        return $stmt;
    }

    private function parseDeleteStatement(): DeleteStatement
    {
        $this->expect(SqlLexer::T_DELETE);
        $this->expect(SqlLexer::T_FROM);

        $stmt = new DeleteStatement();
        $stmt->table = $this->parseIdentifier();

        if ($this->match(SqlLexer::T_WHERE)) {
            $stmt->where = $this->parseExpression();
        }

        return $stmt;
    }

    /**
     * Parse WITH statement at top level (with EOF validation)
     */
    private function parseWithStatement(): WithStatement
    {
        $stmt = $this->parseWithBody();

        if ($this->current()['type'] !== SqlLexer::T_EOF) {
            throw new SqlSyntaxException(
                "Unexpected trailing input",
                $this->sql,
                $this->current()['pos']
            );
        }

        return $stmt;
    }

    /**
     * Parse a single CTE definition: name [(columns)] AS (query)
     */
    private function parseCteDefinition(): array
    {
        $nameToken = $this->expect(SqlLexer::T_IDENTIFIER);
        $name = $nameToken['value'];

        // Optional column list: cte_name(col1, col2)
        $columns = null;
        if ($this->match(SqlLexer::T_LPAREN)) {
            $columns = [];
            do {
                $colToken = $this->expect(SqlLexer::T_IDENTIFIER);
                $columns[] = $colToken['value'];
            } while ($this->match(SqlLexer::T_COMMA));
            $this->expect(SqlLexer::T_RPAREN);
        }

        $this->expect(SqlLexer::T_AS);
        $this->expect(SqlLexer::T_LPAREN);

        // Parse the CTE query (supports UNION/INTERSECT/EXCEPT)
        $query = $this->parseSelectOrUnion();

        $this->expect(SqlLexer::T_RPAREN);

        return [
            'name' => $name,
            'columns' => $columns,
            'query' => $query,
        ];
    }

    private function parseColumnList(): array
    {
        $columns = [];
        do {
            if ($this->match(SqlLexer::T_STAR)) {
                $columns[] = new ColumnNode(new IdentifierNode('*'));
            } else {
                $expr = $this->parseExpression();
                $alias = null;

                if ($this->match(SqlLexer::T_AS)) {
                    $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                    $alias = $aliasToken['value'];
                } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                    // Implicit alias
                    $this->pos++;
                    $alias = $this->tokens[$this->pos - 1]['value'];
                }

                $columns[] = new ColumnNode($expr, $alias);
            }
        } while ($this->match(SqlLexer::T_COMMA));

        return $columns;
    }

    // --- Expression Parsing (Precedence Handling) ---

    private function parseExpression(): ASTNode
    {
        $left = $this->parseAnd();
        while ($this->match(SqlLexer::T_OR)) {
            $right = $this->parseAnd();
            $left = new BinaryOperation($left, 'OR', $right);
        }
        return $left;
    }

    private function parseAnd(): ASTNode
    {
        $left = $this->parseComparison();
        while ($this->match(SqlLexer::T_AND)) {
            $right = $this->parseComparison();
            $left = new BinaryOperation($left, 'AND', $right);
        }
        return $left;
    }

    private const COMPARISON_OPS = ['=', '!=', '<>', '<', '<=', '>', '>='];

    private function parseComparison(): ASTNode
    {
        // Handle NOT EXISTS
        if ($this->match(SqlLexer::T_NOT)) {
            if ($this->match(SqlLexer::T_EXISTS)) {
                return $this->parseExistsOperation(negated: true);
            }
            // Handle generic NOT (e.g., NOT is_active, NOT (a = b))
            $expr = $this->parseComparison();
            return new UnaryOperation('NOT', $expr);
        }

        // Handle EXISTS (SELECT ...)
        if ($this->match(SqlLexer::T_EXISTS)) {
            return $this->parseExistsOperation(negated: false);
        }

        $left = $this->parseAdditive();

        // Handle comparison operators only (not arithmetic)
        if ($this->current()['type'] === SqlLexer::T_OP &&
            in_array($this->current()['value'], self::COMPARISON_OPS, true)) {
            $op = $this->current()['value'];
            $this->pos++;

            // Check for ALL/ANY/SOME quantifier
            $quantifier = null;
            if ($this->match(SqlLexer::T_ALL)) {
                $quantifier = 'ALL';
            } elseif ($this->match(SqlLexer::T_ANY) || $this->match(SqlLexer::T_SOME)) {
                $quantifier = 'ANY';  // SOME is synonym for ANY
            }

            if ($quantifier !== null) {
                // Must be followed by subquery (supports UNION/INTERSECT/EXCEPT)
                $this->expect(SqlLexer::T_LPAREN);
                $subquery = $this->parseSelectOrUnion();
                $this->expect(SqlLexer::T_RPAREN);
                return new AST\QuantifiedComparisonNode($left, $op, $quantifier, new SubqueryNode($subquery));
            }

            $right = $this->parseAdditive();
            return new BinaryOperation($left, $op, $right);
        }

        // Handle IS NULL / IS NOT NULL
        if ($this->match(SqlLexer::T_IS)) {
            $negated = $this->match(SqlLexer::T_NOT);
            $this->expect(SqlLexer::T_NULL);
            return new IsNullOperation($left, $negated);
        }

        // Handle NOT IN / NOT LIKE / NOT BETWEEN
        if ($this->match(SqlLexer::T_NOT)) {
            if ($this->match(SqlLexer::T_IN)) {
                return $this->parseInOperation($left, negated: true);
            }
            if ($this->match(SqlLexer::T_LIKE)) {
                $pattern = $this->parseAdditive();
                return new LikeOperation($left, $pattern, negated: true);
            }
            if ($this->match(SqlLexer::T_BETWEEN)) {
                return $this->parseBetweenOperation($left, negated: true);
            }
            throw new SqlSyntaxException(
                "Expected IN, LIKE, or BETWEEN after NOT",
                $this->sql,
                $this->current()['pos']
            );
        }

        // Handle IN clause
        if ($this->match(SqlLexer::T_IN)) {
            return $this->parseInOperation($left, negated: false);
        }

        // Handle LIKE clause
        if ($this->match(SqlLexer::T_LIKE)) {
            $pattern = $this->parseAdditive();
            return new LikeOperation($left, $pattern, negated: false);
        }

        // Handle BETWEEN clause
        if ($this->match(SqlLexer::T_BETWEEN)) {
            return $this->parseBetweenOperation($left, negated: false);
        }

        return $left;
    }

    private function parseBetweenOperation(ASTNode $left, bool $negated): BetweenOperation
    {
        $low = $this->parseAdditive();
        $this->expect(SqlLexer::T_AND);
        $high = $this->parseAdditive();
        return new BetweenOperation($left, $low, $high, $negated);
    }

    private function parseInOperation(ASTNode $left, bool $negated): InOperation
    {
        // SQLite shorthand: IN table_name means IN (SELECT * FROM table_name)
        if ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
            $tableName = $this->current()['value'];
            $this->pos++;
            // Build: SELECT * FROM table_name
            $select = new SelectStatement();
            $select->columns = [new ColumnNode(new IdentifierNode('*'))];
            $select->from = new IdentifierNode($tableName);
            return new InOperation($left, new SubqueryNode($select), $negated);
        }

        $this->expect(SqlLexer::T_LPAREN);

        // Check for Subquery (supports UNION/INTERSECT/EXCEPT)
        if ($this->current()['type'] === SqlLexer::T_SELECT) {
            $subquery = $this->parseSelectOrUnion();
            $this->expect(SqlLexer::T_RPAREN);
            return new InOperation($left, new SubqueryNode($subquery), $negated);
        } else {
            // Simple List - only scalar/additive expressions, not comparisons or boolean
            // Handle empty list: IN () is valid SQL (always false)
            $values = [];
            if ($this->current()['type'] !== SqlLexer::T_RPAREN) {
                do {
                    $values[] = $this->parseAdditive();
                } while ($this->match(SqlLexer::T_COMMA));
            }
            $this->expect(SqlLexer::T_RPAREN);
            return new InOperation($left, $values, $negated);
        }
    }

    private function parseExistsOperation(bool $negated): AST\ExistsOperation
    {
        $this->expect(SqlLexer::T_LPAREN);
        $subquery = $this->parseSelectOrUnion();
        $this->expect(SqlLexer::T_RPAREN);
        return new AST\ExistsOperation(new SubqueryNode($subquery), $negated);
    }

    /**
     * Parse a derived table: (SELECT ...) in FROM position
     * Supports UNION/INTERSECT/EXCEPT inside the subquery
     */
    private function parseDerivedTable(): SubqueryNode
    {
        $this->expect(SqlLexer::T_LPAREN);
        $subquery = $this->parseSelectOrUnion();
        $this->expect(SqlLexer::T_RPAREN);
        return new SubqueryNode($subquery);
    }

    private function parseAdditive(): ASTNode
    {
        $left = $this->parseMultiplicative();

        while ($this->current()['type'] === SqlLexer::T_OP &&
               in_array($this->current()['value'], ['+', '-'], true)) {
            $op = $this->current()['value'];
            $this->pos++;
            $right = $this->parseMultiplicative();
            $left = new BinaryOperation($left, $op, $right);
        }

        return $left;
    }

    private function parseMultiplicative(): ASTNode
    {
        $left = $this->parseConcatenation();

        while (true) {
            $token = $this->current();
            if ($token['type'] === SqlLexer::T_STAR) {
                $this->pos++;
                $right = $this->parseConcatenation();
                $left = new BinaryOperation($left, '*', $right);
            } elseif ($token['type'] === SqlLexer::T_OP && $token['value'] === '/') {
                $this->pos++;
                $right = $this->parseConcatenation();
                $left = new BinaryOperation($left, '/', $right);
            } elseif ($token['type'] === SqlLexer::T_OP && $token['value'] === '%') {
                $this->pos++;
                $right = $this->parseConcatenation();
                $left = new BinaryOperation($left, '%', $right);
            } else {
                break;
            }
        }

        return $left;
    }

    private function parseConcatenation(): ASTNode
    {
        $left = $this->parseAtom();

        while ($this->current()['type'] === SqlLexer::T_OP &&
               $this->current()['value'] === '||') {
            $this->pos++;
            $right = $this->parseAtom();
            $left = new BinaryOperation($left, '||', $right);
        }

        return $left;
    }

    private function parseAtom(): ASTNode
    {
        $token = $this->current();

        // Handle Unary Minus (Negative numbers)
        if ($token['type'] === SqlLexer::T_OP && $token['value'] === '-') {
            $this->pos++;
            $expr = $this->parseAtom();
            return new UnaryOperation('-', $expr);
        }

        // Handle Unary Plus
        if ($token['type'] === SqlLexer::T_OP && $token['value'] === '+') {
            $this->pos++;
            $expr = $this->parseAtom();
            return new UnaryOperation('+', $expr);
        }

        // Handle Parentheses and Scalar Subqueries
        if ($this->match(SqlLexer::T_LPAREN)) {
            // Check for scalar subquery: (SELECT ...), (SELECT ... UNION ...), (WITH ... SELECT ...)
            $currentType = $this->current()['type'];
            if ($currentType === SqlLexer::T_SELECT || $currentType === SqlLexer::T_WITH) {
                $subquery = $this->parseSelectOrUnion();
                $this->expect(SqlLexer::T_RPAREN);
                return new SubqueryNode($subquery);
            }
            $expr = $this->parseExpression();
            $this->expect(SqlLexer::T_RPAREN);
            return $expr;
        }

        // Handle CASE expressions
        if ($this->match(SqlLexer::T_CASE)) {
            return $this->parseCaseExpression();
        }

        // Handle niladic functions (SQL standard functions without parentheses)
        if ($this->match(SqlLexer::T_CURRENT_DATE)) {
            return new AST\NiladicFunctionNode('CURRENT_DATE');
        }
        if ($this->match(SqlLexer::T_CURRENT_TIME)) {
            return new AST\NiladicFunctionNode('CURRENT_TIME');
        }
        if ($this->match(SqlLexer::T_CURRENT_TIMESTAMP)) {
            return new AST\NiladicFunctionNode('CURRENT_TIMESTAMP');
        }

        // Handle Identifiers, Qualified Names (table.column), and Function Calls
        if ($token['type'] === SqlLexer::T_IDENTIFIER) {
            $next = $this->peek();
            if ($next['type'] === SqlLexer::T_LPAREN) {
                return $this->parseFunctionCall();
            }

            // Delegate to parseIdentifier for qualified names (DRY)
            return $this->parseIdentifier();
        }

        // Handle Literals
        if ($this->match(SqlLexer::T_STRING)) {
            return new LiteralNode($token['value'], 'string');
        }

        if ($this->match(SqlLexer::T_NUMBER)) {
            return new LiteralNode($token['value'], 'number');
        }

        if ($this->match(SqlLexer::T_NULL)) {
            return new LiteralNode(null, 'null');
        }

        if ($this->match(SqlLexer::T_TRUE)) {
            return new LiteralNode(true, 'boolean');
        }

        if ($this->match(SqlLexer::T_FALSE)) {
            return new LiteralNode(false, 'boolean');
        }

        // Handle Placeholders
        if ($this->match(SqlLexer::T_PLACEHOLDER)) {
            return new PlaceholderNode($token['value']);
        }

        throw new SqlSyntaxException(
            "Unexpected token in expression: " . $token['type'],
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Parse a number or placeholder (for LIMIT/OFFSET/FETCH)
     */
    private function parseNumberOrPlaceholder(string $context): ASTNode
    {
        $token = $this->current();
        if ($token['type'] === SqlLexer::T_NUMBER) {
            $this->pos++;
            return new LiteralNode($token['value'], 'number');
        }
        if ($token['type'] === SqlLexer::T_PLACEHOLDER) {
            $this->pos++;
            return new PlaceholderNode($token['value']);
        }
        throw new SqlSyntaxException(
            "$context requires a number or placeholder",
            $this->sql,
            $token['pos'] ?? strlen($this->sql)
        );
    }

    private function parseFunctionCall(): FunctionCallNode|WindowFunctionNode
    {
        $nameToken = $this->expect(SqlLexer::T_IDENTIFIER);
        $this->expect(SqlLexer::T_LPAREN);
        $args = [];
        $distinct = false;

        // Handle DISTINCT inside function: COUNT(DISTINCT col)
        if ($this->match(SqlLexer::T_DISTINCT)) {
            $distinct = true;
        }

        if ($this->current()['type'] !== SqlLexer::T_RPAREN) {
            do {
                if ($this->match(SqlLexer::T_STAR)) {
                    $args[] = new IdentifierNode('*');
                } else {
                    $args[] = $this->parseExpression();
                }
            } while ($this->match(SqlLexer::T_COMMA));
        }

        $this->expect(SqlLexer::T_RPAREN);
        $func = new FunctionCallNode($nameToken['value'], $args, $distinct);

        // Check for OVER clause (window function)
        if ($this->match(SqlLexer::T_OVER)) {
            return $this->parseWindowSpec($func);
        }

        return $func;
    }

    /**
     * Parse OVER (PARTITION BY ... ORDER BY ...) clause
     */
    private function parseWindowSpec(FunctionCallNode $func): WindowFunctionNode
    {
        $this->expect(SqlLexer::T_LPAREN);

        $partitionBy = [];
        $orderBy = [];

        // PARTITION BY
        if ($this->match(SqlLexer::T_PARTITION)) {
            $this->expect(SqlLexer::T_BY);
            do {
                $partitionBy[] = $this->parseExpression();
            } while ($this->match(SqlLexer::T_COMMA));
        }

        // ORDER BY
        if ($this->match(SqlLexer::T_ORDER)) {
            $this->expect(SqlLexer::T_BY);
            do {
                $expr = $this->parseExpression();
                $direction = 'ASC';
                if ($this->match(SqlLexer::T_ASC)) {
                    $direction = 'ASC';
                } elseif ($this->match(SqlLexer::T_DESC)) {
                    $direction = 'DESC';
                }
                $orderBy[] = ['expr' => $expr, 'direction' => $direction];
            } while ($this->match(SqlLexer::T_COMMA));
        }

        $this->expect(SqlLexer::T_RPAREN);

        return new WindowFunctionNode($func, $partitionBy, $orderBy);
    }

    /**
     * Parse CASE expression
     *
     * Two forms:
     * - Simple: CASE expr WHEN value THEN result [WHEN ...] [ELSE result] END
     * - Searched: CASE WHEN condition THEN result [WHEN ...] [ELSE result] END
     */
    private function parseCaseExpression(): CaseWhenNode
    {
        // Check for simple vs searched CASE
        $operand = null;
        if ($this->current()['type'] !== SqlLexer::T_WHEN) {
            // Simple CASE: CASE expr WHEN ...
            $operand = $this->parseExpression();
        }

        // Parse WHEN clauses
        $whenClauses = [];
        while ($this->match(SqlLexer::T_WHEN)) {
            $when = $this->parseExpression();
            $this->expect(SqlLexer::T_THEN);
            $then = $this->parseExpression();
            $whenClauses[] = ['when' => $when, 'then' => $then];
        }

        if (empty($whenClauses)) {
            throw new SqlSyntaxException(
                "CASE requires at least one WHEN clause",
                $this->sql,
                $this->current()['pos']
            );
        }

        // Optional ELSE clause
        $elseResult = null;
        if ($this->match(SqlLexer::T_ELSE)) {
            $elseResult = $this->parseExpression();
        }

        // Required END
        $this->expect(SqlLexer::T_END);

        return new CaseWhenNode($operand, $whenClauses, $elseResult);
    }

    private function parseIdentifier(): IdentifierNode
    {
        $token = $this->expect(SqlLexer::T_IDENTIFIER);
        $parts = [$token['value']];

        // Handle qualified identifiers (schema.table.column or table.*)
        while ($this->current()['type'] === SqlLexer::T_DOT) {
            $this->pos++; // consume dot
            $nextToken = $this->current();

            if ($nextToken['type'] === SqlLexer::T_IDENTIFIER) {
                $parts[] = $nextToken['value'];
                $this->pos++;
            } elseif ($nextToken['type'] === SqlLexer::T_STAR) {
                // Handle table.* wildcard
                $parts[] = '*';
                $this->pos++;
                break; // table.* is terminal - no further qualification allowed
            } else {
                throw new SqlSyntaxException(
                    "Expected identifier or * after dot",
                    $this->sql,
                    $nextToken['pos']
                );
            }
        }

        return new IdentifierNode($parts);
    }

    // --- JOIN Parsing ---

    private const JOIN_TYPE_TOKENS = [
        SqlLexer::T_JOIN,
        SqlLexer::T_LEFT,
        SqlLexer::T_RIGHT,
        SqlLexer::T_INNER,
        SqlLexer::T_FULL,
        SqlLexer::T_CROSS,
    ];

    private function isJoinStart(): bool
    {
        return in_array($this->current()['type'], self::JOIN_TYPE_TOKENS, true);
    }

    private function parseJoin(): JoinNode
    {
        $joinType = $this->parseJoinType();

        // Check for derived table in JOIN
        if ($this->current()['type'] === SqlLexer::T_LPAREN) {
            $table = $this->parseDerivedTable();
            // Derived tables require an alias
            $alias = null;
            if ($this->match(SqlLexer::T_AS)) {
                $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                $alias = $aliasToken['value'];
            } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                $alias = $this->current()['value'];
                $this->pos++;
            } else {
                throw new \RuntimeException("Derived table in JOIN requires an alias");
            }
        } else {
            $table = $this->parseIdentifier();

            // Optional alias
            $alias = null;
            if ($this->match(SqlLexer::T_AS)) {
                $aliasToken = $this->expect(SqlLexer::T_IDENTIFIER);
                $alias = $aliasToken['value'];
            } elseif ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
                // Implicit alias - but be careful not to consume ON
                $alias = $this->current()['value'];
                $this->pos++;
            }
        }

        // ON condition (required for all except CROSS JOIN)
        $condition = null;
        if ($this->match(SqlLexer::T_ON)) {
            $condition = $this->parseExpression();
        } elseif ($joinType !== 'CROSS') {
            throw new SqlSyntaxException(
                "Expected ON after JOIN",
                $this->sql,
                $this->current()['pos']
            );
        }

        return new JoinNode($joinType, $table, $condition, $alias);
    }

    private function parseJoinType(): string
    {
        // Simple: JOIN or INNER JOIN
        if ($this->match(SqlLexer::T_JOIN)) {
            return 'INNER';
        }

        if ($this->match(SqlLexer::T_INNER)) {
            $this->expect(SqlLexer::T_JOIN);
            return 'INNER';
        }

        // LEFT [OUTER] JOIN
        if ($this->match(SqlLexer::T_LEFT)) {
            $this->match(SqlLexer::T_OUTER); // optional
            $this->expect(SqlLexer::T_JOIN);
            return 'LEFT';
        }

        // RIGHT [OUTER] JOIN
        if ($this->match(SqlLexer::T_RIGHT)) {
            $this->match(SqlLexer::T_OUTER); // optional
            $this->expect(SqlLexer::T_JOIN);
            return 'RIGHT';
        }

        // FULL [OUTER] JOIN
        if ($this->match(SqlLexer::T_FULL)) {
            $this->match(SqlLexer::T_OUTER); // optional
            $this->expect(SqlLexer::T_JOIN);
            return 'FULL';
        }

        // CROSS JOIN
        if ($this->match(SqlLexer::T_CROSS)) {
            $this->expect(SqlLexer::T_JOIN);
            return 'CROSS';
        }

        throw new SqlSyntaxException(
            "Expected JOIN keyword",
            $this->sql,
            $this->current()['pos']
        );
    }

    /**
     * Parse SQL and bind parameters, returning the AST
     *
     * Convenience method that parses SQL and binds parameter values to
     * PlaceholderNodes in a single call.
     *
     * @param string $sql SQL to parse
     * @param array $params Parameters to bind (positional or named)
     * @return ASTNode Parsed AST with bound PlaceholderNodes
     */
    public static function parseWithParams(string $sql, array $params = []): ASTNode
    {
        // Get AST from cache (may be shared)
        $wasCached = false;
        $ast = self::parseCached($sql, $wasCached);

        // If we have params to bind, we'll mutate the AST - must clone if shared
        if (!empty($params)) {
            if ($wasCached) {
                $ast = $ast->deepClone();
            }
            $paramsCopy = $params;
            self::bindParams($ast, $paramsCopy);
        }

        return $ast;
    }

    /**
     * Parse SQL with caching - returns AST from cache (possibly shared)
     *
     * Returns the cached AST directly. Callers should treat it as shared
     * (read-only) and deep-clone before mutation. This avoids cloning
     * overhead for read-only query execution paths.
     *
     * For queries that will be mutated, use the instance parse() method
     * which always returns a fresh AST.
     *
     * @param string $sql SQL query to parse
     * @param bool &$wasCached Output: true if AST was from cache
     * @return ASTNode Parsed AST (may be shared - treat as read-only)
     * @throws SqlSyntaxException
     */
    public static function parseCached(string $sql, bool &$wasCached = false): ASTNode
    {
        // Check cache first
        if (isset(self::$astCache[$sql])) {
            $wasCached = true;
            return self::$astCache[$sql];
        }

        $wasCached = false;

        // Parse fresh
        $parser = new self();
        $ast = $parser->parse($sql);

        // Evict oldest entry if cache is full
        if (count(self::$astCache) >= self::CACHE_MAX_SIZE) {
            array_shift(self::$astCache);
        }

        // Cache and return (caller should treat as shared)
        self::$astCache[$sql] = $ast;
        return $ast;
    }

    /**
     * Clear the AST cache (for testing or memory management)
     */
    public static function clearCache(): void
    {
        self::$astCache = [];
    }

    // =========================================================================
    // DDL Parsing (CREATE, DROP, etc.)
    // =========================================================================

    /**
     * Parse CREATE statement (TABLE, INDEX, VIEW)
     */
    private function parseCreateStatement(): ASTNode
    {
        $this->expect(SqlLexer::T_CREATE);
        $token = $this->current();

        // CREATE UNIQUE INDEX
        if ($token['type'] === SqlLexer::T_UNIQUE) {
            $this->pos++;
            $this->expect(SqlLexer::T_INDEX);
            return $this->parseCreateIndex(unique: true);
        }

        if ($token['type'] === SqlLexer::T_TABLE) {
            $this->pos++; // consume TABLE token
            return $this->parseCreateTable(temporary: false);
        }

        // CREATE TEMPORARY TABLE or CREATE TEMP TABLE
        if ($token['type'] === SqlLexer::T_TEMPORARY || $token['type'] === SqlLexer::T_TEMP) {
            $this->pos++;
            $this->expect(SqlLexer::T_TABLE);
            return $this->parseCreateTable(temporary: true);
        }

        if ($token['type'] === SqlLexer::T_INDEX) {
            $this->pos++; // consume INDEX
            return $this->parseCreateIndex(unique: false);
        }

        throw new SqlSyntaxException(
            "Expected TABLE or INDEX after CREATE",
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Parse CREATE TABLE statement
     *
     * Syntax: CREATE [TEMPORARY|TEMP] TABLE [IF NOT EXISTS] name (column_def, ..., [constraint, ...])
     */
    private function parseCreateTable(bool $temporary = false): CreateTableStatement
    {
        $stmt = new CreateTableStatement();
        $stmt->temporary = $temporary;

        // IF NOT EXISTS
        if ($this->match(SqlLexer::T_IF)) {
            $this->expect(SqlLexer::T_NOT);
            $this->expectKeyword('EXISTS');
            $stmt->ifNotExists = true;
        }

        // Table name
        $stmt->table = $this->parseIdentifier();

        // Column definitions and constraints
        $this->expect(SqlLexer::T_LPAREN);

        do {
            // Check for table-level constraint
            $token = $this->current();
            if (in_array($token['type'], [SqlLexer::T_PRIMARY, SqlLexer::T_UNIQUE, SqlLexer::T_FOREIGN, SqlLexer::T_CHECK, SqlLexer::T_CONSTRAINT])) {
                $stmt->constraints[] = $this->parseTableConstraint();
            } else {
                $stmt->columns[] = $this->parseColumnDefinition();
            }
        } while ($this->match(SqlLexer::T_COMMA));

        $this->expect(SqlLexer::T_RPAREN);

        return $stmt;
    }

    /**
     * Parse column definition
     */
    private function parseColumnDefinition(): ColumnDefinition
    {
        $col = new ColumnDefinition();

        // Column name
        $col->name = $this->expectIdentifierName();

        // Data type (optional in SQLite)
        if ($this->current()['type'] === SqlLexer::T_IDENTIFIER) {
            $col->dataType = strtoupper($this->current()['value']);
            $this->pos++;

            // Type parameters: VARCHAR(255), DECIMAL(10,2)
            if ($this->match(SqlLexer::T_LPAREN)) {
                $col->length = (int) $this->current()['value'];
                $this->expect(SqlLexer::T_NUMBER);

                if ($this->match(SqlLexer::T_COMMA)) {
                    $col->scale = (int) $this->current()['value'];
                    $this->expect(SqlLexer::T_NUMBER);
                    $col->precision = $col->length;
                    $col->length = null;
                }

                $this->expect(SqlLexer::T_RPAREN);
            }
        }

        // Column constraints
        while ($this->parseColumnConstraint($col)) {
            // Keep parsing constraints
        }

        return $col;
    }

    /**
     * Parse a single column constraint
     * @return bool True if a constraint was parsed
     */
    private function parseColumnConstraint(ColumnDefinition $col): bool
    {
        $token = $this->current();

        // PRIMARY KEY
        if ($token['type'] === SqlLexer::T_PRIMARY) {
            $this->pos++;
            $this->expect(SqlLexer::T_KEY);
            $col->primaryKey = true;

            // AUTOINCREMENT
            if ($this->match(SqlLexer::T_AUTOINCREMENT)) {
                $col->autoIncrement = true;
            }
            return true;
        }

        // NOT NULL
        if ($token['type'] === SqlLexer::T_NOT) {
            $this->pos++;
            $this->expect(SqlLexer::T_NULL);
            $col->notNull = true;
            return true;
        }

        // NULL (explicit nullable)
        if ($token['type'] === SqlLexer::T_NULL) {
            $this->pos++;
            return true;
        }

        // UNIQUE
        if ($token['type'] === SqlLexer::T_UNIQUE) {
            $this->pos++;
            $col->unique = true;
            return true;
        }

        // DEFAULT value
        if ($token['type'] === SqlLexer::T_DEFAULT) {
            $this->pos++;
            $col->default = $this->parseDefaultValue();
            return true;
        }

        // REFERENCES table(column)
        if ($token['type'] === SqlLexer::T_REFERENCES) {
            $this->pos++;
            $col->references = $this->expectIdentifierName();
            if ($this->match(SqlLexer::T_LPAREN)) {
                $col->referencesColumn = $this->expectIdentifierName();
                $this->expect(SqlLexer::T_RPAREN);
            }
            return true;
        }

        // CHECK (expression)
        if ($token['type'] === SqlLexer::T_CHECK) {
            $this->pos++;
            $this->expect(SqlLexer::T_LPAREN);
            // Skip the check expression for now (complex parsing)
            $depth = 1;
            while ($depth > 0 && $this->current()['type'] !== SqlLexer::T_EOF) {
                if ($this->match(SqlLexer::T_LPAREN)) $depth++;
                elseif ($this->match(SqlLexer::T_RPAREN)) $depth--;
                else $this->pos++;
            }
            return true;
        }

        // AUTOINCREMENT (standalone, SQLite style)
        if ($token['type'] === SqlLexer::T_AUTOINCREMENT) {
            $this->pos++;
            $col->autoIncrement = true;
            return true;
        }

        return false;
    }

    /**
     * Parse default value expression
     */
    private function parseDefaultValue(): ASTNode
    {
        $token = $this->current();

        // NULL
        if ($this->match(SqlLexer::T_NULL)) {
            return new LiteralNode(null, 'null');
        }

        // String literal
        if ($token['type'] === SqlLexer::T_STRING) {
            $this->pos++;
            return new LiteralNode($token['value'], 'string');
        }

        // Number literal
        if ($token['type'] === SqlLexer::T_NUMBER) {
            $this->pos++;
            return new LiteralNode($token['value'], 'number');
        }

        // TRUE/FALSE
        if ($this->match(SqlLexer::T_TRUE)) {
            return new LiteralNode(true, 'boolean');
        }
        if ($this->match(SqlLexer::T_FALSE)) {
            return new LiteralNode(false, 'boolean');
        }

        // CURRENT_TIMESTAMP, etc.
        if ($token['type'] === SqlLexer::T_CURRENT_TIMESTAMP) {
            $this->pos++;
            return new LiteralNode('CURRENT_TIMESTAMP', 'keyword');
        }

        // Parenthesized expression
        if ($this->match(SqlLexer::T_LPAREN)) {
            $expr = $this->parseExpression();
            $this->expect(SqlLexer::T_RPAREN);
            return $expr;
        }

        throw new SqlSyntaxException(
            "Expected default value",
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Parse table-level constraint
     */
    private function parseTableConstraint(): TableConstraint
    {
        $constraint = new TableConstraint();

        // CONSTRAINT name
        if ($this->match(SqlLexer::T_CONSTRAINT)) {
            $constraint->name = $this->expectIdentifierName();
        }

        $token = $this->current();

        // PRIMARY KEY (col1, col2, ...)
        if ($token['type'] === SqlLexer::T_PRIMARY) {
            $this->pos++;
            $this->expect(SqlLexer::T_KEY);
            $constraint->constraintType = 'PRIMARY KEY';
            $constraint->columns = $this->parseConstraintColumnList();
            return $constraint;
        }

        // UNIQUE (col1, col2, ...)
        if ($token['type'] === SqlLexer::T_UNIQUE) {
            $this->pos++;
            $constraint->constraintType = 'UNIQUE';
            $constraint->columns = $this->parseConstraintColumnList();
            return $constraint;
        }

        // FOREIGN KEY (col1, ...) REFERENCES table(col1, ...)
        if ($token['type'] === SqlLexer::T_FOREIGN) {
            $this->pos++;
            $this->expect(SqlLexer::T_KEY);
            $constraint->constraintType = 'FOREIGN KEY';
            $constraint->columns = $this->parseConstraintColumnList();

            $this->expect(SqlLexer::T_REFERENCES);
            $constraint->references = $this->expectIdentifierName();
            $constraint->referencesColumns = $this->parseConstraintColumnList();

            // ON DELETE / ON UPDATE
            while ($this->match(SqlLexer::T_ON)) {
                if ($this->match(SqlLexer::T_DELETE)) {
                    $constraint->onDelete = $this->parseReferentialAction();
                } elseif ($this->match(SqlLexer::T_UPDATE)) {
                    $constraint->onUpdate = $this->parseReferentialAction();
                }
            }

            return $constraint;
        }

        // CHECK (expression)
        if ($token['type'] === SqlLexer::T_CHECK) {
            $this->pos++;
            $constraint->constraintType = 'CHECK';
            $this->expect(SqlLexer::T_LPAREN);
            $constraint->checkExpression = $this->parseExpression();
            $this->expect(SqlLexer::T_RPAREN);
            return $constraint;
        }

        throw new SqlSyntaxException(
            "Expected constraint type (PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK)",
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Parse (col1, col2, ...) list for constraints
     */
    private function parseConstraintColumnList(): array
    {
        $this->expect(SqlLexer::T_LPAREN);
        $columns = [];
        do {
            $columns[] = $this->expectIdentifierName();
        } while ($this->match(SqlLexer::T_COMMA));
        $this->expect(SqlLexer::T_RPAREN);
        return $columns;
    }

    /**
     * Parse referential action (CASCADE, RESTRICT, SET NULL, etc.)
     */
    private function parseReferentialAction(): string
    {
        if ($this->match(SqlLexer::T_CASCADE)) return 'CASCADE';
        if ($this->match(SqlLexer::T_RESTRICT)) return 'RESTRICT';
        if ($this->match(SqlLexer::T_NO)) {
            $this->expect(SqlLexer::T_ACTION);
            return 'NO ACTION';
        }
        if ($this->match(SqlLexer::T_SET)) {
            if ($this->match(SqlLexer::T_NULL)) return 'SET NULL';
            if ($this->match(SqlLexer::T_DEFAULT)) return 'SET DEFAULT';
        }

        throw new SqlSyntaxException(
            "Expected referential action",
            $this->sql,
            $this->current()['pos']
        );
    }

    /**
     * Parse CREATE INDEX statement
     */
    private function parseCreateIndex(bool $unique): CreateIndexStatement
    {
        $stmt = new CreateIndexStatement();
        $stmt->unique = $unique;

        // IF NOT EXISTS
        if ($this->match(SqlLexer::T_IF)) {
            $this->expect(SqlLexer::T_NOT);
            $this->expectKeyword('EXISTS');
            $stmt->ifNotExists = true;
        }

        // Index name
        $stmt->name = $this->expectIdentifierName();

        // ON table
        $this->expect(SqlLexer::T_ON);
        $stmt->table = $this->parseIdentifier();

        // (col1 [ASC|DESC], col2 [ASC|DESC], ...)
        $this->expect(SqlLexer::T_LPAREN);
        do {
            $col = new IndexColumn();
            $col->name = $this->expectIdentifierName();

            if ($this->match(SqlLexer::T_ASC)) {
                $col->order = 'ASC';
            } elseif ($this->match(SqlLexer::T_DESC)) {
                $col->order = 'DESC';
            }

            $stmt->columns[] = $col;
        } while ($this->match(SqlLexer::T_COMMA));
        $this->expect(SqlLexer::T_RPAREN);

        return $stmt;
    }

    /**
     * Parse DROP statement (TABLE, INDEX)
     */
    private function parseDropStatement(): ASTNode
    {
        $this->expect(SqlLexer::T_DROP);
        $token = $this->current();

        if ($token['type'] === SqlLexer::T_TABLE) {
            return $this->parseDropTable();
        }

        if ($token['type'] === SqlLexer::T_INDEX) {
            $this->pos++; // consume INDEX
            return $this->parseDropIndex();
        }

        throw new SqlSyntaxException(
            "Expected TABLE or INDEX after DROP",
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Parse DROP TABLE statement
     */
    private function parseDropTable(): DropTableStatement
    {
        $this->expect(SqlLexer::T_TABLE);
        $stmt = new DropTableStatement();

        // IF EXISTS
        if ($this->match(SqlLexer::T_IF)) {
            $this->expectKeyword('EXISTS');
            $stmt->ifExists = true;
        }

        $stmt->table = $this->parseIdentifier();
        return $stmt;
    }

    /**
     * Parse DROP INDEX statement
     */
    private function parseDropIndex(): DropIndexStatement
    {
        // INDEX token already consumed by parseDropStatement
        $stmt = new DropIndexStatement();

        // IF EXISTS
        if ($this->match(SqlLexer::T_IF)) {
            $this->expectKeyword('EXISTS');
            $stmt->ifExists = true;
        }

        $stmt->name = $this->expectIdentifierName();

        // ON table (optional, for MySQL compatibility)
        if ($this->match(SqlLexer::T_ON)) {
            $stmt->table = $this->parseIdentifier();
        }

        return $stmt;
    }

    /**
     * Expect a specific keyword (may be token or identifier)
     */
    private function expectKeyword(string $keyword): void
    {
        $token = $this->current();
        $upper = strtoupper($keyword);

        // Check if it matches as a token type
        if ($token['type'] === $upper) {
            $this->pos++;
            return;
        }

        // Check if it's an identifier with the keyword name
        if ($token['type'] === SqlLexer::T_IDENTIFIER && strtoupper($token['value']) === $upper) {
            $this->pos++;
            return;
        }

        throw new SqlSyntaxException(
            "Expected $keyword",
            $this->sql,
            $token['pos']
        );
    }

    /**
     * Expect an identifier token and return its name
     */
    private function expectIdentifierName(): string
    {
        $token = $this->current();
        if ($token['type'] !== SqlLexer::T_IDENTIFIER) {
            throw new SqlSyntaxException(
                "Expected identifier",
                $this->sql,
                $token['pos']
            );
        }
        $this->pos++;
        return $token['value'];
    }

    /**
     * Bind parameter values to PlaceholderNodes in an AST
     *
     * Supports both positional (?) and named (:name) placeholders.
     * For positional, params are consumed in order.
     * For named, params are looked up by name (without the colon).
     *
     * @param ASTNode $node The AST to bind params to
     * @param array $params Values to bind (modified by reference for positional)
     * @return int Number of params bound
     */
    public static function bindParams(ASTNode $node, array &$params): int
    {
        $bound = 0;

        if ($node instanceof PlaceholderNode) {
            if (str_starts_with($node->token, ':')) {
                $name = substr($node->token, 1);
                if (!array_key_exists($name, $params)) {
                    throw new \RuntimeException("Missing parameter for placeholder :$name");
                }
                $node->bind($params[$name]);
            } else {
                if (empty($params)) {
                    throw new \RuntimeException('Not enough parameters for placeholders in query');
                }
                $node->bind(array_shift($params));
            }
            return 1;
        }

        foreach (get_object_vars($node) as $value) {
            if ($value instanceof ASTNode) {
                $bound += self::bindParams($value, $params);
            } elseif (is_array($value)) {
                foreach ($value as $item) {
                    if ($item instanceof ASTNode) {
                        $bound += self::bindParams($item, $params);
                    } elseif (is_array($item)) {
                        foreach ($item as $subItem) {
                            if ($subItem instanceof ASTNode) {
                                $bound += self::bindParams($subItem, $params);
                            }
                        }
                    }
                }
            }
        }

        return $bound;
    }
}