Skip to main content

reddb_rql/
renderer.rs

1//! AST → SQL/RQL renderer (partial subset for property round-trip tests).
2//!
3//! Covers the three query categories exercised by the property test:
4//! - `SELECT col, … FROM table [WHERE simple-filter]`
5//! - `INSERT INTO table (cols) VALUES (vals)`
6//! - `QUEUE PUSH queue value`
7//!
8//! Run the round-trip property test with:
9//! ```text
10//! cargo test -p reddb-server property_round_trip
11//! ```
12
13use crate::ast::{
14    BinOp, CompareOp, Expr, FieldRef, Filter, InsertQuery, Projection, QueryExpr, QueueCommand,
15    TableQuery, UpdateQuery, UpdateTarget,
16};
17use reddb_types::types::Value;
18
19/// Render a `QueryExpr` back into canonical SQL/RQL.
20///
21/// Returns an empty string for variants outside the supported subset.
22pub fn render(expr: &QueryExpr) -> String {
23    match expr {
24        QueryExpr::Table(tq) => render_table(tq),
25        QueryExpr::Insert(iq) => render_insert(iq),
26        QueryExpr::Update(uq) => render_update(uq),
27        QueryExpr::QueueCommand(qc) => render_queue_command(qc),
28        _ => String::new(),
29    }
30}
31
32fn render_table(tq: &TableQuery) -> String {
33    let cols = if tq.columns.is_empty() {
34        "*".to_string()
35    } else {
36        tq.columns
37            .iter()
38            .map(render_projection)
39            .collect::<Vec<_>>()
40            .join(", ")
41    };
42    let mut sql = format!("SELECT {} FROM {}", cols, tq.table);
43    if let Some(filter) = &tq.filter {
44        sql.push_str(" WHERE ");
45        sql.push_str(&render_filter(filter));
46    }
47    sql
48}
49
50fn render_insert(iq: &InsertQuery) -> String {
51    let cols = iq.columns.join(", ");
52    let rows: Vec<String> = iq
53        .values
54        .iter()
55        .map(|row| {
56            let vals = row
57                .iter()
58                .map(render_value_sql)
59                .collect::<Vec<_>>()
60                .join(", ");
61            format!("({})", vals)
62        })
63        .collect();
64    format!(
65        "INSERT INTO {} ({}) VALUES {}",
66        iq.table,
67        cols,
68        rows.join(", ")
69    )
70}
71
72fn render_update(uq: &UpdateQuery) -> String {
73    let mut sql = format!("UPDATE {}", uq.table);
74    if let Some(target) = render_update_target(uq.target) {
75        sql.push(' ');
76        sql.push_str(target);
77    }
78    sql.push_str(" SET ");
79    let assignments = uq
80        .assignment_exprs
81        .iter()
82        .enumerate()
83        .map(|(idx, (column, expr))| {
84            let op = uq
85                .compound_assignment_ops
86                .get(idx)
87                .copied()
88                .flatten()
89                .map(render_compound_assignment_op)
90                .unwrap_or("=");
91            format!("{} {} {}", column, op, render_expr_sql(expr))
92        })
93        .collect::<Vec<_>>()
94        .join(", ");
95    sql.push_str(&assignments);
96    if let Some(filter) = &uq.filter {
97        sql.push_str(" WHERE ");
98        sql.push_str(&render_filter(filter));
99    }
100    if let Some(claim_limit) = uq.claim_limit {
101        if uq.claim_exact {
102            sql.push_str(" CLAIM EXACT ");
103        } else {
104            sql.push_str(" CLAIM LIMIT ");
105        }
106        sql.push_str(&claim_limit.to_string());
107    }
108    sql
109}
110
111fn render_update_target(target: UpdateTarget) -> Option<&'static str> {
112    match target {
113        UpdateTarget::Rows => None,
114        UpdateTarget::Documents => Some("DOCUMENTS"),
115        UpdateTarget::Kv => Some("KV"),
116        UpdateTarget::Nodes => Some("NODES"),
117        UpdateTarget::Edges => Some("EDGES"),
118    }
119}
120
121fn render_compound_assignment_op(op: BinOp) -> &'static str {
122    match op {
123        BinOp::Add => "+=",
124        BinOp::Sub => "-=",
125        BinOp::Mul => "*=",
126        BinOp::Div => "/=",
127        BinOp::Mod => "%=",
128        _ => "=",
129    }
130}
131
132fn render_queue_command(qc: &QueueCommand) -> String {
133    match qc {
134        QueueCommand::Push { queue, value, .. } => {
135            format!("QUEUE PUSH {} {}", queue, render_value_sql(value))
136        }
137        _ => String::new(),
138    }
139}
140
141fn render_expr_sql(expr: &Expr) -> String {
142    match expr {
143        Expr::Literal { value, .. } => render_value_sql(value),
144        Expr::Column { field, .. } => render_field_ref(field),
145        Expr::BinaryOp { op, lhs, rhs, .. } => format!(
146            "{} {} {}",
147            render_expr_sql(lhs),
148            render_bin_op(*op),
149            render_expr_sql(rhs)
150        ),
151        _ => "NULL".to_string(),
152    }
153}
154
155fn render_bin_op(op: BinOp) -> &'static str {
156    match op {
157        BinOp::Add => "+",
158        BinOp::Sub => "-",
159        BinOp::Mul => "*",
160        BinOp::Div => "/",
161        BinOp::Mod => "%",
162        BinOp::Eq => "=",
163        BinOp::Ne => "!=",
164        BinOp::Lt => "<",
165        BinOp::Le => "<=",
166        BinOp::Gt => ">",
167        BinOp::Ge => ">=",
168        BinOp::And => "AND",
169        BinOp::Or => "OR",
170        _ => "",
171    }
172}
173
174fn render_projection(p: &Projection) -> String {
175    match p {
176        Projection::All => "*".to_string(),
177        Projection::Column(col) => col.clone(),
178        Projection::Alias(col, alias) => format!("{} AS {}", col, alias),
179        Projection::Field(field, alias) => {
180            let col = render_field_ref(field);
181            match alias {
182                Some(a) => format!("{} AS {}", col, a),
183                None => col,
184            }
185        }
186        _ => "*".to_string(),
187    }
188}
189
190pub(crate) fn render_field_ref(f: &FieldRef) -> String {
191    match f {
192        FieldRef::TableColumn { table, column } if table.is_empty() => column.clone(),
193        FieldRef::TableColumn { table, column } => format!("{}.{}", table, column),
194        _ => "field".to_string(),
195    }
196}
197
198fn render_filter(filter: &Filter) -> String {
199    match filter {
200        Filter::Compare { field, op, value } => {
201            format!(
202                "{} {} {}",
203                render_field_ref(field),
204                op,
205                render_value_sql(value)
206            )
207        }
208        Filter::And(a, b) => format!("({}) AND ({})", render_filter(a), render_filter(b)),
209        Filter::Or(a, b) => format!("({}) OR ({})", render_filter(a), render_filter(b)),
210        _ => "1=1".to_string(),
211    }
212}
213
214/// Render a `Value` as a SQL literal suitable for embedding in a query string.
215/// Only the subset used by property tests is handled; others fall back to NULL.
216///
217/// `pub` (was `pub(crate)`) so the server's `storage::query::renderer` shim can
218/// re-export it for the runtime `render_value_sql` call-sites (#1103).
219pub fn render_value_sql(v: &Value) -> String {
220    match v {
221        Value::Null => "NULL".to_string(),
222        Value::Integer(i) => i.to_string(),
223        Value::UnsignedInteger(u) => u.to_string(),
224        Value::Float(f) => {
225            // Ensure the rendered form parses back as Float, not Integer.
226            if f.fract() == 0.0 {
227                format!("{:.1}", f)
228            } else {
229                format!("{}", f)
230            }
231        }
232        Value::Boolean(b) => {
233            if *b {
234                "true".to_string()
235            } else {
236                "false".to_string()
237            }
238        }
239        Value::Text(s) => format!("'{}'", s.replace('\'', "''")),
240        // JSON bytes are stored as canonical compact JSON; emit them raw so
241        // the lexer picks them up as a JsonLiteral token on re-parse.
242        Value::Json(bytes) => String::from_utf8_lossy(bytes).to_string(),
243        _ => "NULL".to_string(),
244    }
245}