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, DeleteQuery, Expr, FieldRef, Filter, InsertQuery, Projection, QueryExpr,
15    QueueCommand, 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::Delete(dq) => render_delete(dq),
28        QueryExpr::QueueCommand(qc) => render_queue_command(qc),
29        QueryExpr::Explain(explain) => render_explain(explain.inner.as_ref()),
30        _ => String::new(),
31    }
32}
33
34fn render_explain(inner: &QueryExpr) -> String {
35    let rendered = render(inner);
36    if rendered.is_empty() {
37        String::new()
38    } else {
39        format!("EXPLAIN {rendered}")
40    }
41}
42
43fn render_table(tq: &TableQuery) -> String {
44    let cols = if tq.columns.is_empty() {
45        "*".to_string()
46    } else {
47        tq.columns
48            .iter()
49            .map(render_projection)
50            .collect::<Vec<_>>()
51            .join(", ")
52    };
53    let mut sql = format!("SELECT {} FROM {}", cols, tq.table);
54    if let Some(filter) = &tq.filter {
55        sql.push_str(" WHERE ");
56        sql.push_str(&render_filter(filter));
57    }
58    sql
59}
60
61fn render_insert(iq: &InsertQuery) -> String {
62    let cols = iq.columns.join(", ");
63    let rows: Vec<String> = iq
64        .values
65        .iter()
66        .map(|row| {
67            let vals = row
68                .iter()
69                .map(render_value_sql)
70                .collect::<Vec<_>>()
71                .join(", ");
72            format!("({})", vals)
73        })
74        .collect();
75    format!(
76        "INSERT INTO {} ({}) VALUES {}",
77        iq.table,
78        cols,
79        rows.join(", ")
80    )
81}
82
83fn render_update(uq: &UpdateQuery) -> String {
84    let mut sql = format!("UPDATE {}", uq.table);
85    if let Some(target) = render_update_target(uq.target) {
86        sql.push(' ');
87        sql.push_str(target);
88    }
89    sql.push_str(" SET ");
90    let assignments = uq
91        .assignment_exprs
92        .iter()
93        .enumerate()
94        .map(|(idx, (column, expr))| {
95            let op = uq
96                .compound_assignment_ops
97                .get(idx)
98                .copied()
99                .flatten()
100                .map(render_compound_assignment_op)
101                .unwrap_or("=");
102            format!("{} {} {}", column, op, render_expr_sql(expr))
103        })
104        .collect::<Vec<_>>()
105        .join(", ");
106    sql.push_str(&assignments);
107    if let Some(filter) = &uq.filter {
108        sql.push_str(" WHERE ");
109        sql.push_str(&render_filter(filter));
110    }
111    if let Some(claim_limit) = uq.claim_limit {
112        if uq.claim_exact {
113            sql.push_str(" CLAIM EXACT ");
114        } else {
115            sql.push_str(" CLAIM LIMIT ");
116        }
117        sql.push_str(&claim_limit.to_string());
118    }
119    sql
120}
121
122fn render_delete(dq: &DeleteQuery) -> String {
123    let mut sql = format!("DELETE FROM {}", dq.table);
124    if let Some(filter) = &dq.filter {
125        sql.push_str(" WHERE ");
126        sql.push_str(&render_filter(filter));
127    }
128    sql
129}
130
131fn render_update_target(target: UpdateTarget) -> Option<&'static str> {
132    match target {
133        // Row/document/KV updates render unmarked (ADR 0067, #1711): the model
134        // markers were removed and the catalog resolves the model. `Documents`
135        // / `Kv` only arise from runtime catalog inference, never from parsed
136        // SQL, so rendering them back to text must stay unmarked to round-trip.
137        UpdateTarget::Rows | UpdateTarget::Documents | UpdateTarget::Kv => None,
138        UpdateTarget::Nodes => Some("NODES"),
139        UpdateTarget::Edges => Some("EDGES"),
140    }
141}
142
143fn render_compound_assignment_op(op: BinOp) -> &'static str {
144    match op {
145        BinOp::Add => "+=",
146        BinOp::Sub => "-=",
147        BinOp::Mul => "*=",
148        BinOp::Div => "/=",
149        BinOp::Mod => "%=",
150        _ => "=",
151    }
152}
153
154fn render_queue_command(qc: &QueueCommand) -> String {
155    match qc {
156        QueueCommand::Push { queue, value, .. } => {
157            format!("QUEUE PUSH {} {}", queue, render_value_sql(value))
158        }
159        _ => String::new(),
160    }
161}
162
163fn render_expr_sql(expr: &Expr) -> String {
164    match expr {
165        Expr::Literal { value, .. } => render_value_sql(value),
166        Expr::Column { field, .. } => render_field_ref(field),
167        Expr::BinaryOp { op, lhs, rhs, .. } => format!(
168            "{} {} {}",
169            render_expr_sql(lhs),
170            render_bin_op(*op),
171            render_expr_sql(rhs)
172        ),
173        _ => "NULL".to_string(),
174    }
175}
176
177fn render_bin_op(op: BinOp) -> &'static str {
178    match op {
179        BinOp::Add => "+",
180        BinOp::Sub => "-",
181        BinOp::Mul => "*",
182        BinOp::Div => "/",
183        BinOp::Mod => "%",
184        BinOp::Eq => "=",
185        BinOp::Ne => "!=",
186        BinOp::Lt => "<",
187        BinOp::Le => "<=",
188        BinOp::Gt => ">",
189        BinOp::Ge => ">=",
190        BinOp::And => "AND",
191        BinOp::Or => "OR",
192        _ => "",
193    }
194}
195
196fn render_projection(p: &Projection) -> String {
197    match p {
198        Projection::All => "*".to_string(),
199        Projection::Column(col) => col.clone(),
200        Projection::Alias(col, alias) => format!("{} AS {}", col, alias),
201        Projection::Field(field, alias) => {
202            let col = render_field_ref(field);
203            match alias {
204                Some(a) => format!("{} AS {}", col, a),
205                None => col,
206            }
207        }
208        _ => "*".to_string(),
209    }
210}
211
212pub(crate) fn render_field_ref(f: &FieldRef) -> String {
213    match f {
214        FieldRef::TableColumn { table, column } if table.is_empty() => column.clone(),
215        FieldRef::TableColumn { table, column } => format!("{}.{}", table, column),
216        _ => "field".to_string(),
217    }
218}
219
220fn render_filter(filter: &Filter) -> String {
221    match filter {
222        Filter::Compare { field, op, value } => {
223            format!(
224                "{} {} {}",
225                render_field_ref(field),
226                op,
227                render_value_sql(value)
228            )
229        }
230        Filter::And(a, b) => format!("({}) AND ({})", render_filter(a), render_filter(b)),
231        Filter::Or(a, b) => format!("({}) OR ({})", render_filter(a), render_filter(b)),
232        _ => "1=1".to_string(),
233    }
234}
235
236/// Render a `Value` as a SQL literal suitable for embedding in a query string.
237/// Only the subset used by property tests is handled; others fall back to NULL.
238///
239/// `pub` (was `pub(crate)`) so the server's `storage::query::renderer` shim can
240/// re-export it for the runtime `render_value_sql` call-sites (#1103).
241pub fn render_value_sql(v: &Value) -> String {
242    match v {
243        Value::Null => "NULL".to_string(),
244        Value::Integer(i) => i.to_string(),
245        Value::UnsignedInteger(u) => u.to_string(),
246        Value::Float(f) => {
247            // Ensure the rendered form parses back as Float, not Integer.
248            if f.fract() == 0.0 {
249                format!("{:.1}", f)
250            } else {
251                format!("{}", f)
252            }
253        }
254        Value::Boolean(b) => {
255            if *b {
256                "true".to_string()
257            } else {
258                "false".to_string()
259            }
260        }
261        Value::Text(s) => format!("'{}'", s.replace('\'', "''")),
262        // JSON bytes are stored as canonical compact JSON; emit them raw so
263        // the lexer picks them up as a JsonLiteral token on re-parse.
264        Value::Json(bytes) => String::from_utf8_lossy(bytes).to_string(),
265        // Array literals render back to `[...]` so a comparison against an
266        // array literal re-parses losslessly (issue #1708) rather than
267        // collapsing to NULL.
268        Value::Array(items) => {
269            let rendered: Vec<String> = items.iter().map(render_value_sql).collect();
270            format!("[{}]", rendered.join(", "))
271        }
272        _ => "NULL".to_string(),
273    }
274}