Skip to main content

camel_component_sql/
query.rs

1//! Query template parsing and parameter resolution for SQL components.
2//!
3//! This module provides parsing of Camel-style SQL templates with parameter
4//! placeholders and resolution of those parameters from an Exchange.
5//!
6//! TODO(SQL-006): Named parameters using `:name`-style syntax (e.g.
7//! `SELECT * FROM users WHERE name = :name`) are not yet supported in
8//! downstream SQL execution. The query template parser supports `:#name`
9//! (Camel-style `:#` prefix) and positional `#`, but the underlying database
10//! driver binding currently only supports positional `$1, $2, ...` params.
11
12use camel_api::ExchangeLookupPath;
13use camel_component_api::{Body, CamelError, Exchange};
14
15/// A parsed parameter slot in a query template.
16#[derive(Debug, Clone, PartialEq)]
17pub enum ParamSlot {
18    /// Positional parameter (#) — index is the 0-based position among all positional params
19    Positional(usize),
20    /// Named parameter (:#name) — resolved from headers/body/properties
21    Named(String),
22    /// IN clause parameter (:#in:name) — expanded to multiple $N placeholders
23    InClause(String),
24    /// Dynamic expression parameter (:#${expr}) — resolved from body/header/property paths
25    Expression(String),
26}
27
28/// A parsed query template with fragments and parameter slots.
29#[derive(Debug, Clone)]
30pub struct QueryTemplate {
31    /// Text fragments between parameters. Always has one more element than params.
32    pub fragments: Vec<String>,
33    /// Parameter slots in order of appearance.
34    pub params: Vec<ParamSlot>,
35}
36
37/// A fully resolved query ready for execution.
38#[derive(Debug, Clone)]
39pub struct PreparedQuery {
40    /// The final SQL with $1, $2, ... placeholders.
41    pub sql: String,
42    /// The binding values in order.
43    pub bindings: Vec<serde_json::Value>,
44}
45
46/// Parses a Camel-style SQL template into fragments and parameter slots.
47///
48/// Token types (using `#` as default placeholder):
49/// - `:#in:name` → InClause("name") — IN clause expansion
50/// - `:#${expr}` → Expression("expr") — dynamic expression (body.field, header.name, property.key)
51/// - `:#name` → Named("name") — named parameter (name is alphanumeric + underscore)
52/// - `#` (standalone) → Positional(N) — positional parameter (N increments per positional)
53///
54/// # Arguments
55/// * `template` - The SQL template string to parse
56/// * `placeholder` - The character used as placeholder (typically '#')
57///
58/// # Returns
59/// A `QueryTemplate` with fragments and parameter slots.
60pub fn parse_query_template(
61    template: &str,
62    placeholder: char,
63) -> Result<QueryTemplate, CamelError> {
64    let mut fragments = Vec::new();
65    let mut params = Vec::new();
66    let mut positional_index = 0usize;
67
68    let chars: Vec<char> = template.chars().collect();
69    let mut i = 0usize;
70    let mut last_param_end = 0usize;
71    let mut in_literal = false;
72
73    while i < chars.len() {
74        // Handle string literals
75        if chars[i] == '\'' {
76            // Handle escaped quote '' inside literal
77            if in_literal && i + 1 < chars.len() && chars[i + 1] == '\'' {
78                i += 2;
79                continue;
80            }
81            in_literal = !in_literal;
82            i += 1;
83            continue;
84        }
85
86        // Only process placeholder when not inside a string literal
87        if !in_literal && chars[i] == placeholder {
88            // Check if preceded by ':'
89            if i > 0 && chars[i - 1] == ':' {
90                // Named, Expression, or InClause parameter
91                // Check if followed by 'in:' for InClause
92                // Pattern: :#in:name
93                //          ^  ^^^^
94                //          |  check these chars after #
95                let is_in_clause = check_in_prefix(&chars, i + 1);
96                // Check for expression syntax :#${expr}
97                // Pattern: :#${expr}
98                //          ^  ^^
99                //          |  check $ and { after #
100                let is_expression =
101                    i + 2 < chars.len() && chars[i + 1] == '$' && chars[i + 2] == '{';
102
103                if is_in_clause {
104                    // InClause parameter - extract name after ':#in:'
105                    // i is at '#', so name starts at i + 4 (after 'in:')
106                    let name_start = i + 4;
107                    let (name, name_end) = extract_param_name(&chars, name_start);
108
109                    if name.is_empty() {
110                        return Err(CamelError::ProcessorError(format!(
111                            "Empty IN clause parameter name at position {}",
112                            i
113                        )));
114                    }
115
116                    // Fragment ends at ':' (position i-1)
117                    fragments.push(chars[last_param_end..(i - 1)].iter().collect());
118
119                    params.push(ParamSlot::InClause(name));
120                    last_param_end = name_end;
121                    i = name_end;
122                } else if is_expression {
123                    // Expression parameter - extract content between { and }
124                    // i is at '#', so $ is at i + 1, { is at i + 2
125                    let brace_start = i + 2;
126                    if let Some(brace_end) = find_matching_brace(&chars, brace_start) {
127                        // Extract expression content (between { and })
128                        let expr_content: String =
129                            chars[(brace_start + 1)..brace_end].iter().collect();
130
131                        if expr_content.is_empty() {
132                            return Err(CamelError::ProcessorError(format!(
133                                "Empty expression at position {}",
134                                i
135                            )));
136                        }
137
138                        // Fragment ends at ':' (position i-1)
139                        fragments.push(chars[last_param_end..(i - 1)].iter().collect());
140
141                        params.push(ParamSlot::Expression(expr_content));
142                        last_param_end = brace_end + 1;
143                        i = brace_end + 1;
144                    } else {
145                        return Err(CamelError::ProcessorError(format!(
146                            "Unclosed expression at position {}",
147                            i
148                        )));
149                    }
150                } else {
151                    // Named parameter - extract name after ':#'
152                    let name_start = i + 1;
153                    let (name, name_end) = extract_param_name(&chars, name_start);
154
155                    if name.is_empty() {
156                        return Err(CamelError::ProcessorError(format!(
157                            "Empty named parameter name at position {}",
158                            i
159                        )));
160                    }
161
162                    // Fragment ends at ':' (position i-1)
163                    fragments.push(chars[last_param_end..(i - 1)].iter().collect());
164
165                    params.push(ParamSlot::Named(name));
166                    last_param_end = name_end;
167                    i = name_end;
168                }
169            } else {
170                // Positional parameter
171                // Fragment ends at this '#'
172                fragments.push(chars[last_param_end..i].iter().collect());
173
174                params.push(ParamSlot::Positional(positional_index));
175                positional_index += 1;
176                last_param_end = i + 1;
177                i += 1;
178            }
179        } else {
180            i += 1;
181        }
182    }
183
184    // Add final fragment (everything after last param, or whole string if no params)
185    fragments.push(chars[last_param_end..].iter().collect());
186
187    Ok(QueryTemplate { fragments, params })
188}
189
190/// Check if chars starting at position `start` begin with "in:"
191fn check_in_prefix(chars: &[char], start: usize) -> bool {
192    let in_prefix: Vec<char> = "in:".chars().collect();
193    if start + in_prefix.len() > chars.len() {
194        return false;
195    }
196    chars[start..start + in_prefix.len()] == in_prefix[..]
197}
198
199/// Extract a parameter name starting at the given position.
200///
201/// Reads until a SQL delimiter is encountered. The terminator set is:
202/// whitespace, `?`, `(`, `)`, `,`, `;`, `=`, `<`, `>`, `!`, `'`, `"`, `` ` ``,
203/// `/`, and `:`. Single `:` and Postgres `::` casts both terminate the name
204/// (see rc-o6o Phase 2 §3.2 — `:#id::text` resolves `id` and leaves `::text`
205/// as SQL). Names may contain alphanumerics, `_`, `-`, `.`, and other
206/// characters valid in map keys.
207///
208/// Returns `(name, end_position)` where `end_position` is the index of the
209/// terminator (or `chars.len()` at end-of-string).
210fn extract_param_name(chars: &[char], start: usize) -> (String, usize) {
211    let mut name = String::new();
212    let mut i = start;
213    while i < chars.len() {
214        let c = chars[i];
215        if is_name_terminator(c) {
216            break;
217        }
218        name.push(c);
219        i += 1;
220    }
221    (name, i)
222}
223
224/// Returns true when `c` ends a placeholder name. See [`extract_param_name`].
225fn is_name_terminator(c: char) -> bool {
226    c.is_whitespace()
227        || matches!(
228            c,
229            '?' | '(' | ')' | ',' | ';' | '=' | '<' | '>' | '!' | '\'' | '"' | '`' | '/' | ':'
230        )
231}
232
233/// Finds the closing brace matching an opening brace at `start` (0-indexed into chars).
234/// Returns the index of `}` or None if not found.
235fn find_matching_brace(chars: &[char], start: usize) -> Option<usize> {
236    // Simple scan for matching } (SQL expressions won't have nested braces)
237    chars[start..]
238        .iter()
239        .position(|&c| c == '}')
240        .map(|p| start + p)
241}
242
243/// Resolves parameter values from the exchange and builds the final SQL.
244///
245/// Resolution rules:
246/// - **Named**: Look up value in order: (1) body if it's a JSON object → look for key,
247///   (2) exchange input headers, (3) exchange properties. Error if not found anywhere.
248/// - **Positional**: Body must be a JSON array → use index. Error if out of bounds.
249/// - **InClause**: Resolve same as Named, but value must be a JSON array → expand to
250///   multiple `$N, $N+1, ...` placeholders.
251///
252/// The output SQL uses `$1, $2, ...` numbered placeholders (sqlx style).
253///
254/// # Arguments
255/// * `tpl` - The parsed query template
256/// * `exchange` - The exchange containing message body, headers, and properties
257/// * `in_separator` - Separator between values in IN clause expansion
258///
259/// # Returns
260/// A `PreparedQuery` with the final SQL and bindings.
261pub fn resolve_params(
262    tpl: &QueryTemplate,
263    exchange: &Exchange,
264    in_separator: &str,
265) -> Result<PreparedQuery, CamelError> {
266    let mut sql_parts = Vec::new();
267    let mut bindings = Vec::new();
268    let mut placeholder_num = 1usize;
269
270    // Pre-extract body as JSON array for POSITIONAL params only.
271    // (Named / InClause / Expression now route through ExchangeLookupPath.)
272    let body_array = match &exchange.input.body {
273        Body::Json(val) => val.as_array(),
274        _ => None,
275    };
276
277    for (i, param) in tpl.params.iter().enumerate() {
278        // Add fragment before this param
279        sql_parts.push(tpl.fragments[i].clone());
280
281        match param {
282            ParamSlot::Positional(idx) => {
283                let arr = body_array.ok_or_else(|| {
284                    CamelError::ProcessorError(
285                        "Positional parameter requires body to be a JSON array".to_string(),
286                    )
287                })?;
288
289                let value = arr.get(*idx).ok_or_else(|| {
290                    CamelError::ProcessorError(format!(
291                        "Positional parameter index {} out of bounds (array length {})",
292                        idx,
293                        arr.len()
294                    ))
295                })?;
296
297                sql_parts.push(format!("${}", placeholder_num));
298                placeholder_num += 1;
299                bindings.push(value.clone());
300            }
301            ParamSlot::Named(name) => {
302                let value = resolve_named_param(name, exchange)?;
303                sql_parts.push(format!("${}", placeholder_num));
304                placeholder_num += 1;
305                bindings.push(value);
306            }
307            ParamSlot::InClause(name) => {
308                let value = resolve_named_param(name, exchange)?;
309
310                let arr = value.as_array().ok_or_else(|| {
311                    CamelError::ProcessorError(format!(
312                        "IN clause parameter '{}' must be an array, got type {}",
313                        name,
314                        match &value {
315                            serde_json::Value::Null => "null",
316                            serde_json::Value::Bool(_) => "bool",
317                            serde_json::Value::Number(_) => "number",
318                            serde_json::Value::String(_) => "string",
319                            serde_json::Value::Array(_) => "array",
320                            serde_json::Value::Object(_) => "object",
321                        }
322                    ))
323                })?;
324
325                if arr.is_empty() {
326                    // Empty IN clause - emit NULL to produce valid SQL like "IN (NULL)"
327                    // which matches nothing (no bindings needed for NULL literal)
328                    sql_parts.push("NULL".to_string());
329                } else {
330                    let placeholders: Vec<String> = arr
331                        .iter()
332                        .map(|_| {
333                            let p = format!("${}", placeholder_num);
334                            placeholder_num += 1;
335                            p
336                        })
337                        .collect();
338
339                    // Just output comma-separated placeholders, template has parentheses
340                    sql_parts.push(placeholders.join(in_separator));
341                    bindings.extend(arr.iter().cloned());
342                }
343            }
344            ParamSlot::Expression(expr) => {
345                let value = resolve_expression_param(expr, exchange)?;
346                sql_parts.push(format!("${}", placeholder_num));
347                placeholder_num += 1;
348                bindings.push(value);
349            }
350        }
351    }
352
353    // Add final fragment
354    sql_parts.push(tpl.fragments.last().cloned().unwrap_or_default());
355
356    Ok(PreparedQuery {
357        sql: sql_parts.join(""),
358        bindings,
359    })
360}
361
362/// Resolves a named parameter (`:#name`, `:#in:name`, or the identifier portion
363/// of `:#${expr}`) via the shared [`ExchangeLookupPath`] grammar.
364///
365/// Resolution order:
366/// - `body.x.y` walks JSON; `header.x.y` / `property.x.y` / `exchangeProperty.x.y`
367///   are flat keys (maps not nested); anything else is unscoped (body JSON key
368///   → header → property).
369///
370/// Returns `CamelError::ProcessorError` when the path does not resolve.
371fn resolve_named_param(name: &str, exchange: &Exchange) -> Result<serde_json::Value, CamelError> {
372    let path = ExchangeLookupPath::parse(name).map_err(|e| {
373        CamelError::ProcessorError(format!("Invalid placeholder name {name:?}: {e}"))
374    })?;
375    // SQL binds scalars. Bare reserved scope names like `:#body` parse as
376    // `Body(vec![])` (whole body, meaningful for Simple `${body}`) but are
377    // ambiguous for SQL — reject explicitly per e_gpt oracle blessing #2.
378    if let ExchangeLookupPath::Body(segments) = &path
379        && segments.is_empty()
380    {
381        return Err(CamelError::ProcessorError(format!(
382            "SQL placeholder ':#{name}' requires a path (e.g. ':#{name}.field') — bare scope not supported"
383        )));
384    }
385    path.lookup(exchange).ok_or_else(|| {
386        CamelError::ProcessorError(format!(
387            "Named parameter '{name}' not found in body, headers, or properties"
388        ))
389    })
390}
391
392/// Resolves an expression parameter (`:#${expr}`). The expr is the literal
393/// content between `{` and `}` — it follows the same [`ExchangeLookupPath``
394/// grammar as `:#name`. Simple language delegates (`:#${jsonpath:$.a}`) are
395/// NOT supported here; that path belongs to the Simple language itself, not
396/// SQL's `:#${...}` shape.
397fn resolve_expression_param(
398    expr: &str,
399    exchange: &Exchange,
400) -> Result<serde_json::Value, CamelError> {
401    // Same grammar, distinct error message so users can tell which placeholder
402    // form they hit (the SQL fragment around the param is the real clue, but
403    // the message prefix removes ambiguity for log-only contexts).
404    let path = ExchangeLookupPath::parse(expr)
405        .map_err(|e| CamelError::ProcessorError(format!("Invalid expression {expr:?}: {e}")))?;
406    // Same bare-scope rejection as `resolve_named_param` — `:#${body}` is
407    // ambiguous for SQL just like `:#body`.
408    if let ExchangeLookupPath::Body(segments) = &path
409        && segments.is_empty()
410    {
411        return Err(CamelError::ProcessorError(format!(
412            "SQL expression ':#${{{expr}}}' requires a path (e.g. ':#${{{expr}.field}}') — bare scope not supported"
413        )));
414    }
415    path.lookup(exchange).ok_or_else(|| {
416        CamelError::ProcessorError(format!(
417            "Expression '{expr}' not found in body, headers, or properties"
418        ))
419    })
420}
421
422/// Returns true if SQL should run through select path.
423pub fn is_select_query(sql: &str) -> bool {
424    let trimmed = sql.trim_start().to_uppercase();
425    if trimmed.starts_with("WITH") {
426        return trimmed.contains("SELECT")
427            && !trimmed.contains("INSERT INTO")
428            && !trimmed.contains("UPDATE ")
429            && !trimmed.contains("DELETE FROM");
430    }
431
432    trimmed.starts_with("SELECT")
433        || trimmed.starts_with("VALUES")
434        || trimmed.starts_with("TABLE")
435        || trimmed.starts_with("SHOW")
436        || trimmed.starts_with("EXPLAIN")
437}
438
439const DEFAULT_IN_SEPARATOR: &str = ", ";
440
441pub fn resolve_params_default(
442    tpl: &QueryTemplate,
443    exchange: &Exchange,
444) -> Result<PreparedQuery, CamelError> {
445    resolve_params(tpl, exchange, DEFAULT_IN_SEPARATOR)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use camel_component_api::{Body, Exchange, Message};
452
453    #[test]
454    fn parse_no_params() {
455        let tpl = parse_query_template("select * from users", '#').unwrap();
456        assert_eq!(tpl.fragments.len(), 1);
457        assert!(tpl.params.is_empty());
458    }
459
460    #[test]
461    fn parse_positional_params() {
462        let tpl = parse_query_template("insert into t values (#, #)", '#').unwrap();
463        assert_eq!(tpl.params.len(), 2);
464        assert!(matches!(tpl.params[0], ParamSlot::Positional(0)));
465        assert!(matches!(tpl.params[1], ParamSlot::Positional(1)));
466    }
467
468    #[test]
469    fn parse_named_params() {
470        let tpl =
471            parse_query_template("select * from t where id = :#id and name = :#name", '#').unwrap();
472        assert_eq!(tpl.params.len(), 2);
473        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"));
474        assert!(matches!(&tpl.params[1], ParamSlot::Named(n) if n == "name"));
475    }
476
477    #[test]
478    fn parse_mixed_params() {
479        let tpl =
480            parse_query_template("select * from t where id = :#id and status = #", '#').unwrap();
481        assert_eq!(tpl.params.len(), 2);
482        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"));
483        assert!(matches!(tpl.params[1], ParamSlot::Positional(0)));
484    }
485
486    #[test]
487    fn parse_in_clause() {
488        let tpl = parse_query_template("select * from t where id in (:#in:ids)", '#').unwrap();
489        assert_eq!(tpl.params.len(), 1);
490        assert!(matches!(&tpl.params[0], ParamSlot::InClause(n) if n == "ids"));
491    }
492
493    #[test]
494    fn resolve_named_from_headers() {
495        let tpl = parse_query_template("select * from t where id = :#id", '#').unwrap();
496        let mut msg = Message::default();
497        msg.set_header("id", serde_json::json!(42));
498        let ex = Exchange::new(msg);
499
500        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
501        assert_eq!(prepared.sql, "select * from t where id = $1");
502        assert_eq!(prepared.bindings.len(), 1);
503        assert_eq!(prepared.bindings[0], serde_json::json!(42));
504    }
505
506    #[test]
507    fn resolve_named_from_body_map() {
508        let tpl = parse_query_template("select * from t where id = :#id", '#').unwrap();
509        let msg = Message::new(Body::Json(serde_json::json!({"id": 99})));
510        let ex = Exchange::new(msg);
511
512        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
513        assert_eq!(prepared.bindings[0], serde_json::json!(99));
514    }
515
516    #[test]
517    fn resolve_positional_from_body_array() {
518        let tpl = parse_query_template("insert into t values (#, #)", '#').unwrap();
519        let msg = Message::new(Body::Json(serde_json::json!(["foo", 42])));
520        let ex = Exchange::new(msg);
521
522        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
523        assert_eq!(prepared.sql, "insert into t values ($1, $2)");
524        assert_eq!(prepared.bindings[0], serde_json::json!("foo"));
525        assert_eq!(prepared.bindings[1], serde_json::json!(42));
526    }
527
528    #[test]
529    fn resolve_named_from_properties() {
530        let tpl = parse_query_template("select * from t where id = :#myProp", '#').unwrap();
531        let mut ex = Exchange::new(Message::default());
532        ex.set_property("myProp", serde_json::json!(7));
533
534        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
535        assert_eq!(prepared.bindings[0], serde_json::json!(7));
536    }
537
538    #[test]
539    fn resolve_named_not_found() {
540        let tpl = parse_query_template("select * from t where id = :#missing", '#').unwrap();
541        let ex = Exchange::new(Message::default());
542
543        let result = resolve_params(&tpl, &ex, ", ");
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn resolve_in_clause_expansion() {
549        let tpl = parse_query_template("select * from t where id in (:#in:ids)", '#').unwrap();
550        let mut msg = Message::default();
551        msg.set_header("ids", serde_json::json!([1, 2, 3]));
552        let ex = Exchange::new(msg);
553
554        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
555        assert_eq!(prepared.sql, "select * from t where id in ($1, $2, $3)");
556        assert_eq!(
557            prepared.bindings,
558            vec![
559                serde_json::json!(1),
560                serde_json::json!(2),
561                serde_json::json!(3)
562            ]
563        );
564    }
565
566    #[test]
567    fn build_sql_correct_placeholders() {
568        let tpl = parse_query_template(
569            "select * from t where a = :#x and b = # and c in (:#in:ids)",
570            '#',
571        )
572        .unwrap();
573        let mut msg = Message::new(Body::Json(serde_json::json!(["pos_val"])));
574        msg.set_header("x", serde_json::json!("hello"));
575        msg.set_header("ids", serde_json::json!([10, 20]));
576        let ex = Exchange::new(msg);
577
578        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
579        assert_eq!(
580            prepared.sql,
581            "select * from t where a = $1 and b = $2 and c in ($3, $4)"
582        );
583        assert_eq!(prepared.bindings.len(), 4);
584    }
585
586    #[test]
587    fn is_select() {
588        assert!(is_select_query("SELECT * FROM t"));
589        assert!(is_select_query("  select * from t"));
590        assert!(is_select_query("WITH cte AS (SELECT 1) SELECT * FROM cte"));
591        assert!(is_select_query(
592            "with results as (select 1) select * from results"
593        ));
594        assert!(!is_select_query(
595            "WITH cte AS (SELECT id FROM t) INSERT INTO other SELECT * FROM cte"
596        ));
597        assert!(is_select_query("TABLE users"));
598        assert!(is_select_query("SHOW TABLES"));
599        assert!(is_select_query("EXPLAIN SELECT * FROM t"));
600        assert!(!is_select_query("INSERT INTO t VALUES (1)"));
601        assert!(!is_select_query("UPDATE t SET x = 1"));
602        assert!(!is_select_query("DELETE FROM t"));
603    }
604
605    #[test]
606    fn parse_trailing_param() {
607        let tpl = parse_query_template("select * from t where id = #", '#').unwrap();
608        assert_eq!(tpl.params.len(), 1);
609        assert_eq!(tpl.fragments.len(), 2);
610        assert_eq!(tpl.fragments[0], "select * from t where id = ");
611        assert_eq!(tpl.fragments[1], "");
612    }
613
614    #[test]
615    fn parse_leading_param() {
616        let tpl = parse_query_template("# = id", '#').unwrap();
617        assert_eq!(tpl.params.len(), 1);
618        assert_eq!(tpl.fragments.len(), 2);
619        assert_eq!(tpl.fragments[0], "");
620        assert_eq!(tpl.fragments[1], " = id");
621    }
622
623    #[test]
624    fn parse_consecutive_params() {
625        let tpl = parse_query_template("# # #", '#').unwrap();
626        assert_eq!(tpl.params.len(), 3);
627        assert_eq!(tpl.fragments.len(), 4);
628        assert_eq!(tpl.fragments[0], "");
629        assert_eq!(tpl.fragments[1], " ");
630        assert_eq!(tpl.fragments[2], " ");
631        assert_eq!(tpl.fragments[3], "");
632    }
633
634    #[test]
635    fn resolution_priority_body_over_headers() {
636        // Body should take priority over headers
637        let tpl = parse_query_template("select * from t where id = :#id", '#').unwrap();
638        let mut msg = Message::new(Body::Json(serde_json::json!({"id": 1})));
639        msg.set_header("id", serde_json::json!(2)); // This should be ignored
640        let ex = Exchange::new(msg);
641
642        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
643        assert_eq!(prepared.bindings[0], serde_json::json!(1)); // From body, not header
644    }
645
646    #[test]
647    fn resolution_priority_headers_over_properties() {
648        // Headers should take priority over properties
649        let tpl = parse_query_template("select * from t where id = :#id", '#').unwrap();
650        let mut msg = Message::default();
651        msg.set_header("id", serde_json::json!(10));
652        let mut ex = Exchange::new(msg);
653        ex.set_property("id", serde_json::json!(20)); // This should be ignored
654
655        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
656        assert_eq!(prepared.bindings[0], serde_json::json!(10)); // From header, not property
657    }
658
659    #[test]
660    fn custom_placeholder_char() {
661        let tpl = parse_query_template("select * from t where id = :@id", '@').unwrap();
662        assert_eq!(tpl.params.len(), 1);
663        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"));
664    }
665
666    #[test]
667    fn parse_expression_param() {
668        let tpl = parse_query_template("select * from t where id = :#${body.id}", '#').unwrap();
669        assert_eq!(tpl.params.len(), 1);
670        assert!(matches!(&tpl.params[0], ParamSlot::Expression(e) if e == "body.id"));
671    }
672
673    #[test]
674    fn resolve_expression_from_body() {
675        let tpl = parse_query_template("select * from t where id = :#${body.id}", '#').unwrap();
676        let msg = Message::new(Body::Json(serde_json::json!({"id": 42})));
677        let ex = Exchange::new(msg);
678        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
679        assert_eq!(prepared.sql, "select * from t where id = $1");
680        assert_eq!(prepared.bindings[0], serde_json::json!(42));
681    }
682
683    #[test]
684    fn resolve_expression_from_header() {
685        let tpl =
686            parse_query_template("select * from t where name = :#${header.name}", '#').unwrap();
687        let mut msg = Message::default();
688        msg.set_header("name", serde_json::json!("alice"));
689        let ex = Exchange::new(msg);
690        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
691        assert_eq!(prepared.bindings[0], serde_json::json!("alice"));
692    }
693
694    #[test]
695    fn resolve_expression_from_property() {
696        let tpl =
697            parse_query_template("select * from t where k = :#${property.myKey}", '#').unwrap();
698        let mut ex = Exchange::new(Message::default());
699        ex.set_property("myKey", serde_json::json!(99));
700        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
701        assert_eq!(prepared.bindings[0], serde_json::json!(99));
702    }
703
704    #[test]
705    fn parse_hash_in_string_literal() {
706        // # inside a string literal should NOT be treated as a parameter
707        let tpl =
708            parse_query_template("select * from t where x = '#literal' and id = #", '#').unwrap();
709        assert_eq!(tpl.params.len(), 1);
710        assert!(matches!(tpl.params[0], ParamSlot::Positional(0)));
711    }
712
713    #[test]
714    fn parse_escaped_quote_in_literal() {
715        // '' inside a string literal is an escaped quote, not end of literal
716        let tpl =
717            parse_query_template("select * from t where x = 'it''s' and id = #", '#').unwrap();
718        assert_eq!(tpl.params.len(), 1);
719        assert!(matches!(tpl.params[0], ParamSlot::Positional(0)));
720    }
721
722    #[test]
723    fn empty_in_clause_produces_null() {
724        let tpl = parse_query_template("select * from t where id in (:#in:ids)", '#').unwrap();
725        let mut msg = Message::default();
726        msg.set_header("ids", serde_json::json!([]));
727        let ex = Exchange::new(msg);
728        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
729        assert_eq!(prepared.sql, "select * from t where id in (NULL)");
730        assert!(prepared.bindings.is_empty());
731    }
732
733    #[test]
734    fn in_clause_custom_separator() {
735        let tpl = parse_query_template("select * from t where id in (:#in:ids)", '#').unwrap();
736        let mut msg = Message::default();
737        msg.set_header("ids", serde_json::json!([1, 2, 3]));
738        let ex = Exchange::new(msg);
739
740        let prepared = resolve_params(&tpl, &ex, ";").unwrap();
741        assert_eq!(prepared.sql, "select * from t where id in ($1;$2;$3)");
742    }
743
744    #[test]
745    fn parse_kebab_case_named_param() {
746        // `:#my-param` should parse the WHOLE "my-param" as the name. The current
747        // parser stops at `-` (not alphanumeric), producing `Named("my")` and
748        // leaking `-param` into the SQL fragment. rc-o6o Bug A.
749        let tpl = parse_query_template("select * from t where id = :#my-param", '#').unwrap();
750        assert_eq!(tpl.params.len(), 1);
751        assert!(
752            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "my-param"),
753            "got {:?}",
754            tpl.params[0]
755        );
756        assert_eq!(tpl.fragments[0], "select * from t where id = ");
757        assert_eq!(tpl.fragments[1], ""); // no trailing SQL leak
758    }
759
760    #[test]
761    fn parse_dotted_named_param() {
762        // Dots are now allowed inside names so `body.user.name` is one token.
763        let tpl = parse_query_template("select * from t where x = :#body.user.name", '#').unwrap();
764        assert!(
765            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "body.user.name"),
766            "got {:?}",
767            tpl.params[0]
768        );
769        assert_eq!(tpl.fragments[1], "");
770    }
771
772    #[test]
773    fn parse_named_param_terminated_by_paren() {
774        // `:#id)` — close paren terminates the name.
775        let tpl = parse_query_template("fn(:#id)", '#').unwrap();
776        assert!(
777            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"),
778            "got {:?}",
779            tpl.params[0]
780        );
781        assert_eq!(tpl.fragments[1], ")");
782    }
783
784    #[test]
785    fn parse_named_param_terminated_by_comma() {
786        let tpl = parse_query_template("fn(:#a, :#b)", '#').unwrap();
787        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "a"));
788        assert!(matches!(&tpl.params[1], ParamSlot::Named(n) if n == "b"));
789    }
790
791    #[test]
792    fn parse_named_param_terminated_by_equals() {
793        let tpl = parse_query_template("select :#x=1", '#').unwrap();
794        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "x"));
795        assert_eq!(tpl.fragments[1], "=1");
796    }
797
798    #[test]
799    fn parse_named_param_terminated_by_semicolon() {
800        let tpl = parse_query_template("select :#x;", '#').unwrap();
801        assert!(matches!(&tpl.params[0], ParamSlot::Named(n) if n == "x"));
802        assert_eq!(tpl.fragments[1], ";");
803    }
804
805    #[test]
806    fn parse_in_clause_kebab_case_name() {
807        // `:#in:my-ids` — IN-clause with hyphenated name.
808        let tpl = parse_query_template("select * from t where id in (:#in:my-ids)", '#').unwrap();
809        assert!(
810            matches!(&tpl.params[0], ParamSlot::InClause(n) if n == "my-ids"),
811            "got {:?}",
812            tpl.params[0]
813        );
814    }
815
816    #[test]
817    fn resolve_named_dotted_body_path_walks_json() {
818        // `:#body.user.name` should walk `body["user"]["name"]`. Today the parser
819        // stops at `.`, capturing `body`, and resolution fails with "Named
820        // parameter 'body' not found". rc-o6o Bug A (scoped lookup).
821        let tpl =
822            parse_query_template("select * from users where name = :#body.user.name", '#').unwrap();
823        assert!(
824            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "body.user.name"),
825            "got {:?}",
826            tpl.params[0]
827        );
828
829        let msg = Message::new(Body::Json(serde_json::json!({
830            "user": { "name": "alice" }
831        })));
832        let ex = Exchange::new(msg);
833
834        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
835        assert_eq!(prepared.sql, "select * from users where name = $1");
836        assert_eq!(prepared.bindings[0], serde_json::json!("alice"));
837    }
838
839    #[test]
840    fn resolve_named_header_scope_flat_dotted_key() {
841        // `:#header.correlation.id` — flat key "correlation.id".
842        let tpl =
843            parse_query_template("select * from t where x = :#header.correlation.id", '#').unwrap();
844        let mut msg = Message::default();
845        msg.set_header("correlation.id", serde_json::json!("abc-123"));
846        let ex = Exchange::new(msg);
847
848        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
849        assert_eq!(prepared.bindings[0], serde_json::json!("abc-123"));
850    }
851
852    #[test]
853    fn resolve_named_property_scope_alias() {
854        // `:#exchangeProperty.tenantId` — property flat key "tenantId".
855        let tpl =
856            parse_query_template("select * from t where x = :#exchangeProperty.tenantId", '#')
857                .unwrap();
858        let mut ex = Exchange::new(Message::default());
859        ex.set_property("tenantId", serde_json::json!("acme"));
860
861        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
862        assert_eq!(prepared.bindings[0], serde_json::json!("acme"));
863    }
864
865    #[test]
866    fn resolve_named_unscoped_kebab_case_fallback_to_property() {
867        // `:#my-param` — unscoped. Body and header miss; property hits.
868        let tpl = parse_query_template("select * from t where x = :#my-param", '#').unwrap();
869        let mut ex = Exchange::new(Message::default());
870        ex.set_property("my-param", serde_json::json!(7));
871
872        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
873        assert_eq!(prepared.bindings[0], serde_json::json!(7));
874    }
875
876    #[test]
877    fn resolve_expression_nested_body_walk() {
878        // `:#${body.user.name}` now walks nested JSON (used to be flat-only).
879        let tpl = parse_query_template("select * from users where name = :#${body.user.name}", '#')
880            .unwrap();
881        let msg = Message::new(Body::Json(serde_json::json!({
882            "user": { "name": "bob" }
883        })));
884        let ex = Exchange::new(msg);
885
886        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
887        assert_eq!(prepared.bindings[0], serde_json::json!("bob"));
888    }
889
890    #[test]
891    fn resolve_named_missing_returns_clear_error() {
892        let tpl = parse_query_template("select * from t where x = :#nope", '#').unwrap();
893        let ex = Exchange::new(Message::default());
894
895        let err = resolve_params(&tpl, &ex, ", ").unwrap_err();
896        let msg = format!("{err}");
897        assert!(
898            msg.contains("nope"),
899            "error should mention the missing name: {msg}"
900        );
901    }
902
903    #[test]
904    fn resolve_named_bare_body_rejected() {
905        // e_gpt oracle blessing condition #2: bare `:#body` is ambiguous for SQL.
906        // SQL binds scalars; "whole body" doesn't make sense as a SQL binding.
907        // `parse("body")` returns `Body(vec![])` (whole body for Simple `${body}`);
908        // SQL caller must explicitly reject this with a clear error.
909        let tpl = parse_query_template("select * from t where x = :#body", '#').unwrap();
910        let ex = Exchange::new(Message::new(Body::Json(serde_json::json!({"a": 1}))));
911
912        let err = resolve_params(&tpl, &ex, ", ").unwrap_err();
913        let msg = format!("{err}");
914        assert!(
915            msg.contains("body") && (msg.contains("path") || msg.contains("requires")),
916            "bare ':#body' must be rejected with a clear message mentioning path requirement: {msg}"
917        );
918    }
919
920    #[test]
921    fn resolve_named_bare_header_property_exchange_property_rejected() {
922        // Bare reserved scope names (`:#header`, `:#property`, `:#exchangeProperty`)
923        // error at ExchangeLookupPath::parse time (EmptyScopedKey). Verify the SQL
924        // surface propagates the error clearly.
925        for bare in ["header", "property", "exchangeProperty"] {
926            let sql = format!("select * from t where x = :#{bare}");
927            let tpl = parse_query_template(&sql, '#').unwrap();
928            let ex = Exchange::new(Message::default());
929            let err = resolve_params(&tpl, &ex, ", ").unwrap_err();
930            let msg = format!("{err}");
931            assert!(
932                msg.contains(bare),
933                "bare ':#{bare}' must error with a message mentioning the scope name: {msg}"
934            );
935        }
936    }
937
938    #[test]
939    fn resolve_expression_bare_body_rejected() {
940        // `:#${body}` is the explicit-expression form; same bare-scope rejection
941        // as `:#body` (e_gpt oracle re-bless concern — SQL binds scalars).
942        let tpl = parse_query_template("select * from t where x = :#${body}", '#').unwrap();
943        let ex = Exchange::new(Message::new(Body::Json(serde_json::json!({"a": 1}))));
944
945        let err = resolve_params(&tpl, &ex, ", ").unwrap_err();
946        let msg = format!("{err}");
947        assert!(
948            msg.contains("body") && (msg.contains("path") || msg.contains("requires")),
949            "bare ':#${{body}}' must be rejected: {msg}"
950        );
951    }
952
953    #[test]
954    fn parse_postgres_cast_after_placeholder_leaves_double_colon_as_sql() {
955        // `:#id::text` — placeholder name is `id`; `::text` is a Postgres cast
956        // that MUST remain in the SQL fragment. rc-o6o Phase 2 §3.2 mandatory
957        // contract test.
958        let tpl = parse_query_template("select :#id::text as id_text from t", '#').unwrap();
959        assert!(
960            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"),
961            "got {:?}",
962            tpl.params[0]
963        );
964        assert_eq!(tpl.fragments[0], "select ");
965        assert_eq!(tpl.fragments[1], "::text as id_text from t");
966    }
967
968    #[test]
969    fn resolve_postgres_cast_binds_id_value() {
970        let tpl = parse_query_template("select :#id::text as id_text from t", '#').unwrap();
971        let mut msg = Message::default();
972        msg.set_header("id", serde_json::json!(42));
973        let ex = Exchange::new(msg);
974
975        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
976        assert_eq!(prepared.sql, "select $1::text as id_text from t");
977        assert_eq!(prepared.bindings[0], serde_json::json!(42));
978    }
979
980    #[test]
981    fn parse_single_colon_terminates_name() {
982        // `:#id:x` — single colon terminates. `:x` remains in SQL.
983        // (No real SQL meaning; verifies the single-vs-double-colon rule.)
984        let tpl = parse_query_template("select :#id:x from t", '#').unwrap();
985        assert!(
986            matches!(&tpl.params[0], ParamSlot::Named(n) if n == "id"),
987            "got {:?}",
988            tpl.params[0]
989        );
990        assert_eq!(tpl.fragments[1], ":x from t");
991    }
992
993    #[test]
994    fn resolve_in_clause_kebab_case_name_from_property() {
995        // `:#in:my-ids` — IN-clause with hyphenated name. Resolves via Unscoped
996        // fallback to a property holding an array.
997        let tpl = parse_query_template("select * from t where id in (:#in:my-ids)", '#').unwrap();
998        let mut ex = Exchange::new(Message::default());
999        ex.set_property("my-ids", serde_json::json!([1, 2, 3]));
1000
1001        let prepared = resolve_params(&tpl, &ex, ", ").unwrap();
1002        assert_eq!(prepared.sql, "select * from t where id in ($1, $2, $3)");
1003        assert_eq!(
1004            prepared.bindings,
1005            vec![
1006                serde_json::json!(1),
1007                serde_json::json!(2),
1008                serde_json::json!(3)
1009            ]
1010        );
1011    }
1012}