icydb-core 0.252.9

IcyDB — A schema-first typed query engine and persistence runtime for Internet Computer canisters
Documentation
//! Module: db::sql::parser
//! Responsibility: reduced SQL statement parsing for deterministic frontend normalization.
//! Does not own: standalone predicate parsing semantics, planner policy, or execution semantics.
//! Boundary: parses one SQL statement into frontend-neutral statement contracts on top of the shared SQL token cursor.

mod clauses;
mod model;
mod projection;
mod statement;

#[cfg(test)]
mod tests;

use crate::{
    db::sql_shared::{
        Keyword, MAX_SQL_EXPR_DEPTH, SqlExpectedToken, SqlIntegerLiteralClause, SqlSyntaxErrorKind,
        SqlTokenCursor, TokenKind, sql_expr_depth_limit_error, tokenize_sql,
    },
    value::Value,
};
use icydb_diagnostic_code::SqlFeatureCode;

pub(crate) use crate::db::sql_shared::SqlParseError;
pub(crate) use model::{
    SqlAggregateCall, SqlAggregateKind, SqlAlterColumnAction,
    SqlAlterTableAddCheckConstraintStatement, SqlAlterTableAddColumnStatement,
    SqlAlterTableAlterColumnStatement, SqlAlterTableDropColumnStatement,
    SqlAlterTableDropConstraintStatement, SqlAlterTableRenameColumnStatement,
    SqlAlterTableValidateConstraintStatement, SqlAssignment, SqlCaseArm,
    SqlCreateIndexExpressionFunction, SqlCreateIndexExpressionKey, SqlCreateIndexKeyItem,
    SqlCreateIndexStatement, SqlCreateIndexUniqueness, SqlDdlSchemaVersionContract,
    SqlDdlStatement, SqlDeleteStatement, SqlDescribeMode, SqlDescribeStatement,
    SqlDropIndexStatement, SqlExpr, SqlExprBinaryOp, SqlExprUnaryOp, SqlInsertSource,
    SqlInsertStatement, SqlIntegrityStatement, SqlOrderDirection, SqlOrderTerm, SqlProjection,
    SqlReturningProjection, SqlScalarFunction, SqlScalarFunctionCallShape, SqlSelectItem,
    SqlSelectStatement, SqlShowColumnsStatement, SqlShowConstraintsStatement,
    SqlShowEntitiesStatement, SqlShowIndexesStatement, SqlShowMemoryStatement,
    SqlShowRelationsStatement, SqlShowStoresStatement, SqlStatement, SqlUpdateStatement,
    SqlWriteValue,
};
#[cfg(feature = "sql")]
pub(crate) use model::{SqlExplainMode, SqlExplainStatement, SqlExplainTarget};
/// Parse one reduced SQL statement.
///
/// Parsing is deterministic and normalization-insensitive for keyword casing,
/// insignificant whitespace, and optional one-statement terminator (`;`).
pub(crate) fn parse_sql(sql: &str) -> Result<SqlStatement, SqlParseError> {
    let tokens = tokenize_sql(sql)?;
    if tokens.is_empty() {
        return Err(SqlParseError::EmptyInput);
    }

    let mut parser = Parser::new(SqlTokenCursor::new(tokens));
    let statement = parser.parse_statement()?;

    if parser.eat_semicolon() && !parser.is_eof() {
        return Err(SqlParseError::unsupported_feature(
            SqlFeatureCode::MultiStatementSql,
        ));
    }

    if !parser.is_eof() {
        if let Some(err) = parser.trailing_clause_order_error(&statement) {
            return Err(err);
        }

        if let Some(feature) = parser.peek_unsupported_feature() {
            return Err(SqlParseError::unsupported_feature(feature));
        }

        return Err(SqlParseError::expected_end_of_input(parser.peek_kind()));
    }

    Ok(statement)
}

