reddb-io-rql 1.28.21

RedDB Query Language (RQL) front-end + conformance authority (ADR 0053). Owns the sqllogictest-format conformance suite whose truth comes from the public SQLite corpus; depends only on reddb-io-types.
Documentation
//! AST → SQL/RQL renderer (partial subset for property round-trip tests).
//!
//! Covers the three query categories exercised by the property test:
//! - `SELECT col, … FROM table [WHERE simple-filter]`
//! - `INSERT INTO table (cols) VALUES (vals)`
//! - `QUEUE PUSH queue value`
//!
//! Run the round-trip property test with:
//! ```text
//! cargo test -p reddb-server property_round_trip
//! ```

use crate::ast::{
    BinOp, CompareOp, DeleteQuery, Expr, FieldRef, Filter, InsertQuery, Projection, QueryExpr,
    QueueCommand, TableQuery, UpdateQuery, UpdateTarget,
};
use reddb_types::types::Value;

/// Render a `QueryExpr` back into canonical SQL/RQL.
///
/// Returns an empty string for variants outside the supported subset.
pub fn render(expr: &QueryExpr) -> String {
    match expr {
        QueryExpr::Table(tq) => render_table(tq),
        QueryExpr::Insert(iq) => render_insert(iq),
        QueryExpr::Update(uq) => render_update(uq),
        QueryExpr::Delete(dq) => render_delete(dq),
        QueryExpr::QueueCommand(qc) => render_queue_command(qc),
        QueryExpr::Explain(explain) => render_explain(explain.inner.as_ref()),
        _ => String::new(),
    }
}

/// Preserve ordinary names; quote keywords and names requiring delimiters.
pub fn render_identifier(identifier: &str) -> String {
    let mut lexer = crate::lexer::Lexer::new(identifier);
    let contextual_keyword = matches!(
        identifier.to_ascii_uppercase().as_str(),
        "CASE" | "CONSTRAINT" | "CURRENT_TIMESTAMP" | "CURRENT_DATE" | "CURRENT_TIME"
    );
    let plain = !contextual_keyword
        && matches!(lexer.next_token(), Ok(token) if token.token == crate::lexer::Token::Ident(identifier.to_string()))
        && matches!(lexer.next_token(), Ok(token) if token.token == crate::lexer::Token::Eof);
    if plain {
        identifier.to_string()
    } else {
        format!(
            "\"{}\"",
            identifier.replace('\\', "\\\\").replace('"', "\"\"")
        )
    }
}

// Dotted collection names normalize to paths, including a quoted whole name.
// Field paths still require separate segments; their quoting is not retained.
fn render_identifier_path(path: &str) -> String {
    path.split('.')
        .map(render_identifier)
        .collect::<Vec<_>>()
        .join(".")
}

fn render_explain(inner: &QueryExpr) -> String {
    let rendered = render(inner);
    if rendered.is_empty() {
        String::new()
    } else {
        format!("EXPLAIN {rendered}")
    }
}

fn render_table(tq: &TableQuery) -> String {
    let cols = if tq.columns.is_empty() {
        "*".to_string()
    } else {
        tq.columns
            .iter()
            .map(render_projection)
            .collect::<Vec<_>>()
            .join(", ")
    };
    let mut sql = format!("SELECT {} FROM {}", cols, render_identifier_path(&tq.table));
    if let Some(filter) = &tq.filter {
        sql.push_str(" WHERE ");
        sql.push_str(&render_filter(filter));
    }
    sql
}

