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        // Row/document/KV updates render unmarked (ADR 0067, #1711): the model
114        // markers were removed and the catalog resolves the model. `Documents`
115        // / `Kv` only arise from runtime catalog inference, never from parsed
116        // SQL, so rendering them back to text must stay unmarked to round-trip.
117        UpdateTarget::Rows | UpdateTarget::Documents | UpdateTarget::Kv => None,
118        UpdateTarget::Nodes => Some("NODES"),
119        UpdateTarget::Edges => Some("EDGES"),
120    }
121}
122
123fn render_compound_assignment_op(op: BinOp) -> &'static str {
124    match op {
125        BinOp::Add => "+=",
126        BinOp::Sub => "-=",
127        BinOp::Mul => "*=",
128        BinOp::Div => "/=",
129        BinOp::Mod => "%=",
130        _ => "=",
131    }
132}
133
134fn render_queue_command(qc: &QueueCommand) -> String {
135    match qc {
136        QueueCommand::Push { queue, value, .. } => {
137            format!("QUEUE PUSH {} {}", queue, render_value_sql(value))
138        }
139        _ => String::new(),
140    }
141}
142
143fn render_expr_sql(expr: &Expr) -> String {
144    match expr {
145        Expr::Literal { value, .. } => render_value_sql(value),
146        Expr::Column { field, .. } => render_field_ref(field),
147        Expr::BinaryOp { op, lhs, rhs, .. } => format!(
148            "{} {} {}",
149            render_expr_sql(lhs),
150            render_bin_op(*op),
151            render_expr_sql(rhs)
152        ),
153        _ => "NULL".to_string(),
154    }
155}
156
157fn render_bin_op(op: BinOp) -> &'static str {
158    match op {
159        BinOp::Add => "+",
160        BinOp::Sub => "-",
161        BinOp::Mul => "*",
162        BinOp::Div => "/",
163        BinOp::Mod => "%",
164        BinOp::Eq => "=",
165        BinOp::Ne => "!=",
166        BinOp::Lt => "<",
167        BinOp::Le => "<=",
168        BinOp::Gt => ">",
169        BinOp::Ge => ">=",
170        BinOp::And => "AND",
171        BinOp::Or => "OR",
172        _ => "",
173    }
174}
175
176fn render_projection(p: &Projection) -> String {
177    match p {
178        Projection::All => "*".to_string(),
179        Projection::Column(col) => col.clone(),
180        Projection::Alias(col, alias) => format!("{} AS {}", col, alias),
181        Projection::Field(field, alias) => {
182            let col = render_field_ref(field);
183            match alias {
184                Some(a) => format!("{} AS {}", col, a),
185                None => col,
186            }
187        }
188        _ => "*".to_string(),
189    }
190}
191
192pub(crate) fn render_field_ref(f: &FieldRef) -> String {
193    match f {
194        FieldRef::TableColumn { table, column } if table.is_empty() => column.clone(),
195        FieldRef::TableColumn { table, column } => format!("{}.{}", table, column),
196        _ => "field".to_string(),
197    }
198}
199
200fn render_filter(filter: &Filter) -> String {
201    match filter {
202        Filter::Compare { field, op, value } => {
203            format!(
204                "{} {} {}",
205                render_field_ref(field),
206                op,
207                render_value_sql(value)
208            )
209        }
210        Filter::And(a, b) => format!("({}) AND ({})", render_filter(a), render_filter(b)),
211        Filter::Or(a, b) => format!("({}) OR ({})", render_filter(a), render_filter(b)),
212        _ => "1=1".to_string(),
213    }
214}
215
216/// Render a `Value` as a SQL literal suitable for embedding in a query string.
217/// Only the subset used by property tests is handled; others fall back to NULL.
218///
219/// `pub` (was `pub(crate)`) so the server's `storage::query::renderer` shim can
220/// re-export it for the runtime `render_value_sql` call-sites (#1103).
221pub fn render_value_sql(v: &Value) -> String {
222    match v {
223        Value::Null => "NULL".to_string(),
224        Value::Integer(i) => i.to_string(),
225        Value::UnsignedInteger(u) => u.to_string(),
226        Value::Float(f) => {
227            // Ensure the rendered form parses back as Float, not Integer.
228            if f.fract() == 0.0 {
229                format!("{:.1}", f)
230            } else {
231                format!("{}", f)
232            }
233        }
234        Value::Boolean(b) => {
235            if *b {
236                "true".to_string()
237            } else {
238                "false".to_string()
239            }
240        }
241        Value::Text(s) => format!("'{}'", s.replace('\'', "''")),
242        // JSON bytes are stored as canonical compact JSON; emit them raw so
243        // the lexer picks them up as a JsonLiteral token on re-parse.
244        Value::Json(bytes) => String::from_utf8_lossy(bytes).to_string(),
245        // Array literals render back to `[...]` so a comparison against an
246        // array literal re-parses losslessly (issue #1708) rather than
247        // collapsing to NULL.
248        Value::Array(items) => {
249            let rendered: Vec<String> = items.iter().map(render_value_sql).collect();
250            format!("[{}]", rendered.join(", "))
251        }
252        _ => "NULL".to_string(),
253    }
254}