/// Parse one bounded `CHECK INTEGRITY` statement.
///
/// The dedicated entry keeps this administrative capability outside ordinary
/// query/mutation/DDL dispatch while sharing the canonical lexer and parser
/// grammar. Generated endpoint routing may call this same owner directly.
pub(super) fn parse_integrity_sql(sql: &str) -> Result<SqlIntegrityStatement, SqlParseError> {
    let tokens = tokenize_sql(sql)?;
    if tokens.is_empty() {
        return Err(SqlParseError::EmptyInput);
    }

    let mut parser = Parser::new(SqlTokenCursor::new(tokens));
    let statement = parser.parse_integrity_statement()?;

    if parser.eat_semicolon() && !parser.is_eof() {
        return Err(SqlParseError::unsupported_feature(
            SqlFeatureCode::MultiStatementSql,
        ));
    }
    if !parser.is_eof() {
        return Err(SqlParseError::expected_end_of_input(parser.peek_kind()));
    }

    Ok(statement)
}

// Parser state over one pre-tokenized SQL statement.
struct Parser {
    cursor: SqlTokenCursor,
    next_param_index: usize,
    expr_depth: usize,
}

impl Parser {
    const fn new(cursor: SqlTokenCursor) -> Self {
        Self {
            cursor,
            next_param_index: 0,
            expr_depth: 0,
        }
    }

    fn parse_literal(&mut self) -> Result<Value, SqlParseError> {
        self.cursor.parse_literal()
    }

    fn expect_string_literal(&mut self) -> Result<String, SqlParseError> {
        if !matches!(self.peek_kind(), Some(TokenKind::StringLiteral(_))) {
            return Err(SqlParseError::expected(
                SqlExpectedToken::StringLiteral,
                self.peek_kind(),
            ));
        }

        match self.parse_literal()? {
            Value::Text(value) => Ok(value),
            _ => Err(SqlParseError::expected(
                SqlExpectedToken::StringLiteral,
                self.peek_kind(),
            )),
        }
    }

    fn parse_u32_literal(&mut self, clause: SqlIntegerLiteralClause) -> Result<u32, SqlParseError> {
        let Some(TokenKind::Number(value)) = self.peek_kind() else {
            return Err(SqlParseError::expected(
                SqlExpectedToken::IntegerLiteral { clause },
                self.peek_kind(),
            ));
        };
        let value = value.as_str();

        if value.contains('.') || value.starts_with('-') {
            return Err(SqlParseError::invalid_syntax(
                SqlSyntaxErrorKind::IntegerLiteralRequiresNonNegative { clause },
            ));
        }

        let parsed = value.parse::<u32>().map_err(|_| {
            SqlParseError::invalid_syntax(SqlSyntaxErrorKind::IntegerLiteralU32Overflow { clause })
        })?;
        self.cursor.advance();

        Ok(parsed)
    }

    fn parse_u64_literal(&mut self, clause: SqlIntegerLiteralClause) -> Result<u64, SqlParseError> {
        let Some(TokenKind::Number(value)) = self.peek_kind() else {
            return Err(SqlParseError::expected(
                SqlExpectedToken::IntegerLiteral { clause },
                self.peek_kind(),
            ));
        };
        let value = value.as_str();

        if value.contains('.') || value.starts_with('-') {
            return Err(SqlParseError::invalid_syntax(
                SqlSyntaxErrorKind::IntegerLiteralRequiresNonNegative { clause },
            ));
        }

        let parsed = value.parse::<u64>().map_err(|_| {
            SqlParseError::invalid_syntax(SqlSyntaxErrorKind::IntegerLiteralU64Overflow { clause })
        })?;
        self.cursor.advance();

        Ok(parsed)
    }

    fn expect_keyword(&mut self, keyword: Keyword) -> Result<(), SqlParseError> {
        self.cursor.expect_keyword(keyword)
    }

    fn expect_identifier(&mut self) -> Result<String, SqlParseError> {
        self.cursor.expect_identifier()
    }

    fn expect_lparen(&mut self) -> Result<(), SqlParseError> {
        self.cursor.expect_lparen()
    }

    fn expect_rparen(&mut self) -> Result<(), SqlParseError> {
        self.cursor.expect_rparen()
    }

    fn eat_keyword(&mut self, keyword: Keyword) -> bool {
        self.cursor.eat_keyword(keyword)
    }