fn render_insert(iq: &InsertQuery) -> String {
    let cols = iq
        .columns
        .iter()
        .map(|name| render_identifier(name))
        .collect::<Vec<_>>()
        .join(", ");
    let rows: Vec<String> = iq
        .values
        .iter()
        .map(|row| {
            let vals = row
                .iter()
                .map(render_value_sql)
                .collect::<Vec<_>>()
                .join(", ");
            format!("({})", vals)
        })
        .collect();
    let mut sql = format!(
        "INSERT INTO {} ({}) VALUES {}",
        render_identifier_path(&iq.table),
        cols,
        rows.join(", ")
    );
    if let Some(clause) = &iq.on_conflict {
        sql.push_str(" ON CONFLICT");
        if let Some(columns) = &clause.target {
            sql.push_str(" (");
            sql.push_str(
                &columns
                    .iter()
                    .map(|column| render_identifier(column))
                    .collect::<Vec<_>>()
                    .join(", "),
            );
            sql.push(')');
        }
        match &clause.action {
            crate::ast::OnConflictAction::DoNothing => sql.push_str(" DO NOTHING"),
            crate::ast::OnConflictAction::DoUpdate { assignments } => {
                sql.push_str(" DO UPDATE SET ");
                sql.push_str(
                    &assignments
                        .iter()
                        .map(|(column, expr)| {
                            format!("{} = {}", render_identifier(column), render_expr_sql(expr))
                        })
                        .collect::<Vec<_>>()
                        .join(", "),
                );
            }
        }
    }
    sql
}

fn render_update(uq: &UpdateQuery) -> String {
    let mut sql = format!("UPDATE {}", render_identifier_path(&uq.table));
    if let Some(target) = render_update_target(uq.target) {
        sql.push(' ');
        sql.push_str(target);
    }
    sql.push_str(" SET ");
    let assignments = uq
        .assignment_exprs
        .iter()
        .enumerate()
        .map(|(idx, (column, expr))| {
            let op = uq
                .compound_assignment_ops
                .get(idx)
                .copied()
                .flatten()
                .map(render_compound_assignment_op)
                .unwrap_or("=");
            format!(
                "{} {} {}",
                render_identifier_path(column),
                op,
                render_expr_sql(expr)
            )
        })
        .collect::<Vec<_>>()
        .join(", ");
    sql.push_str(&assignments);
    if let Some(filter) = &uq.filter {
        sql.push_str(" WHERE ");
        sql.push_str(&render_filter(filter));
    }
    if let Some(claim_limit) = uq.claim_limit {
        if uq.claim_exact {
            sql.push_str(" CLAIM EXACT ");
        } else {
            sql.push_str(" CLAIM LIMIT ");
        }
        sql.push_str(&claim_limit.to_string());
    }
    sql
}

fn render_delete(dq: &DeleteQuery) -> String {
    let mut sql = format!("DELETE FROM {}", render_identifier_path(&dq.table));
    if let Some(filter) = &dq.filter {
        sql.push_str(" WHERE ");
        sql.push_str(&render_filter(filter));
    }
    sql
}

fn render_update_target(target: UpdateTarget) -> Option<&'static str> {
    match target {
        // Row/document/KV updates render unmarked (ADR 0067, #1711): the model
        // markers were removed and the catalog resolves the model. `Documents`
        // / `Kv` only arise from runtime catalog inference, never from parsed
        // SQL, so rendering them back to text must stay unmarked to round-trip.
        UpdateTarget::Rows | UpdateTarget::Documents | UpdateTarget::Kv => None,
        UpdateTarget::Nodes => Some("NODES"),
        UpdateTarget::Edges => Some("EDGES"),
    }
}

fn render_compound_assignment_op(op: BinOp) -> &'static str {
    match op {
        BinOp::Add => "+=",
        BinOp::Sub => "-=",
        BinOp::Mul => "*=",
        BinOp::Div => "/=",
        BinOp::Mod => "%=",
        _ => "=",
    }
}

fn render_queue_command(qc: &QueueCommand) -> String {
    match qc {
        QueueCommand::Push { queue, value, .. } => {
            format!(
                "QUEUE PUSH {} {}",
                render_identifier(queue),
                render_value_sql(value)
            )
        }
        _ => String::new(),
    }
}

fn render_expr_sql(expr: &Expr) -> String {
    match expr {
        Expr::Literal { value, .. } => render_value_sql(value),
        Expr::Column { field, .. } => render_field_ref(field),
        Expr::BinaryOp { op, lhs, rhs, .. } => format!(
            "{} {} {}",
            render_expr_sql(lhs),
            render_bin_op(*op),
            render_expr_sql(rhs)
        ),
        _ => "NULL".to_string(),
    }
}

