Skip to main content

linkrs_query/planning/
dml_shape.rs

1//! DML shape normalization for plan-cache reuse.
2//!
3//! Same-shape DML statements (e.g. 10000 identical INSERT VERTEX templates
4//! that only differ in literal values) should compile their physical plan
5//! once and reuse it from the plan cache, binding per-statement literal
6//! values at execution time.
7//!
8//! This module re-renders a DML statement as a canonical template in which
9//! every literal value position is replaced by a `@__dml_N` parameter slot,
10//! producing a normalized query text (identical across same-shape
11//! statements) together with the ordered literal values used for
12//! execution-time binding.
13//!
14//! Scope: the direct write statements (INSERT / DELETE / UPDATE / MERGE /
15//! SET / REMOVE) — see `crate::pipeline::prepared::is_direct_dml_statement`
16//! for the authoritative candidate set (`normalize_shape` below accepts a
17//! subset of it: no COPY).
18//! Read queries are deliberately excluded: their plans depend on constant
19//! values (constant folding, index selection) and users already have
20//! explicit `$param` binding for parameterized read reuse.
21//!
22//! Semantic note: after normalization the bound DML plan sees only `@__dml_N`
23//! parameters, not the literal values. DML plans do not rely on constant
24//! values for constant folding or index selection, so parameterization does
25//! not degrade plan quality — the same rationale that excludes read queries.
26
27use crate::parser::ast::Stmt;
28use linkrs_core::types::expr::{ContextualExpression, Expression};
29use linkrs_core::Value;
30
31mod delete;
32mod insert;
33mod merge;
34mod set_remove;
35mod update;
36
37/// Prefix used for synthesized DML parameter names.
38pub const DML_PARAM_PREFIX: &str = "__dml_";
39
40/// Result of normalizing a DML statement's shape.
41#[derive(Debug, Clone)]
42pub struct DmlShape {
43    /// Query text with literal values replaced by `@__dml_N` placeholders.
44    pub normalized_text: String,
45    /// Literal values in left-to-right source order, indexed by `N`.
46    pub values: Vec<Value>,
47}
48
49/// Normalize a DML statement, returning the canonical template text and the
50/// ordered literal values.
51///
52/// Returns `None` when the statement is not shape-cacheable (e.g. a value
53/// position uses a construct that cannot be re-rendered as a parameter).
54pub fn normalize_shape(stmt: &Stmt) -> Option<DmlShape> {
55    let mut values = Vec::new();
56    let normalized_text = match stmt {
57        Stmt::Insert(s) => insert::render_insert(s, &mut values)?,
58        Stmt::Delete(s) => delete::render_delete(s, &mut values)?,
59        Stmt::Update(s) => update::render_update(s, &mut values)?,
60        Stmt::Merge(s) => merge::render_merge(s, &mut values)?,
61        Stmt::Set(s) => set_remove::render_set(s, &mut values)?,
62        Stmt::Remove(s) => set_remove::render_remove(s, &mut values)?,
63        _ => return None,
64    };
65    if values.is_empty() {
66        return None;
67    }
68    Some(DmlShape {
69        normalized_text,
70        values,
71    })
72}
73
74/// Render an expression position, replacing every literal leaf with a
75/// `@__dml_N` placeholder and recording the value.
76///
77/// Returns `None` when the expression contains a construct that cannot be
78/// re-rendered as re-parseable text; the caller then treats the statement as
79/// not shape-cacheable instead of risking a broken template.
80pub(crate) fn render_contextual(
81    out: &mut String,
82    values: &mut Vec<Value>,
83    expr: &ContextualExpression,
84) -> Option<()> {
85    render_expr(out, values, expr.expression()?.inner())
86}
87
88/// Render an expression into `out`, replacing every literal leaf with a
89/// `@__dml_N` placeholder.
90///
91/// Only constructs that can be faithfully re-parsed are handled; anything
92/// else yields `None` so the statement falls back to the non-cached path.
93fn render_expr(out: &mut String, values: &mut Vec<Value>, expr: &Expression) -> Option<()> {
94    match expr {
95        Expression::Literal(value) => {
96            push_param(out, values, value.clone());
97            Some(())
98        }
99        Expression::Variable(name) => {
100            out.push_str(name);
101            Some(())
102        }
103        Expression::Parameter(name) => {
104            out.push('@');
105            out.push_str(name);
106            Some(())
107        }
108        Expression::SessionVariable(name) => {
109            out.push('$');
110            out.push_str(name);
111            Some(())
112        }
113        Expression::Property { object, property } => {
114            render_expr(out, values, object)?;
115            out.push('.');
116            out.push_str(property);
117            Some(())
118        }
119        Expression::Binary { left, op, right } => {
120            // `IN`/`NOT IN` and the subscript/attribute/JSONB operators cannot
121            // be re-rendered as `(left OP right)` text; exclude them so the
122            // statement falls back instead of producing a broken template.
123            if matches!(
124                op,
125                linkrs_core::types::operators::BinaryOperator::In
126                    | linkrs_core::types::operators::BinaryOperator::NotIn
127                    | linkrs_core::types::operators::BinaryOperator::Subscript
128                    | linkrs_core::types::operators::BinaryOperator::Attribute
129                    | linkrs_core::types::operators::BinaryOperator::JsonGet
130                    | linkrs_core::types::operators::BinaryOperator::JsonGetText
131                    | linkrs_core::types::operators::BinaryOperator::JsonPathGet
132                    | linkrs_core::types::operators::BinaryOperator::JsonPathGetText
133            ) {
134                return None;
135            }
136            out.push('(');
137            render_expr(out, values, left)?;
138            out.push(' ');
139            out.push_str(op.name());
140            out.push(' ');
141            render_expr(out, values, right)?;
142            out.push(')');
143            Some(())
144        }
145        Expression::Unary { op, operand } => {
146            out.push('(');
147            out.push_str(op.name());
148            out.push(' ');
149            render_expr(out, values, operand)?;
150            out.push(')');
151            Some(())
152        }
153        Expression::Function { name, args } => {
154            out.push_str(name);
155            out.push('(');
156            for (index, arg) in args.iter().enumerate() {
157                if index > 0 {
158                    out.push_str(", ");
159                }
160                render_expr(out, values, arg.as_expr())?;
161            }
162            out.push(')');
163            Some(())
164        }
165        Expression::List(items) => {
166            out.push('[');
167            for (index, item) in items.iter().enumerate() {
168                if index > 0 {
169                    out.push_str(", ");
170                }
171                render_expr(out, values, item)?;
172            }
173            out.push(']');
174            Some(())
175        }
176        Expression::Map(pairs) => {
177            out.push('{');
178            for (index, (key, value)) in pairs.iter().enumerate() {
179                if index > 0 {
180                    out.push_str(", ");
181                }
182                out.push_str(key);
183                out.push_str(": ");
184                render_expr(out, values, value)?;
185            }
186            out.push('}');
187            Some(())
188        }
189        Expression::Label(name) => {
190            out.push(':');
191            out.push_str(name);
192            Some(())
193        }
194        Expression::TagProperty { tag_name, property } => {
195            out.push_str(tag_name);
196            out.push('.');
197            out.push_str(property);
198            Some(())
199        }
200        Expression::EdgeProperty {
201            edge_name,
202            property,
203        } => {
204            out.push_str(edge_name);
205            out.push('.');
206            out.push_str(property);
207            Some(())
208        }
209        Expression::LabelTagProperty { tag, property } => {
210            match tag.as_ref() {
211                Expression::Variable(name) => out.push_str(name),
212                Expression::Label(name) => out.push_str(name),
213                _ => return None,
214            }
215            out.push('.');
216            out.push_str(property);
217            Some(())
218        }
219        _ => None,
220    }
221}
222
223/// Emit a `@__dml_N` placeholder and record the literal value.
224fn push_param(out: &mut String, values: &mut Vec<Value>, value: Value) {
225    let index = values.len();
226    out.push('@');
227    out.push_str(DML_PARAM_PREFIX);
228    out.push_str(&index.to_string());
229    values.push(value);
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use crate::parser::ast::{InsertTarget, UpdateTarget};
236    use crate::parser::Parser;
237
238    fn parse(query: &str) -> Stmt {
239        let mut parser = Parser::new(query);
240        let result = parser
241            .parse()
242            .unwrap_or_else(|e| panic!("parse failed for {query:?}: {e}"));
243        assert!(!parser.has_errors(), "parse errors for {query:?}");
244        result.ast.stmt().clone()
245    }
246
247    #[test]
248    fn normalizes_insert_vertex_shape() {
249        let query = "INSERT VERTEX person(name, age) VALUES \"p00001\": (\"Alice\", 30)";
250        let stmt = parse(query);
251        let shape = normalize_shape(&stmt).expect("shape should normalize");
252        assert_eq!(
253            shape.normalized_text,
254            "INSERT VERTEX person(name, age) VALUES @__dml_0: (@__dml_1, @__dml_2)"
255        );
256        assert_eq!(shape.values.len(), 3);
257        assert_eq!(shape.values[0], Value::from("p00001"));
258        assert_eq!(shape.values[1], Value::from("Alice"));
259        assert_eq!(shape.values[2], Value::from(30i64));
260    }
261
262    #[test]
263    fn normalizes_insert_edge_shape() {
264        let query = "INSERT EDGE works_at(position, salary) VALUES \"p00001\" -> \"c0001\" @0: (\"Engineer\", 45000)";
265        let stmt = parse(query);
266        let shape = normalize_shape(&stmt).expect("shape should normalize");
267        assert_eq!(
268            shape.normalized_text,
269            "INSERT EDGE works_at(position, salary) VALUES @__dml_0 -> @__dml_1 @@__dml_2: (@__dml_3, @__dml_4)"
270        );
271        assert_eq!(shape.values.len(), 5);
272    }
273
274    #[test]
275    fn same_shape_produces_same_normalized_text() {
276        let a = parse("INSERT VERTEX user(id, name) VALUES \"u1\": (\"Bob\", 1)");
277        let b = parse("INSERT VERTEX user(id, name) VALUES \"u2\": (\"Alice\", 2)");
278        let shape_a = normalize_shape(&a).expect("shape a");
279        let shape_b = normalize_shape(&b).expect("shape b");
280        assert_eq!(shape_a.normalized_text, shape_b.normalized_text);
281    }
282
283    #[test]
284    fn normalized_text_reparses_with_parameters() {
285        let query = "INSERT VERTEX person(name, age) VALUES \"p00001\": (\"Alice\", 30)";
286        let stmt = parse(query);
287        let shape = normalize_shape(&stmt).expect("shape should normalize");
288        let reparsed = parse(&shape.normalized_text);
289        match reparsed {
290            Stmt::Insert(insert) => {
291                assert!(matches!(insert.target, InsertTarget::Vertices { .. }));
292                if let InsertTarget::Vertices { values, .. } = &insert.target {
293                    assert!(values[0].vid.is_parameter());
294                }
295            }
296            _ => panic!("reparse should produce INSERT"),
297        }
298    }
299
300    #[test]
301    fn normalizes_if_not_exists_and_multi_row() {
302        let query =
303            "INSERT VERTEX IF NOT EXISTS person(name) VALUES \"p1\": (\"A\"), \"p2\": (\"B\")";
304        let stmt = parse(query);
305        let shape = normalize_shape(&stmt).expect("shape should normalize");
306        assert_eq!(
307            shape.normalized_text,
308            "INSERT VERTEX IF NOT EXISTS person(name) VALUES @__dml_0: (@__dml_1), @__dml_2: (@__dml_3)"
309        );
310        assert_eq!(shape.values.len(), 4);
311    }
312
313    #[test]
314    fn parameterizes_nested_literal_in_insert() {
315        let query = "INSERT VERTEX user(id, name) VALUES \"u1\": (upper(\"bob\"), 1)";
316        let stmt = parse(query);
317        let shape = normalize_shape(&stmt).expect("shape should normalize");
318        assert_eq!(
319            shape.normalized_text,
320            "INSERT VERTEX user(id, name) VALUES @__dml_0: (upper(@__dml_1), @__dml_2)"
321        );
322    }
323
324    #[test]
325    fn skips_unrenderable_expression() {
326        let query = "UPDATE VERTEX \"v1\" SET name = \"x\" WHERE arr[0] > 1";
327        let stmt = parse(query);
328        assert!(normalize_shape(&stmt).is_none());
329    }
330
331    #[test]
332    fn skip_non_dml() {
333        let stmt = parse("MATCH (n:person) RETURN n");
334        assert!(normalize_shape(&stmt).is_none());
335    }
336
337    #[test]
338    fn normalizes_update_vertex() {
339        let query = "UPDATE VERTEX \"v1\" SET name = \"x\"";
340        let stmt = parse(query);
341        let shape = normalize_shape(&stmt).expect("shape should normalize");
342        assert_eq!(
343            shape.normalized_text,
344            "UPDATE VERTEX @__dml_0 SET name = @__dml_1"
345        );
346        assert_eq!(shape.values.len(), 2);
347    }
348
349    #[test]
350    fn normalizes_update_edge_long_form() {
351        let query = "UPDATE EDGE OF works_at FROM \"p1\" TO \"p2\" @1 SET salary = 100";
352        let stmt = parse(query);
353        let shape = normalize_shape(&stmt).expect("shape should normalize");
354        assert_eq!(
355            shape.normalized_text,
356            "UPDATE EDGE OF works_at FROM @__dml_0 TO @__dml_1 @@__dml_2 SET salary = @__dml_3"
357        );
358    }
359
360    #[test]
361    fn normalizes_upsert_edge_short_form() {
362        let query = "UPSERT EDGE \"p1\" -> \"p2\" @1 OF works_at SET salary = 100";
363        let stmt = parse(query);
364        let shape = normalize_shape(&stmt).expect("shape should normalize");
365        assert_eq!(
366            shape.normalized_text,
367            "UPSERT EDGE @__dml_0 -> @__dml_1 @@__dml_2 OF works_at SET salary = @__dml_3"
368        );
369    }
370
371    #[test]
372    fn normalizes_update_with_where() {
373        let query = "UPDATE VERTEX \"v1\" SET name = \"x\" WHERE age > 30";
374        let stmt = parse(query);
375        let shape = normalize_shape(&stmt).expect("shape should normalize");
376        assert_eq!(
377            shape.normalized_text,
378            "UPDATE VERTEX @__dml_0 SET name = @__dml_1 WHERE (age > @__dml_2)"
379        );
380    }
381
382    #[test]
383    fn update_reparse_matches_target() {
384        let query = "UPDATE VERTEX \"v1\" SET name = \"x\" WHERE age > 30";
385        let stmt = parse(query);
386        let shape = normalize_shape(&stmt).expect("shape should normalize");
387        let reparsed = parse(&shape.normalized_text);
388        match reparsed {
389            Stmt::Update(update) => {
390                assert!(matches!(update.target, UpdateTarget::Vertex(_)));
391                assert!(update.where_clause.is_some());
392            }
393            _ => panic!("reparse should produce UPDATE"),
394        }
395    }
396
397    #[test]
398    fn update_with_yield_is_skipped() {
399        let query = "UPSERT VERTEX ON person SET name = \"x\" WHERE id(vid) == \"v1\" YIELD name";
400        let stmt = parse(query);
401        assert!(normalize_shape(&stmt).is_none());
402    }
403
404    #[test]
405    fn normalizes_delete_vertex_list() {
406        let query = "DELETE VERTEX person FROM \"v1\", \"v2\"";
407        let stmt = parse(query);
408        let shape = normalize_shape(&stmt).expect("shape should normalize");
409        assert_eq!(
410            shape.normalized_text,
411            "DELETE VERTEX person FROM @__dml_0, @__dml_1"
412        );
413    }
414
415    #[test]
416    fn normalizes_delete_edge() {
417        let query = "DELETE EDGE works_at \"s1\" -> \"d1\" @0";
418        let stmt = parse(query);
419        let shape = normalize_shape(&stmt).expect("shape should normalize");
420        assert_eq!(
421            shape.normalized_text,
422            "DELETE EDGE works_at @__dml_0 -> @__dml_1 @@__dml_2"
423        );
424    }
425
426    #[test]
427    fn normalizes_delete_tag_with_edge() {
428        let query = "DELETE VERTEX person FROM \"v1\" WITH EDGE";
429        let stmt = parse(query);
430        let shape = normalize_shape(&stmt).expect("shape should normalize");
431        assert_eq!(
432            shape.normalized_text,
433            "DELETE VERTEX person FROM @__dml_0 WITH EDGE"
434        );
435    }
436
437    #[test]
438    fn normalizes_merge_node_with_properties() {
439        let query = "MERGE (n:person {name: \"a\"}) ON CREATE SET age = 1";
440        let stmt = parse(query);
441        let shape = normalize_shape(&stmt).expect("shape should normalize");
442        assert_eq!(
443            shape.normalized_text,
444            "MERGE (n:person {name: @__dml_0}) ON CREATE SET age = @__dml_1"
445        );
446    }
447
448    #[test]
449    fn normalizes_merge_path() {
450        let query = "MERGE (a:person)-[:knows]->(b:person) ON MATCH SET score = 5";
451        let stmt = parse(query);
452        let shape = normalize_shape(&stmt).expect("shape should normalize");
453        assert_eq!(
454            shape.normalized_text,
455            "MERGE (a:person)-[:knows]->(b:person) ON MATCH SET score = @__dml_0"
456        );
457    }
458
459    #[test]
460    fn normalizes_set_statement() {
461        let query = "SET name = \"x\", age = 30";
462        let stmt = parse(query);
463        let shape = normalize_shape(&stmt).expect("shape should normalize");
464        assert_eq!(shape.normalized_text, "SET name = @__dml_0, age = @__dml_1");
465    }
466
467    #[test]
468    fn remove_without_literals_is_skipped() {
469        let query = "REMOVE v.name, v.age";
470        let stmt = parse(query);
471        assert!(normalize_shape(&stmt).is_none());
472    }
473
474    #[test]
475    fn skips_non_roundtrippable_binary_operators() {
476        use linkrs_core::types::operators::BinaryOperator;
477        let cases = [
478            BinaryOperator::In,
479            BinaryOperator::NotIn,
480            BinaryOperator::Subscript,
481            BinaryOperator::Attribute,
482            BinaryOperator::JsonGet,
483            BinaryOperator::JsonGetText,
484            BinaryOperator::JsonPathGet,
485            BinaryOperator::JsonPathGetText,
486        ];
487        for op in cases {
488            let expr = Expression::binary(Expression::variable("a"), op, Expression::variable("b"));
489            let mut out = String::new();
490            let mut values = Vec::new();
491            assert!(
492                render_expr(&mut out, &mut values, &expr).is_none(),
493                "op {op:?} should be excluded"
494            );
495        }
496    }
497}