    fn eat_identifier_keyword(&mut self, keyword: &str) -> bool {
        self.cursor.eat_identifier_keyword(keyword)
    }

    fn eat_comma(&mut self) -> bool {
        self.cursor.eat_comma()
    }

    fn eat_plus(&mut self) -> bool {
        self.cursor.eat_plus()
    }

    fn eat_question(&mut self) -> bool {
        self.cursor.eat_question()
    }

    fn eat_minus(&mut self) -> bool {
        self.cursor.eat_minus()
    }

    fn eat_slash(&mut self) -> bool {
        self.cursor.eat_slash()
    }

    fn eat_semicolon(&mut self) -> bool {
        self.cursor.eat_semicolon()
    }

    fn eat_star(&mut self) -> bool {
        self.cursor.eat_star()
    }

    const fn take_param_index(&mut self) -> usize {
        let index = self.next_param_index;
        self.next_param_index = self.next_param_index.saturating_add(1);

        index
    }

    const fn enter_sql_expr_depth(&mut self) -> Result<(), SqlParseError> {
        if self.expr_depth >= MAX_SQL_EXPR_DEPTH {
            return Err(sql_expr_depth_limit_error());
        }

        self.expr_depth = self.expr_depth.saturating_add(1);

        Ok(())
    }

    const fn leave_sql_expr_depth(&mut self) {
        self.expr_depth = self.expr_depth.saturating_sub(1);
    }

    fn peek_keyword(&self, keyword: Keyword) -> bool {
        self.cursor.peek_keyword(keyword)
    }

    fn peek_lparen(&self) -> bool {
        self.cursor.peek_lparen()
    }

    fn peek_unsupported_feature(&self) -> Option<SqlFeatureCode> {
        sql_unsupported_feature(self.cursor.peek_kind())
    }

    fn peek_kind(&self) -> Option<&TokenKind> {
        self.cursor.peek_kind()
    }

    fn expect_identifier_keyword(&mut self, keyword: &str) -> Result<(), SqlParseError> {
        if self.eat_identifier_keyword(keyword) {
            return Ok(());
        }

        Err(SqlParseError::expected(
            SqlExpectedToken::identifier_keyword(keyword),
            self.peek_kind(),
        ))
    }

    const fn is_eof(&self) -> bool {
        self.cursor.is_eof()
    }
}

// Keep reduced-SQL feature-policy codes at the statement parser boundary so
// sql_shared remains a lexical token/cursor utility.
const fn sql_unsupported_feature(kind: Option<&TokenKind>) -> Option<SqlFeatureCode> {
    match kind {
        Some(TokenKind::Keyword(Keyword::As)) => Some(SqlFeatureCode::ColumnAlias),
        Some(TokenKind::Keyword(Keyword::Describe)) => Some(SqlFeatureCode::DescribeModifier),
        Some(TokenKind::Keyword(Keyword::Having)) => Some(SqlFeatureCode::Having),
        Some(TokenKind::Keyword(Keyword::Insert)) => Some(SqlFeatureCode::Insert),
        Some(TokenKind::Keyword(Keyword::Join)) => Some(SqlFeatureCode::Join),
        Some(TokenKind::Keyword(Keyword::Filter)) => Some(SqlFeatureCode::AggregateFilterClause),
        Some(TokenKind::Keyword(Keyword::Over)) => Some(SqlFeatureCode::WindowFunction),
        Some(TokenKind::Keyword(Keyword::Returning)) => {
            Some(SqlFeatureCode::ReturningUnsupportedShape)
        }
        Some(TokenKind::Keyword(Keyword::Show)) => Some(SqlFeatureCode::ShowUnsupportedCommand),
        Some(TokenKind::Keyword(Keyword::With)) => Some(SqlFeatureCode::With),
        Some(TokenKind::Keyword(Keyword::Union | Keyword::Intersect | Keyword::Except)) => {
            Some(SqlFeatureCode::UnionIntersectExcept)
        }
        Some(TokenKind::Keyword(Keyword::Update)) => Some(SqlFeatureCode::Update),
        _ => None,
    }
}