fn render_bin_op(op: BinOp) -> &'static str {
    match op {
        BinOp::Add => "+",
        BinOp::Sub => "-",
        BinOp::Mul => "*",
        BinOp::Div => "/",
        BinOp::Mod => "%",
        BinOp::Eq => "=",
        BinOp::Ne => "!=",
        BinOp::Lt => "<",
        BinOp::Le => "<=",
        BinOp::Gt => ">",
        BinOp::Ge => ">=",
        BinOp::And => "AND",
        BinOp::Or => "OR",
        _ => "",
    }
}

fn render_projection(p: &Projection) -> String {
    match p {
        Projection::All => "*".to_string(),
        Projection::Column(col) => render_identifier(col),
        Projection::Alias(col, alias) => {
            format!("{} AS {}", render_identifier(col), render_identifier(alias))
        }
        Projection::Field(field, alias) => {
            let col = render_field_ref(field);
            match alias {
                Some(a) => format!("{} AS {}", col, render_identifier(a)),
                None => col,
            }
        }
        _ => "*".to_string(),
    }
}

pub(crate) fn render_field_ref(f: &FieldRef) -> String {
    match f {
        FieldRef::TableColumn { table, column } if table.is_empty() => {
            render_identifier_path(column)
        }
        FieldRef::TableColumn { table, column } => {
            format!(
                "{}.{}",
                render_identifier_path(table),
                render_identifier_path(column)
            )
        }
        _ => "field".to_string(),
    }
}

fn render_filter(filter: &Filter) -> String {
    match filter {
        Filter::Compare { field, op, value } => {
            format!(
                "{} {} {}",
                render_field_ref(field),
                op,
                render_value_sql(value)
            )
        }
        Filter::And(a, b) => format!("({}) AND ({})", render_filter(a), render_filter(b)),
        Filter::Or(a, b) => format!("({}) OR ({})", render_filter(a), render_filter(b)),
        _ => "1=1".to_string(),
    }
}

/// Render a `Value` as a SQL literal suitable for embedding in a query string.
/// Only the subset used by property tests is handled; others fall back to NULL.
///
/// `pub` (was `pub(crate)`) so server runtime call-sites can import it directly
/// without duplicating the rendering logic (#1103).
pub fn render_value_sql(v: &Value) -> String {
    match v {
        Value::Null => "NULL".to_string(),
        Value::Integer(i) => i.to_string(),
        Value::UnsignedInteger(u) => u.to_string(),
        Value::Float(f) => {
            // Ensure the rendered form parses back as Float, not Integer.
            if f.fract() == 0.0 {
                format!("{:.1}", f)
            } else {
                format!("{}", f)
            }
        }
        Value::Boolean(b) => {
            if *b {
                "true".to_string()
            } else {
                "false".to_string()
            }
        }
        Value::Text(s) => format!("'{}'", s.replace('\'', "''")),
        // JSON bytes are stored as canonical compact JSON; emit them raw so
        // the lexer picks them up as a JsonLiteral token on re-parse.
        Value::Json(bytes) => String::from_utf8_lossy(bytes).to_string(),
        // Array literals render back to `[...]` so a comparison against an
        // array literal re-parses losslessly (issue #1708) rather than
        // collapsing to NULL.
        Value::Array(items) => {
            let rendered: Vec<String> = items.iter().map(render_value_sql).collect();
            format!("[{}]", rendered.join(", "))
        }
        _ => "NULL".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::render;

    #[test]
    fn insert_on_conflict_round_trips_through_the_canonical_renderer() {
        let sql = "INSERT INTO counters (name, value) VALUES ('jobs', 3) ON CONFLICT (name) DO UPDATE SET value = value + EXCLUDED.value";
        let parsed = crate::parser::parse(sql).expect("ON CONFLICT should parse");

        assert_eq!(render(&parsed.query), sql);
    }
}