Skip to main content

reddb_server/storage/query/
user_params.rs

1//! User-supplied positional parameter binding for `$N` placeholders.
2//!
3//! Tracer-bullet half of issue #353. The parser emits `Expr::Parameter`
4//! nodes when it sees `$N`; this module validates that the indices form
5//! a contiguous 0-based range and substitutes the user-provided values
6//! into the AST. Type validation is delegated to the existing engine
7//! type checker, which runs on the substituted literals downstream.
8
9use crate::storage::query::planner::shape::bind_user_param_query;
10use reddb_rql::ast::{Expr, QueryExpr, SearchCommand, Span};
11use reddb_rql::sql_lowering::{expr_to_filter, fold_expr_to_value};
12use reddb_types::Value;
13
14/// One parameter placeholder found in the parsed query AST.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ParameterRef {
17    /// Zero-based index into the caller-supplied parameter slice.
18    pub index: usize,
19    /// Source span of the placeholder token.
20    pub span: Span,
21}
22
23/// Recursively check whether `expr` contains any `Expr::Parameter` node.
24/// Used by the INSERT parser to know when to defer literal folding to
25/// the user_params binder.
26///
27/// The implementation moved with the parser family into `reddb-io-rql`
28/// (#1103, ADR 0053) — it is pure `Expr`-tree walking and depends only on
29/// the AST that already lives in the crate. This re-export keeps the
30/// historical `storage::query::user_params::expr_contains_parameter` path
31/// resolving for the binder's call-sites.
32pub use reddb_rql::sql_lowering::expr_contains_parameter;
33
34/// Substitute every `Expr::Parameter { index }` in `expr` with
35/// `Expr::Literal { value: params[index] }`. Used by INSERT binding,
36/// which must hand a fully literal AST to `fold_expr_to_value`.
37fn substitute_params_in_expr(expr: Expr, params: &[Value]) -> Result<Expr, UserParamError> {
38    match expr {
39        Expr::Parameter { index, span } => {
40            let value = params.get(index).ok_or(UserParamError::Arity {
41                expected: index + 1,
42                got: params.len(),
43            })?;
44            Ok(Expr::Literal {
45                value: value.clone(),
46                span,
47            })
48        }
49        Expr::Literal { .. } | Expr::Column { .. } => Ok(expr),
50        Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
51            op,
52            lhs: Box::new(substitute_params_in_expr(*lhs, params)?),
53            rhs: Box::new(substitute_params_in_expr(*rhs, params)?),
54            span,
55        }),
56        Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
57            op,
58            operand: Box::new(substitute_params_in_expr(*operand, params)?),
59            span,
60        }),
61        Expr::Cast {
62            inner,
63            target,
64            span,
65        } => Ok(Expr::Cast {
66            inner: Box::new(substitute_params_in_expr(*inner, params)?),
67            target,
68            span,
69        }),
70        Expr::FunctionCall { name, args, span } => {
71            let new_args = args
72                .into_iter()
73                .map(|a| substitute_params_in_expr(a, params))
74                .collect::<Result<Vec<_>, _>>()?;
75            Ok(Expr::FunctionCall {
76                name,
77                args: new_args,
78                span,
79            })
80        }
81        Expr::Case {
82            branches,
83            else_,
84            span,
85        } => {
86            let new_branches = branches
87                .into_iter()
88                .map(|(c, v)| {
89                    Ok::<_, UserParamError>((
90                        substitute_params_in_expr(c, params)?,
91                        substitute_params_in_expr(v, params)?,
92                    ))
93                })
94                .collect::<Result<Vec<_>, _>>()?;
95            let new_else = match else_ {
96                Some(e) => Some(Box::new(substitute_params_in_expr(*e, params)?)),
97                None => None,
98            };
99            Ok(Expr::Case {
100                branches: new_branches,
101                else_: new_else,
102                span,
103            })
104        }
105        Expr::IsNull {
106            operand,
107            negated,
108            span,
109        } => Ok(Expr::IsNull {
110            operand: Box::new(substitute_params_in_expr(*operand, params)?),
111            negated,
112            span,
113        }),
114        Expr::InList {
115            target,
116            values,
117            negated,
118            span,
119        } => Ok(Expr::InList {
120            target: Box::new(substitute_params_in_expr(*target, params)?),
121            values: values
122                .into_iter()
123                .map(|v| substitute_params_in_expr(v, params))
124                .collect::<Result<Vec<_>, _>>()?,
125            negated,
126            span,
127        }),
128        Expr::Between {
129            target,
130            low,
131            high,
132            negated,
133            span,
134        } => Ok(Expr::Between {
135            target: Box::new(substitute_params_in_expr(*target, params)?),
136            low: Box::new(substitute_params_in_expr(*low, params)?),
137            high: Box::new(substitute_params_in_expr(*high, params)?),
138            negated,
139            span,
140        }),
141        Expr::Subquery { .. } => Ok(expr),
142        // Window function calls don't appear in INSERT VALUES contexts
143        // (the only path that drives substitute_params_in_expr), but
144        // forward parameter substitution into the args / partition /
145        // order keys so this stays correct if a future caller routes
146        // window-bearing expressions through here.
147        Expr::WindowFunctionCall {
148            name,
149            args,
150            window,
151            span,
152        } => {
153            let new_args = args
154                .into_iter()
155                .map(|a| substitute_params_in_expr(a, params))
156                .collect::<Result<Vec<_>, _>>()?;
157            let new_partition = window
158                .partition_by
159                .into_iter()
160                .map(|e| substitute_params_in_expr(e, params))
161                .collect::<Result<Vec<_>, _>>()?;
162            let new_order = window
163                .order_by
164                .into_iter()
165                .map(|o| {
166                    Ok::<_, UserParamError>(reddb_rql::ast::WindowOrderItem {
167                        expr: substitute_params_in_expr(o.expr, params)?,
168                        ascending: o.ascending,
169                        nulls_first: o.nulls_first,
170                    })
171                })
172                .collect::<Result<Vec<_>, _>>()?;
173            Ok(Expr::WindowFunctionCall {
174                name,
175                args: new_args,
176                window: reddb_rql::ast::WindowSpec {
177                    partition_by: new_partition,
178                    order_by: new_order,
179                    frame: window.frame,
180                },
181                span,
182            })
183        }
184    }
185}
186
187/// Errors surfaced when binding fails. The wire layer turns these into
188/// `QUERY_ERROR` / `INVALID_PARAMS` responses.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum UserParamError {
191    /// Caller supplied fewer or more values than the SQL references.
192    /// `expected` is the highest `$N` index in the SQL (so a SQL using
193    /// `$1` and `$3` reports `expected = 3`).
194    Arity { expected: usize, got: usize },
195    /// SQL uses `$1` and `$3` but not `$2` — placeholder indices must
196    /// be a contiguous run starting from 1.
197    Gap { missing: usize, max: usize },
198    /// The runtime accepts only `QueryExpr` variants supported by the
199    /// shape binder (Table / Join / Graph / Path / Vector / Hybrid).
200    /// Other shapes (DDL, KV ops, etc.) cannot carry placeholders in
201    /// the tracer-bullet scope.
202    UnsupportedShape,
203    /// A parameter was supplied in a slot that requires a specific type
204    /// (e.g. a vector slot received a string). `slot` describes the
205    /// context, `got` describes the user-supplied value's variant.
206    TypeMismatch {
207        slot: &'static str,
208        got: &'static str,
209    },
210}
211
212impl std::fmt::Display for UserParamError {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        match self {
215            UserParamError::Arity { expected, got } => write!(
216                f,
217                "wrong number of parameters: SQL expects {expected}, got {got}"
218            ),
219            UserParamError::Gap { missing, max } => write!(
220                f,
221                "parameter $`{missing}` is missing (max index used is ${max}) — `$N` indices must be contiguous starting at $1"
222            ),
223            UserParamError::UnsupportedShape => f.write_str(
224                "this query shape does not support `$N` parameters in the tracer-bullet slice",
225            ),
226            UserParamError::TypeMismatch { slot, got } => write!(
227                f,
228                "parameter type mismatch: {slot} (got {got})"
229            ),
230        }
231    }
232}
233
234impl std::error::Error for UserParamError {}
235
236/// Public bind error alias matching the parameter-contract ADR wording.
237pub type BindError = UserParamError;
238
239/// Walk `expr`, collecting parameter placeholders that carry source spans.
240pub fn scan_parameters(expr: &QueryExpr) -> Vec<ParameterRef> {
241    let mut out = Vec::new();
242    visit_query_expr(expr, &mut |e| {
243        if let Expr::Parameter { index, span } = e {
244            out.push(ParameterRef {
245                index: *index,
246                span: *span,
247            });
248        }
249    });
250    out
251}
252
253/// Walk `expr`, collect every `Expr::Parameter { index }` encountered.
254/// Also picks up parameter slots that live outside the `Expr` tree —
255/// today only the vector slot of `SEARCH SIMILAR $N` (see #355).
256pub fn collect_indices(expr: &QueryExpr) -> Vec<usize> {
257    let mut out: Vec<usize> = scan_parameters(expr)
258        .into_iter()
259        .map(|param| param.index)
260        .collect();
261    collect_non_expr_indices(expr, &mut out);
262    out
263}
264
265/// Parameter slots that live on AST nodes outside the `Expr` tree
266/// (e.g. `SearchCommand::Similar { vector_param }`).
267//
268// `clippy::collapsible_match` would have us fold each `if let Some(idx) =
269// limit_param` into the outer pattern. With 10+ near-identical SearchCommand
270// variants, the collapsed form doubles the match arm count and obscures the
271// shared shape. Keep the two-level form for symmetry.
272#[allow(clippy::collapsible_match)]
273fn collect_non_expr_indices(expr: &QueryExpr, out: &mut Vec<usize>) {
274    match expr {
275        QueryExpr::SearchCommand(SearchCommand::Similar {
276            vector_param,
277            limit_param,
278            min_score_param,
279            text_param,
280            ..
281        }) => {
282            if let Some(idx) = vector_param {
283                out.push(*idx);
284            }
285            if let Some(idx) = limit_param {
286                out.push(*idx);
287            }
288            if let Some(idx) = min_score_param {
289                out.push(*idx);
290            }
291            if let Some(idx) = text_param {
292                out.push(*idx);
293            }
294        }
295        QueryExpr::SearchCommand(SearchCommand::Hybrid { limit_param, .. }) => {
296            if let Some(idx) = limit_param {
297                out.push(*idx);
298            }
299        }
300        QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k_param, .. }) => {
301            if let Some(idx) = k_param {
302                out.push(*idx);
303            }
304        }
305        QueryExpr::SearchCommand(SearchCommand::SpatialRadius { limit_param, .. }) => {
306            if let Some(idx) = limit_param {
307                out.push(*idx);
308            }
309        }
310        QueryExpr::SearchCommand(SearchCommand::SpatialBbox { limit_param, .. }) => {
311            if let Some(idx) = limit_param {
312                out.push(*idx);
313            }
314        }
315        QueryExpr::SearchCommand(SearchCommand::SpatialWithinPolygon { limit_param, .. }) => {
316            if let Some(idx) = limit_param {
317                out.push(*idx);
318            }
319        }
320        QueryExpr::SearchCommand(SearchCommand::Text { limit_param, .. }) => {
321            if let Some(idx) = limit_param {
322                out.push(*idx);
323            }
324        }
325        QueryExpr::SearchCommand(SearchCommand::Multimodal { limit_param, .. }) => {
326            if let Some(idx) = limit_param {
327                out.push(*idx);
328            }
329        }
330        QueryExpr::SearchCommand(SearchCommand::Index { limit_param, .. }) => {
331            if let Some(idx) = limit_param {
332                out.push(*idx);
333            }
334        }
335        QueryExpr::SearchCommand(SearchCommand::Context { limit_param, .. }) => {
336            if let Some(idx) = limit_param {
337                out.push(*idx);
338            }
339        }
340        QueryExpr::Table(q) => {
341            if let Some(idx) = q.limit_param {
342                out.push(idx);
343            }
344            if let Some(idx) = q.offset_param {
345                out.push(idx);
346            }
347        }
348        QueryExpr::Ask(q) => {
349            if let Some(idx) = q.question_param {
350                out.push(idx);
351            }
352        }
353        _ => {}
354    }
355}
356
357/// Validate that the indices used by the SQL match the caller's
358/// supplied params (contiguous from 0, length match).
359pub fn validate(indices: &[usize], param_count: usize) -> Result<(), UserParamError> {
360    let max_used = indices.iter().copied().max();
361
362    let expected = match max_used {
363        Some(m) => m + 1,
364        None => 0,
365    };
366
367    if expected != param_count {
368        return Err(UserParamError::Arity {
369            expected,
370            got: param_count,
371        });
372    }
373
374    if let Some(max) = max_used {
375        let mut seen = vec![false; max + 1];
376        for &i in indices {
377            seen[i] = true;
378        }
379        for (i, used) in seen.iter().enumerate() {
380            if !used {
381                return Err(UserParamError::Gap {
382                    missing: i + 1,
383                    max: max + 1,
384                });
385            }
386        }
387    }
388
389    Ok(())
390}
391
392/// One-shot helper: validate arity/gaps then substitute the values.
393pub fn bind(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, UserParamError> {
394    let indices = collect_indices(expr);
395    validate(&indices, params.len())?;
396    bind_validated_parameters(expr, params, !indices.is_empty())
397}
398
399/// A stored-function statement may use only a subset of its function's arguments.
400/// The function signature owns arity; individual references still need bounds checks.
401pub(crate) fn bind_available_parameters(
402    expr: &QueryExpr,
403    params: &[Value],
404) -> Result<QueryExpr, UserParamError> {
405    let indices = collect_indices(expr);
406    if let Some(index) = indices.iter().find(|index| **index >= params.len()) {
407        return Err(UserParamError::Arity {
408            expected: index + 1,
409            got: params.len(),
410        });
411    }
412
413    bind_validated_parameters(expr, params, !indices.is_empty())
414}
415
416fn bind_validated_parameters(
417    expr: &QueryExpr,
418    params: &[Value],
419    has_parameters: bool,
420) -> Result<QueryExpr, UserParamError> {
421    if !has_parameters {
422        return Ok(expr.clone());
423    }
424
425    if let QueryExpr::Function(command) = expr {
426        if let reddb_rql::stored_function::FunctionCommand::Call { name, arguments } =
427            command.as_ref()
428        {
429            return Ok(QueryExpr::Function(Box::new(
430                reddb_rql::stored_function::FunctionCommand::Call {
431                    name: name.clone(),
432                    arguments: arguments
433                        .iter()
434                        .cloned()
435                        .map(|argument| substitute_params_in_expr(argument, params))
436                        .collect::<Result<_, _>>()?,
437                },
438            )));
439        }
440    }
441
442    // SEARCH SIMILAR $N has its parameter slot outside the `Expr`
443    // tree — handle it here rather than threading the binds through
444    // the planner's shape binder, which only knows about `Expr` slots.
445    if let QueryExpr::SearchCommand(SearchCommand::Similar {
446        vector,
447        text,
448        provider,
449        collection,
450        limit,
451        min_score,
452        vector_param,
453        limit_param,
454        min_score_param,
455        text_param,
456    }) = expr
457    {
458        let mut bound_vector = vector.clone();
459        if let Some(idx) = vector_param {
460            let value = params.get(*idx).ok_or(UserParamError::Arity {
461                expected: idx + 1,
462                got: params.len(),
463            })?;
464            bound_vector = match value {
465                Value::Vector(v) => v.clone(),
466                other => {
467                    return Err(UserParamError::TypeMismatch {
468                        slot: "SEARCH SIMILAR vector parameter",
469                        got: value_variant_name(other),
470                    });
471                }
472            };
473        }
474        let bound_limit = if let Some(idx) = limit_param {
475            let value = params.get(*idx).ok_or(UserParamError::Arity {
476                expected: idx + 1,
477                got: params.len(),
478            })?;
479            match value {
480                Value::Integer(n) if *n > 0 => *n as usize,
481                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
482                Value::BigInt(n) if *n > 0 => *n as usize,
483                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
484                    return Err(UserParamError::TypeMismatch {
485                        slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
486                        got: value_variant_name(value),
487                    });
488                }
489                other => {
490                    return Err(UserParamError::TypeMismatch {
491                        slot: "SEARCH SIMILAR LIMIT parameter",
492                        got: value_variant_name(other),
493                    });
494                }
495            }
496        } else {
497            *limit
498        };
499        let bound_min_score = if let Some(idx) = min_score_param {
500            let value = params.get(*idx).ok_or(UserParamError::Arity {
501                expected: idx + 1,
502                got: params.len(),
503            })?;
504            match value {
505                Value::Float(f) => *f as f32,
506                Value::Integer(n) => *n as f32,
507                Value::UnsignedInteger(n) => *n as f32,
508                Value::BigInt(n) => *n as f32,
509                other => {
510                    return Err(UserParamError::TypeMismatch {
511                        slot: "SEARCH SIMILAR MIN_SCORE parameter",
512                        got: value_variant_name(other),
513                    });
514                }
515            }
516        } else {
517            *min_score
518        };
519        let bound_text = if let Some(idx) = text_param {
520            let value = params.get(*idx).ok_or(UserParamError::Arity {
521                expected: idx + 1,
522                got: params.len(),
523            })?;
524            match value {
525                Value::Text(s) => Some(s.to_string()),
526                other => {
527                    return Err(UserParamError::TypeMismatch {
528                        slot: "SEARCH SIMILAR TEXT parameter",
529                        got: value_variant_name(other),
530                    });
531                }
532            }
533        } else {
534            text.clone()
535        };
536        return Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
537            vector: bound_vector,
538            text: bound_text,
539            provider: provider.clone(),
540            collection: collection.clone(),
541            limit: bound_limit,
542            min_score: bound_min_score,
543            vector_param: None,
544            limit_param: None,
545            min_score_param: None,
546            text_param: None,
547        }));
548    }
549
550    if let QueryExpr::SearchCommand(SearchCommand::Hybrid {
551        vector,
552        query,
553        collection,
554        limit,
555        limit_param,
556    }) = expr
557    {
558        let bound_limit = if let Some(idx) = limit_param {
559            let value = params.get(*idx).ok_or(UserParamError::Arity {
560                expected: idx + 1,
561                got: params.len(),
562            })?;
563            match value {
564                Value::Integer(n) if *n > 0 => *n as usize,
565                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
566                Value::BigInt(n) if *n > 0 => *n as usize,
567                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
568                    return Err(UserParamError::TypeMismatch {
569                        slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
570                        got: value_variant_name(value),
571                    });
572                }
573                other => {
574                    return Err(UserParamError::TypeMismatch {
575                        slot: "SEARCH HYBRID LIMIT parameter",
576                        got: value_variant_name(other),
577                    });
578                }
579            }
580        } else {
581            *limit
582        };
583        return Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
584            vector: vector.clone(),
585            query: query.clone(),
586            collection: collection.clone(),
587            limit: bound_limit,
588            limit_param: None,
589        }));
590    }
591
592    if let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
593        lat,
594        lon,
595        k,
596        collection,
597        column,
598        k_param,
599    }) = expr
600    {
601        let bound_k = if let Some(idx) = k_param {
602            let value = params.get(*idx).ok_or(UserParamError::Arity {
603                expected: idx + 1,
604                got: params.len(),
605            })?;
606            match value {
607                Value::Integer(n) if *n > 0 => *n as usize,
608                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
609                Value::BigInt(n) if *n > 0 => *n as usize,
610                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
611                    return Err(UserParamError::TypeMismatch {
612                        slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
613                        got: value_variant_name(value),
614                    });
615                }
616                other => {
617                    return Err(UserParamError::TypeMismatch {
618                        slot: "SEARCH SPATIAL NEAREST K parameter",
619                        got: value_variant_name(other),
620                    });
621                }
622            }
623        } else {
624            *k
625        };
626        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
627            lat: *lat,
628            lon: *lon,
629            k: bound_k,
630            collection: collection.clone(),
631            column: column.clone(),
632            k_param: None,
633        }));
634    }
635
636    if let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
637        center_lat,
638        center_lon,
639        radius_km,
640        collection,
641        column,
642        limit,
643        limit_param,
644    }) = expr
645    {
646        let bound_limit = if let Some(idx) = limit_param {
647            let value = params.get(*idx).ok_or(UserParamError::Arity {
648                expected: idx + 1,
649                got: params.len(),
650            })?;
651            match value {
652                Value::Integer(n) if *n > 0 => *n as usize,
653                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
654                Value::BigInt(n) if *n > 0 => *n as usize,
655                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
656                    return Err(UserParamError::TypeMismatch {
657                        slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
658                        got: value_variant_name(value),
659                    });
660                }
661                other => {
662                    return Err(UserParamError::TypeMismatch {
663                        slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
664                        got: value_variant_name(other),
665                    });
666                }
667            }
668        } else {
669            *limit
670        };
671        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
672            center_lat: *center_lat,
673            center_lon: *center_lon,
674            radius_km: *radius_km,
675            collection: collection.clone(),
676            column: column.clone(),
677            limit: bound_limit,
678            limit_param: None,
679        }));
680    }
681
682    if let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
683        min_lat,
684        min_lon,
685        max_lat,
686        max_lon,
687        collection,
688        column,
689        limit,
690        limit_param,
691    }) = expr
692    {
693        let bound_limit = if let Some(idx) = limit_param {
694            let value = params.get(*idx).ok_or(UserParamError::Arity {
695                expected: idx + 1,
696                got: params.len(),
697            })?;
698            match value {
699                Value::Integer(n) if *n > 0 => *n as usize,
700                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
701                Value::BigInt(n) if *n > 0 => *n as usize,
702                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
703                    return Err(UserParamError::TypeMismatch {
704                        slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
705                        got: value_variant_name(value),
706                    });
707                }
708                other => {
709                    return Err(UserParamError::TypeMismatch {
710                        slot: "SEARCH SPATIAL BBOX LIMIT parameter",
711                        got: value_variant_name(other),
712                    });
713                }
714            }
715        } else {
716            *limit
717        };
718        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
719            min_lat: *min_lat,
720            min_lon: *min_lon,
721            max_lat: *max_lat,
722            max_lon: *max_lon,
723            collection: collection.clone(),
724            column: column.clone(),
725            limit: bound_limit,
726            limit_param: None,
727        }));
728    }
729
730    if let QueryExpr::SearchCommand(SearchCommand::SpatialWithinPolygon {
731        vertices,
732        collection,
733        column,
734        limit,
735        limit_param,
736    }) = expr
737    {
738        let bound_limit = if let Some(idx) = limit_param {
739            let value = params.get(*idx).ok_or(UserParamError::Arity {
740                expected: idx + 1,
741                got: params.len(),
742            })?;
743            match value {
744                Value::Integer(n) if *n > 0 => *n as usize,
745                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
746                Value::BigInt(n) if *n > 0 => *n as usize,
747                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
748                    return Err(UserParamError::TypeMismatch {
749                        slot: "SEARCH SPATIAL WITHIN POLYGON LIMIT parameter (must be > 0)",
750                        got: value_variant_name(value),
751                    });
752                }
753                other => {
754                    return Err(UserParamError::TypeMismatch {
755                        slot: "SEARCH SPATIAL WITHIN POLYGON LIMIT parameter",
756                        got: value_variant_name(other),
757                    });
758                }
759            }
760        } else {
761            *limit
762        };
763        return Ok(QueryExpr::SearchCommand(
764            SearchCommand::SpatialWithinPolygon {
765                vertices: vertices.clone(),
766                collection: collection.clone(),
767                column: column.clone(),
768                limit: bound_limit,
769                limit_param: None,
770            },
771        ));
772    }
773
774    if let QueryExpr::SearchCommand(SearchCommand::Text {
775        query,
776        collection,
777        limit,
778        fuzzy,
779        limit_param,
780    }) = expr
781    {
782        let bound_limit = if let Some(idx) = limit_param {
783            let value = params.get(*idx).ok_or(UserParamError::Arity {
784                expected: idx + 1,
785                got: params.len(),
786            })?;
787            match value {
788                Value::Integer(n) if *n > 0 => *n as usize,
789                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
790                Value::BigInt(n) if *n > 0 => *n as usize,
791                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
792                    return Err(UserParamError::TypeMismatch {
793                        slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
794                        got: value_variant_name(value),
795                    });
796                }
797                other => {
798                    return Err(UserParamError::TypeMismatch {
799                        slot: "SEARCH TEXT LIMIT parameter",
800                        got: value_variant_name(other),
801                    });
802                }
803            }
804        } else {
805            *limit
806        };
807        return Ok(QueryExpr::SearchCommand(SearchCommand::Text {
808            query: query.clone(),
809            collection: collection.clone(),
810            limit: bound_limit,
811            fuzzy: *fuzzy,
812            limit_param: None,
813        }));
814    }
815
816    if let QueryExpr::SearchCommand(SearchCommand::Multimodal {
817        query,
818        collection,
819        limit,
820        limit_param,
821    }) = expr
822    {
823        let bound_limit = if let Some(idx) = limit_param {
824            let value = params.get(*idx).ok_or(UserParamError::Arity {
825                expected: idx + 1,
826                got: params.len(),
827            })?;
828            match value {
829                Value::Integer(n) if *n > 0 => *n as usize,
830                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
831                Value::BigInt(n) if *n > 0 => *n as usize,
832                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
833                    return Err(UserParamError::TypeMismatch {
834                        slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
835                        got: value_variant_name(value),
836                    });
837                }
838                other => {
839                    return Err(UserParamError::TypeMismatch {
840                        slot: "SEARCH MULTIMODAL LIMIT parameter",
841                        got: value_variant_name(other),
842                    });
843                }
844            }
845        } else {
846            *limit
847        };
848        return Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
849            query: query.clone(),
850            collection: collection.clone(),
851            limit: bound_limit,
852            limit_param: None,
853        }));
854    }
855
856    if let QueryExpr::SearchCommand(SearchCommand::Index {
857        index,
858        value,
859        collection,
860        limit,
861        exact,
862        limit_param,
863    }) = expr
864    {
865        let bound_limit = if let Some(idx) = limit_param {
866            let value = params.get(*idx).ok_or(UserParamError::Arity {
867                expected: idx + 1,
868                got: params.len(),
869            })?;
870            match value {
871                Value::Integer(n) if *n > 0 => *n as usize,
872                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
873                Value::BigInt(n) if *n > 0 => *n as usize,
874                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
875                    return Err(UserParamError::TypeMismatch {
876                        slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
877                        got: value_variant_name(value),
878                    });
879                }
880                other => {
881                    return Err(UserParamError::TypeMismatch {
882                        slot: "SEARCH INDEX LIMIT parameter",
883                        got: value_variant_name(other),
884                    });
885                }
886            }
887        } else {
888            *limit
889        };
890        return Ok(QueryExpr::SearchCommand(SearchCommand::Index {
891            index: index.clone(),
892            value: value.clone(),
893            collection: collection.clone(),
894            limit: bound_limit,
895            exact: *exact,
896            limit_param: None,
897        }));
898    }
899
900    if let QueryExpr::SearchCommand(SearchCommand::Context {
901        query,
902        field,
903        collection,
904        limit,
905        depth,
906        limit_param,
907    }) = expr
908    {
909        let bound_limit = if let Some(idx) = limit_param {
910            let value = params.get(*idx).ok_or(UserParamError::Arity {
911                expected: idx + 1,
912                got: params.len(),
913            })?;
914            match value {
915                Value::Integer(n) if *n > 0 => *n as usize,
916                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
917                Value::BigInt(n) if *n > 0 => *n as usize,
918                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
919                    return Err(UserParamError::TypeMismatch {
920                        slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
921                        got: value_variant_name(value),
922                    });
923                }
924                other => {
925                    return Err(UserParamError::TypeMismatch {
926                        slot: "SEARCH CONTEXT LIMIT parameter",
927                        got: value_variant_name(other),
928                    });
929                }
930            }
931        } else {
932            *limit
933        };
934        return Ok(QueryExpr::SearchCommand(SearchCommand::Context {
935            query: query.clone(),
936            field: field.clone(),
937            collection: collection.clone(),
938            limit: bound_limit,
939            depth: *depth,
940            limit_param: None,
941        }));
942    }
943
944    if let QueryExpr::Insert(insert) = expr {
945        let mut bound = insert.clone();
946        let mut new_values: Vec<Vec<Value>> = Vec::with_capacity(bound.value_exprs.len());
947        let new_exprs = bound
948            .value_exprs
949            .into_iter()
950            .map(|row| {
951                row.into_iter()
952                    .map(|e| substitute_params_in_expr(e, params))
953                    .collect::<Result<Vec<_>, _>>()
954            })
955            .collect::<Result<Vec<_>, _>>()?;
956        for row in &new_exprs {
957            let folded = row
958                .iter()
959                .cloned()
960                .map(fold_expr_to_value)
961                .collect::<Result<Vec<_>, _>>()
962                .map_err(|_| UserParamError::UnsupportedShape)?;
963            new_values.push(folded);
964        }
965        if let Some(reddb_rql::ast::OnConflictClause {
966            action: reddb_rql::ast::OnConflictAction::DoUpdate { assignments },
967            ..
968        }) = &mut bound.on_conflict
969        {
970            for (_, assignment) in assignments {
971                *assignment = substitute_params_in_expr(assignment.clone(), params)?;
972            }
973        }
974        bound.value_exprs = new_exprs;
975        bound.values = new_values;
976        return Ok(QueryExpr::Insert(bound));
977    }
978
979    if let QueryExpr::Update(update) = expr {
980        let mut bound = update.clone();
981        let assignment_exprs = bound
982            .assignment_exprs
983            .into_iter()
984            .map(|(column, expr)| Ok((column, substitute_params_in_expr(expr, params)?)))
985            .collect::<Result<Vec<_>, UserParamError>>()?;
986        let assignments = assignment_exprs
987            .iter()
988            .zip(bound.compound_assignment_ops.iter())
989            .filter_map(|((column, expr), compound_op)| {
990                if compound_op.is_some() {
991                    return None;
992                }
993                fold_expr_to_value(expr.clone())
994                    .ok()
995                    .map(|value| (column.clone(), value))
996            })
997            .collect();
998        let where_expr = bound
999            .where_expr
1000            .map(|expr| substitute_params_in_expr(expr, params))
1001            .transpose()?;
1002        let filter = where_expr.as_ref().map(expr_to_filter);
1003        for clause in &mut bound.order_by {
1004            clause.expr = clause
1005                .expr
1006                .take()
1007                .map(|expression| substitute_params_in_expr(expression, params))
1008                .transpose()?;
1009        }
1010        bound.assignment_exprs = assignment_exprs;
1011        bound.assignments = assignments;
1012        bound.where_expr = where_expr;
1013        bound.filter = filter;
1014        return Ok(QueryExpr::Update(bound));
1015    }
1016
1017    if let QueryExpr::Delete(delete) = expr {
1018        let mut bound = delete.clone();
1019        let where_expr = bound
1020            .where_expr
1021            .map(|expr| substitute_params_in_expr(expr, params))
1022            .transpose()?;
1023        let filter = where_expr.as_ref().map(expr_to_filter);
1024        bound.where_expr = where_expr;
1025        bound.filter = filter;
1026        return Ok(QueryExpr::Delete(bound));
1027    }
1028
1029    if let QueryExpr::Ask(ask) = expr {
1030        let Some(idx) = ask.question_param else {
1031            return Ok(QueryExpr::Ask(ask.clone()));
1032        };
1033        let value = params.get(idx).ok_or(UserParamError::Arity {
1034            expected: idx + 1,
1035            got: params.len(),
1036        })?;
1037        let question = match value {
1038            Value::Text(s) => s.to_string(),
1039            other => {
1040                return Err(UserParamError::TypeMismatch {
1041                    slot: "ASK question parameter",
1042                    got: value_variant_name(other),
1043                });
1044            }
1045        };
1046        let mut bound = ask.clone();
1047        bound.question = question;
1048        bound.question_param = None;
1049        return Ok(QueryExpr::Ask(bound));
1050    }
1051
1052    // SELECT LIMIT / OFFSET $N — the planner's Expr-tree binder doesn't
1053    // see these slots (they live on TableQuery, not inside any Expr).
1054    // Run the Expr-tree bind first, then substitute the non-Expr slots
1055    // post-hoc. Mirrors the SearchCommand::Similar pattern above.
1056    if let QueryExpr::Table(table) = expr {
1057        if table.limit_param.is_some() || table.offset_param.is_some() {
1058            let bound_inner =
1059                bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)?;
1060            let mut bound_table = match bound_inner {
1061                QueryExpr::Table(t) => t,
1062                _ => return Err(UserParamError::UnsupportedShape),
1063            };
1064            if let Some(idx) = table.limit_param {
1065                let value = params.get(idx).ok_or(UserParamError::Arity {
1066                    expected: idx + 1,
1067                    got: params.len(),
1068                })?;
1069                let n = match value {
1070                    Value::Integer(n) if *n > 0 => *n as u64,
1071                    Value::UnsignedInteger(n) if *n > 0 => *n,
1072                    Value::BigInt(n) if *n > 0 => *n as u64,
1073                    Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
1074                        return Err(UserParamError::TypeMismatch {
1075                            slot: "SELECT LIMIT parameter (must be > 0)",
1076                            got: value_variant_name(value),
1077                        });
1078                    }
1079                    other => {
1080                        return Err(UserParamError::TypeMismatch {
1081                            slot: "SELECT LIMIT parameter",
1082                            got: value_variant_name(other),
1083                        });
1084                    }
1085                };
1086                bound_table.limit = Some(n);
1087                bound_table.limit_param = None;
1088            }
1089            if let Some(idx) = table.offset_param {
1090                let value = params.get(idx).ok_or(UserParamError::Arity {
1091                    expected: idx + 1,
1092                    got: params.len(),
1093                })?;
1094                let n = match value {
1095                    Value::Integer(n) if *n >= 0 => *n as u64,
1096                    Value::UnsignedInteger(n) => *n,
1097                    Value::BigInt(n) if *n >= 0 => *n as u64,
1098                    Value::Integer(_) | Value::BigInt(_) => {
1099                        return Err(UserParamError::TypeMismatch {
1100                            slot: "SELECT OFFSET parameter (must be >= 0)",
1101                            got: value_variant_name(value),
1102                        });
1103                    }
1104                    other => {
1105                        return Err(UserParamError::TypeMismatch {
1106                            slot: "SELECT OFFSET parameter",
1107                            got: value_variant_name(other),
1108                        });
1109                    }
1110                };
1111                bound_table.offset = Some(n);
1112                bound_table.offset_param = None;
1113            }
1114            return Ok(QueryExpr::Table(bound_table));
1115        }
1116    }
1117
1118    bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
1119}
1120
1121/// One-shot helper matching the parameter-contract ADR wording.
1122pub fn bind_parameters(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, BindError> {
1123    bind(expr, params)
1124}
1125
1126fn value_variant_name(value: &Value) -> &'static str {
1127    match value {
1128        Value::Null => "null",
1129        Value::Integer(_) => "integer",
1130        Value::UnsignedInteger(_) => "unsigned integer",
1131        Value::BigInt(_) => "bigint",
1132        Value::Float(_) => "float",
1133        Value::Text(_) => "text",
1134        Value::Boolean(_) => "boolean",
1135        Value::Vector(_) => "vector",
1136        Value::Json(_) => "json",
1137        Value::Blob(_) => "bytes",
1138        _ => "other",
1139    }
1140}
1141
1142fn visit_projection_parameters<F: FnMut(&Expr)>(
1143    projection: &reddb_rql::ast::Projection,
1144    visit: &mut F,
1145) {
1146    use reddb_rql::ast::{Projection, Span};
1147    match projection {
1148        Projection::Column(column) | Projection::Alias(column, _)
1149            if column.starts_with(reddb_rql::sql_lowering::PARAMETER_PROJECTION_PREFIX) =>
1150        {
1151            if let Some(index) = column
1152                .strip_prefix(reddb_rql::sql_lowering::PARAMETER_PROJECTION_PREFIX)
1153                .and_then(|index| index.parse().ok())
1154            {
1155                visit(&Expr::Parameter {
1156                    index,
1157                    span: Span::synthetic(),
1158                });
1159            }
1160        }
1161        Projection::Function(_, arguments) => {
1162            for argument in arguments {
1163                visit_projection_parameters(argument, visit);
1164            }
1165        }
1166        _ => {
1167            if let Some((expression, _)) = reddb_rql::sql_lowering::projection_to_expr(projection) {
1168                visit_expr(&expression, visit);
1169            }
1170        }
1171    }
1172}
1173
1174fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
1175    match expr {
1176        QueryExpr::Function(command) => {
1177            if let reddb_rql::stored_function::FunctionCommand::Call { arguments, .. } =
1178                command.as_ref()
1179            {
1180                for argument in arguments {
1181                    visit_expr(argument, visit);
1182                }
1183            }
1184        }
1185        QueryExpr::Table(q) => {
1186            for item in &q.select_items {
1187                if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
1188                    visit_expr(expr, visit);
1189                }
1190            }
1191            if let Some(e) = &q.where_expr {
1192                visit_expr(e, visit);
1193            }
1194            for e in &q.group_by_exprs {
1195                visit_expr(e, visit);
1196            }
1197            if let Some(e) = &q.having_expr {
1198                visit_expr(e, visit);
1199            }
1200            for clause in &q.order_by {
1201                if let Some(e) = &clause.expr {
1202                    visit_expr(e, visit);
1203                }
1204            }
1205            if let Some(reddb_rql::ast::TableSource::Subquery(inner)) = &q.source {
1206                visit_query_expr(inner, visit);
1207            }
1208        }
1209        QueryExpr::Graph(q) => {
1210            for projection in &q.return_ {
1211                visit_projection_parameters(projection, visit);
1212            }
1213            if let Some(filter) = &q.filter {
1214                visit_expr(&reddb_rql::sql_lowering::filter_to_expr(filter), visit);
1215            }
1216        }
1217        QueryExpr::Join(q) => {
1218            visit_query_expr(&q.left, visit);
1219            visit_query_expr(&q.right, visit);
1220            for item in &q.return_items {
1221                if let reddb_rql::ast::SelectItem::Expr { expr, .. } = item {
1222                    visit_expr(expr, visit);
1223                }
1224            }
1225            for projection in &q.return_ {
1226                visit_projection_parameters(projection, visit);
1227            }
1228            if let Some(filter) = &q.filter {
1229                visit_expr(&reddb_rql::sql_lowering::filter_to_expr(filter), visit);
1230            }
1231            for clause in &q.order_by {
1232                if let Some(expression) = &clause.expr {
1233                    visit_expr(expression, visit);
1234                }
1235            }
1236        }
1237        QueryExpr::Hybrid(q) => {
1238            visit_query_expr(&q.structured, visit);
1239        }
1240        QueryExpr::Insert(q) => {
1241            for row in &q.value_exprs {
1242                for e in row {
1243                    visit_expr(e, visit);
1244                }
1245            }
1246            if let Some(reddb_rql::ast::OnConflictClause {
1247                action: reddb_rql::ast::OnConflictAction::DoUpdate { assignments },
1248                ..
1249            }) = &q.on_conflict
1250            {
1251                for (_, expression) in assignments {
1252                    visit_expr(expression, visit);
1253                }
1254            }
1255        }
1256        QueryExpr::Update(q) => {
1257            for clause in &q.order_by {
1258                if let Some(expression) = &clause.expr {
1259                    visit_expr(expression, visit);
1260                }
1261            }
1262            for (_, e) in &q.assignment_exprs {
1263                visit_expr(e, visit);
1264            }
1265            if let Some(e) = &q.where_expr {
1266                visit_expr(e, visit);
1267            }
1268        }
1269        QueryExpr::Delete(q) => {
1270            if let Some(e) = &q.where_expr {
1271                visit_expr(e, visit);
1272            }
1273        }
1274        // Vector / Graph / Path: parameter slots in #355 / later issues.
1275        _ => {}
1276    }
1277}
1278
1279fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
1280    visit(expr);
1281    match expr {
1282        Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
1283        Expr::BinaryOp { lhs, rhs, .. } => {
1284            visit_expr(lhs, visit);
1285            visit_expr(rhs, visit);
1286        }
1287        Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
1288        Expr::Cast { inner, .. } => visit_expr(inner, visit),
1289        Expr::FunctionCall { args, .. } => {
1290            for a in args {
1291                visit_expr(a, visit);
1292            }
1293        }
1294        Expr::Case {
1295            branches, else_, ..
1296        } => {
1297            for (c, v) in branches {
1298                visit_expr(c, visit);
1299                visit_expr(v, visit);
1300            }
1301            if let Some(e) = else_ {
1302                visit_expr(e, visit);
1303            }
1304        }
1305        Expr::IsNull { operand, .. } => visit_expr(operand, visit),
1306        Expr::InList { target, values, .. } => {
1307            visit_expr(target, visit);
1308            for v in values {
1309                visit_expr(v, visit);
1310            }
1311        }
1312        Expr::Between {
1313            target, low, high, ..
1314        } => {
1315            visit_expr(target, visit);
1316            visit_expr(low, visit);
1317            visit_expr(high, visit);
1318        }
1319        Expr::Subquery { .. } => {}
1320        Expr::WindowFunctionCall { args, window, .. } => {
1321            for a in args {
1322                visit_expr(a, visit);
1323            }
1324            for e in &window.partition_by {
1325                visit_expr(e, visit);
1326            }
1327            for o in &window.order_by {
1328                visit_expr(&o.expr, visit);
1329            }
1330        }
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use reddb_rql::modes::parse_multi;
1338
1339    fn parse(sql: &str) -> QueryExpr {
1340        parse_multi(sql).expect("parse")
1341    }
1342
1343    #[test]
1344    fn collect_indices_select_where() {
1345        let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
1346        let mut ix = collect_indices(&q);
1347        ix.sort();
1348        assert_eq!(ix, vec![0, 1]);
1349    }
1350
1351    #[test]
1352    fn scan_parameters_reports_index_and_span() {
1353        let sql = "SELECT * FROM users WHERE id = $1 AND name = $2";
1354        let q = parse(sql);
1355        let params = scan_parameters(&q);
1356        assert_eq!(
1357            params.iter().map(|param| param.index).collect::<Vec<_>>(),
1358            vec![0, 1]
1359        );
1360        assert_eq!(
1361            sql[params[0].span.start.offset as usize..params[0].span.end.offset as usize].trim(),
1362            "$1"
1363        );
1364        assert_eq!(
1365            sql[params[1].span.start.offset as usize..params[1].span.end.offset as usize].trim(),
1366            "$2"
1367        );
1368    }
1369
1370    #[test]
1371    fn validate_ok() {
1372        assert!(validate(&[0, 1], 2).is_ok());
1373        assert!(validate(&[0, 1, 0], 2).is_ok());
1374        assert!(validate(&[], 0).is_ok());
1375    }
1376
1377    #[test]
1378    fn validate_arity_too_few() {
1379        let err = validate(&[0, 1], 1).unwrap_err();
1380        assert!(matches!(
1381            err,
1382            UserParamError::Arity {
1383                expected: 2,
1384                got: 1
1385            }
1386        ));
1387    }
1388
1389    #[test]
1390    fn validate_arity_too_many() {
1391        let err = validate(&[0], 3).unwrap_err();
1392        assert!(matches!(
1393            err,
1394            UserParamError::Arity {
1395                expected: 1,
1396                got: 3
1397            }
1398        ));
1399    }
1400
1401    #[test]
1402    fn validate_gap() {
1403        // $1 and $3 used, but not $2.
1404        let err = validate(&[0, 2], 3).unwrap_err();
1405        assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
1406    }
1407
1408    #[test]
1409    fn bind_substitutes_int_param() {
1410        let q = parse("SELECT * FROM users WHERE id = $1");
1411        let bound = bind(&q, &[Value::Integer(42)]).unwrap();
1412        let QueryExpr::Table(t) = bound else {
1413            panic!("expected Table");
1414        };
1415        let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
1416            panic!("expected BinaryOp");
1417        };
1418        assert!(matches!(
1419            *rhs,
1420            Expr::Literal {
1421                value: Value::Integer(42),
1422                ..
1423            }
1424        ));
1425    }
1426
1427    #[test]
1428    fn bind_substitutes_question_numbered_param() {
1429        let q = parse("SELECT * FROM users WHERE id = ?1 AND name = ?2");
1430        let bound = bind(&q, &[Value::Integer(42), Value::text("Alice")]).unwrap();
1431        let QueryExpr::Table(t) = bound else {
1432            panic!("expected Table");
1433        };
1434        let mut literals: Vec<Value> = Vec::new();
1435        visit_expr(&t.where_expr.unwrap(), &mut |e| {
1436            if let Expr::Literal { value, .. } = e {
1437                literals.push(value.clone());
1438            }
1439        });
1440        assert!(literals.iter().any(|v| matches!(v, Value::Integer(42))));
1441        assert!(literals
1442            .iter()
1443            .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1444    }
1445
1446    #[test]
1447    fn bind_substitutes_text_and_null() {
1448        let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1449        let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1450        let QueryExpr::Table(t) = bound else {
1451            panic!("expected Table");
1452        };
1453        let mut literals: Vec<Value> = Vec::new();
1454        visit_expr(&t.where_expr.unwrap(), &mut |e| {
1455            if let Expr::Literal { value, .. } = e {
1456                literals.push(value.clone());
1457            }
1458        });
1459        assert!(literals
1460            .iter()
1461            .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1462        assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1463    }
1464
1465    #[test]
1466    fn bind_search_similar_vector_param() {
1467        // Tracer for #355: `SEARCH SIMILAR $1 COLLECTION embeddings`
1468        // binds the supplied `Value::Vector` into the vector slot.
1469        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1470        let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1471        let QueryExpr::SearchCommand(SearchCommand::Similar {
1472            vector,
1473            vector_param,
1474            collection,
1475            limit,
1476            ..
1477        }) = bound
1478        else {
1479            panic!("expected SearchCommand::Similar");
1480        };
1481        assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1482        assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1483        assert_eq!(collection, "embeddings");
1484        assert_eq!(limit, 5);
1485    }
1486
1487    #[test]
1488    fn bind_search_similar_limit_param() {
1489        // Issue #361: `LIMIT $N` binds an integer parameter.
1490        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1491        let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1492        let QueryExpr::SearchCommand(SearchCommand::Similar {
1493            limit,
1494            limit_param,
1495            min_score_param,
1496            ..
1497        }) = bound
1498        else {
1499            panic!("expected SearchCommand::Similar");
1500        };
1501        assert_eq!(limit, 25);
1502        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1503        assert_eq!(min_score_param, None);
1504    }
1505
1506    #[test]
1507    fn bind_search_similar_min_score_param() {
1508        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1509        let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1510        let QueryExpr::SearchCommand(SearchCommand::Similar {
1511            min_score,
1512            min_score_param,
1513            ..
1514        }) = bound
1515        else {
1516            panic!("expected SearchCommand::Similar");
1517        };
1518        assert!((min_score - 0.42_f32).abs() < 1e-6);
1519        assert_eq!(min_score_param, None);
1520    }
1521
1522    #[test]
1523    fn bind_search_similar_limit_and_min_score_together() {
1524        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1525        let bound = bind(
1526            &q,
1527            &[
1528                Value::Vector(vec![0.1, 0.2]),
1529                Value::Integer(7),
1530                Value::Float(0.9),
1531            ],
1532        )
1533        .unwrap();
1534        let QueryExpr::SearchCommand(SearchCommand::Similar {
1535            limit,
1536            min_score,
1537            vector,
1538            vector_param,
1539            limit_param,
1540            min_score_param,
1541            ..
1542        }) = bound
1543        else {
1544            panic!("expected SearchCommand::Similar");
1545        };
1546        assert_eq!(vector, vec![0.1_f32, 0.2]);
1547        assert_eq!(limit, 7);
1548        assert!((min_score - 0.9_f32).abs() < 1e-6);
1549        assert_eq!(vector_param, None);
1550        assert_eq!(limit_param, None);
1551        assert_eq!(min_score_param, None);
1552    }
1553
1554    #[test]
1555    fn bind_ask_question_param() {
1556        let q = parse("ASK $1 USING openai LIMIT 1");
1557        let bound = bind(&q, &[Value::text("why did incident FDD-12313 fail?")]).unwrap();
1558        let QueryExpr::Ask(ask) = bound else {
1559            panic!("expected Ask");
1560        };
1561        assert_eq!(ask.question, "why did incident FDD-12313 fail?");
1562        assert_eq!(ask.question_param, None);
1563        assert_eq!(ask.provider.as_deref(), Some("openai"));
1564    }
1565
1566    #[test]
1567    fn bind_ask_question_param_rejects_non_text() {
1568        let q = parse("ASK $1 USING openai LIMIT 1");
1569        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1570        assert!(matches!(
1571            err,
1572            UserParamError::TypeMismatch {
1573                slot: "ASK question parameter",
1574                got: "integer"
1575            }
1576        ));
1577    }
1578
1579    #[test]
1580    fn bind_search_similar_limit_rejects_non_integer() {
1581        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1582        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1583        assert!(
1584            matches!(
1585                err,
1586                UserParamError::TypeMismatch {
1587                    slot: "SEARCH SIMILAR LIMIT parameter",
1588                    got: "text"
1589                }
1590            ),
1591            "got {err:?}"
1592        );
1593    }
1594
1595    #[test]
1596    fn bind_search_similar_limit_rejects_zero_or_negative() {
1597        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1598        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1599        assert!(matches!(
1600            err,
1601            UserParamError::TypeMismatch {
1602                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1603                ..
1604            }
1605        ));
1606        let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1607        assert!(matches!(
1608            err,
1609            UserParamError::TypeMismatch {
1610                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1611                ..
1612            }
1613        ));
1614    }
1615
1616    #[test]
1617    fn bind_search_similar_min_score_rejects_non_numeric() {
1618        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1619        let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1620        assert!(matches!(
1621            err,
1622            UserParamError::TypeMismatch {
1623                slot: "SEARCH SIMILAR MIN_SCORE parameter",
1624                got: "vector"
1625            }
1626        ));
1627    }
1628
1629    // Note: `?` placeholder at LIMIT/MIN_SCORE is correctly handled by
1630    // `parse_param_slot`, but `parse_multi` routes any `?`-bearing input
1631    // to the SPARQL frontend (see modes::detect). Exercising `?` for
1632    // non-Expr slots will land alongside the SPARQL detector tightening
1633    // tracked separately. The Dollar path covers the same code below.
1634
1635    #[test]
1636    fn bind_search_similar_rejects_non_vector_param() {
1637        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1638        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1639        assert!(
1640            matches!(
1641                err,
1642                UserParamError::TypeMismatch {
1643                    slot: "SEARCH SIMILAR vector parameter",
1644                    got: "integer"
1645                }
1646            ),
1647            "got {err:?}"
1648        );
1649    }
1650
1651    #[test]
1652    fn bind_search_similar_empty_vector_param() {
1653        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1654        let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1655        let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1656            panic!("expected SearchCommand::Similar");
1657        };
1658        assert!(vector.is_empty());
1659    }
1660
1661    #[test]
1662    fn bind_parameters_substitutes_all_wire_value_variants() {
1663        let q = parse(
1664            "INSERT INTO value_params \
1665             (n, ok, count, score, name, payload, dense, body, seen_at, ident) \
1666             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1667        );
1668        let uuid = [1_u8; 16];
1669        let params = vec![
1670            Value::Null,
1671            Value::Boolean(true),
1672            Value::Integer(42),
1673            Value::Float(1.5),
1674            Value::text("alice"),
1675            Value::Blob(vec![0, 1, 2]),
1676            Value::Vector(vec![0.25, 0.5]),
1677            Value::Json(br#"{"a":1}"#.to_vec()),
1678            Value::Timestamp(1_700_000_000),
1679            Value::Uuid(uuid),
1680        ];
1681        let bound = bind_parameters(&q, &params).unwrap();
1682        let QueryExpr::Insert(insert) = bound else {
1683            panic!("expected Insert");
1684        };
1685        assert_eq!(insert.values, vec![params]);
1686    }
1687
1688    #[test]
1689    fn bind_parameters_reuses_duplicate_index() {
1690        let q = parse("SELECT * FROM users WHERE id = $1 OR manager_id = $1");
1691        let bound = bind_parameters(&q, &[Value::Integer(7)]).unwrap();
1692        let QueryExpr::Table(table) = bound else {
1693            panic!("expected Table");
1694        };
1695        assert!(table.where_expr.is_some());
1696        assert_eq!(
1697            collect_indices(&QueryExpr::Table(table)),
1698            Vec::<usize>::new()
1699        );
1700    }
1701
1702    #[test]
1703    fn bind_search_hybrid_limit_param() {
1704        // Issue #361: `SEARCH HYBRID ... LIMIT $N` binds integer parameter.
1705        let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1706        let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1707        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1708            limit, limit_param, ..
1709        }) = bound
1710        else {
1711            panic!("expected SearchCommand::Hybrid");
1712        };
1713        assert_eq!(limit, 30);
1714        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1715    }
1716
1717    #[test]
1718    fn bind_search_hybrid_k_param() {
1719        // `K $N` is an alias for LIMIT in HYBRID.
1720        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1721        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1722        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1723            limit, limit_param, ..
1724        }) = bound
1725        else {
1726            panic!("expected SearchCommand::Hybrid");
1727        };
1728        assert_eq!(limit, 7);
1729        assert_eq!(limit_param, None);
1730    }
1731
1732    #[test]
1733    fn bind_search_hybrid_limit_rejects_non_integer() {
1734        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1735        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1736        assert!(
1737            matches!(
1738                err,
1739                UserParamError::TypeMismatch {
1740                    slot: "SEARCH HYBRID LIMIT parameter",
1741                    got: "text"
1742                }
1743            ),
1744            "got {err:?}"
1745        );
1746    }
1747
1748    #[test]
1749    fn bind_search_hybrid_limit_rejects_zero() {
1750        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1751        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1752        assert!(matches!(
1753            err,
1754            UserParamError::TypeMismatch {
1755                slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1756                ..
1757            }
1758        ));
1759    }
1760
1761    #[test]
1762    fn bind_search_spatial_nearest_k_param() {
1763        // Issue #361: `SEARCH SPATIAL NEAREST ... K $N` binds an integer.
1764        let q =
1765            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1766        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1767        let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1768        else {
1769            panic!("expected SpatialNearest");
1770        };
1771        assert_eq!(k, 7);
1772        assert_eq!(k_param, None, "k_param must be cleared post-bind");
1773    }
1774
1775    #[test]
1776    fn bind_search_spatial_nearest_k_rejects_zero() {
1777        let q =
1778            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1779        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1780        assert!(matches!(
1781            err,
1782            UserParamError::TypeMismatch {
1783                slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1784                ..
1785            }
1786        ));
1787    }
1788
1789    #[test]
1790    fn bind_search_spatial_nearest_k_rejects_non_integer() {
1791        let q =
1792            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1793        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1794        assert!(matches!(
1795            err,
1796            UserParamError::TypeMismatch {
1797                slot: "SEARCH SPATIAL NEAREST K parameter",
1798                got: "text"
1799            }
1800        ));
1801    }
1802
1803    #[test]
1804    fn bind_search_text_limit_param() {
1805        // Issue #361: `SEARCH TEXT ... LIMIT $N` binds an integer.
1806        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1807        let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1808        let QueryExpr::SearchCommand(SearchCommand::Text {
1809            limit, limit_param, ..
1810        }) = bound
1811        else {
1812            panic!("expected SearchCommand::Text");
1813        };
1814        assert_eq!(limit, 15);
1815        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1816    }
1817
1818    #[test]
1819    fn bind_search_text_limit_rejects_zero() {
1820        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1821        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1822        assert!(matches!(
1823            err,
1824            UserParamError::TypeMismatch {
1825                slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1826                ..
1827            }
1828        ));
1829    }
1830
1831    #[test]
1832    fn bind_search_text_limit_rejects_non_integer() {
1833        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1834        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1835        assert!(matches!(
1836            err,
1837            UserParamError::TypeMismatch {
1838                slot: "SEARCH TEXT LIMIT parameter",
1839                got: "text"
1840            }
1841        ));
1842    }
1843
1844    #[test]
1845    fn bind_search_multimodal_limit_param() {
1846        // Issue #361: `SEARCH MULTIMODAL ... LIMIT $N` binds an integer.
1847        let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1848        let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1849        let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1850            limit, limit_param, ..
1851        }) = bound
1852        else {
1853            panic!("expected SearchCommand::Multimodal");
1854        };
1855        assert_eq!(limit, 40);
1856        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1857    }
1858
1859    #[test]
1860    fn bind_search_multimodal_limit_rejects_zero() {
1861        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1862        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1863        assert!(matches!(
1864            err,
1865            UserParamError::TypeMismatch {
1866                slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1867                ..
1868            }
1869        ));
1870    }
1871
1872    #[test]
1873    fn bind_search_multimodal_limit_rejects_non_integer() {
1874        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1875        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1876        assert!(matches!(
1877            err,
1878            UserParamError::TypeMismatch {
1879                slot: "SEARCH MULTIMODAL LIMIT parameter",
1880                got: "text"
1881            }
1882        ));
1883    }
1884
1885    #[test]
1886    fn bind_search_index_limit_param() {
1887        // Issue #361: `SEARCH INDEX ... LIMIT $N` binds an integer.
1888        let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1889        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1890        let QueryExpr::SearchCommand(SearchCommand::Index {
1891            limit, limit_param, ..
1892        }) = bound
1893        else {
1894            panic!("expected SearchCommand::Index");
1895        };
1896        assert_eq!(limit, 50);
1897        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1898    }
1899
1900    #[test]
1901    fn bind_search_index_limit_rejects_zero() {
1902        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1903        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1904        assert!(matches!(
1905            err,
1906            UserParamError::TypeMismatch {
1907                slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1908                ..
1909            }
1910        ));
1911    }
1912
1913    #[test]
1914    fn bind_search_index_limit_rejects_non_integer() {
1915        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1916        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1917        assert!(matches!(
1918            err,
1919            UserParamError::TypeMismatch {
1920                slot: "SEARCH INDEX LIMIT parameter",
1921                got: "text"
1922            }
1923        ));
1924    }
1925
1926    #[test]
1927    fn bind_search_context_limit_param() {
1928        // Issue #361: `SEARCH CONTEXT ... LIMIT $N` binds an integer.
1929        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1930        let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1931        let QueryExpr::SearchCommand(SearchCommand::Context {
1932            limit, limit_param, ..
1933        }) = bound
1934        else {
1935            panic!("expected SearchCommand::Context");
1936        };
1937        assert_eq!(limit, 60);
1938        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1939    }
1940
1941    #[test]
1942    fn bind_search_context_limit_rejects_zero() {
1943        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1944        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1945        assert!(matches!(
1946            err,
1947            UserParamError::TypeMismatch {
1948                slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1949                ..
1950            }
1951        ));
1952    }
1953
1954    #[test]
1955    fn bind_search_context_limit_rejects_non_integer() {
1956        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1957        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1958        assert!(matches!(
1959            err,
1960            UserParamError::TypeMismatch {
1961                slot: "SEARCH CONTEXT LIMIT parameter",
1962                got: "text"
1963            }
1964        ));
1965    }
1966
1967    #[test]
1968    fn bind_search_spatial_radius_limit_param() {
1969        // Issue #361: `SEARCH SPATIAL RADIUS ... LIMIT $N` binds an integer.
1970        let q = parse(
1971            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1972        );
1973        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1974        let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1975            limit, limit_param, ..
1976        }) = bound
1977        else {
1978            panic!("expected SearchCommand::SpatialRadius");
1979        };
1980        assert_eq!(limit, 50);
1981        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1982    }
1983
1984    #[test]
1985    fn bind_search_spatial_radius_limit_rejects_zero() {
1986        let q = parse(
1987            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1988        );
1989        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1990        assert!(matches!(
1991            err,
1992            UserParamError::TypeMismatch {
1993                slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1994                ..
1995            }
1996        ));
1997    }
1998
1999    #[test]
2000    fn bind_search_spatial_radius_limit_rejects_non_integer() {
2001        let q = parse(
2002            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
2003        );
2004        let err = bind(&q, &[Value::text("five")]).unwrap_err();
2005        assert!(matches!(
2006            err,
2007            UserParamError::TypeMismatch {
2008                slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
2009                got: "text"
2010            }
2011        ));
2012    }
2013
2014    #[test]
2015    fn bind_search_spatial_bbox_limit_param() {
2016        // Issue #361: `SEARCH SPATIAL BBOX ... LIMIT $N` binds an integer.
2017        let q =
2018            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
2019        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
2020        let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
2021            limit, limit_param, ..
2022        }) = bound
2023        else {
2024            panic!("expected SearchCommand::SpatialBbox");
2025        };
2026        assert_eq!(limit, 50);
2027        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
2028    }
2029
2030    #[test]
2031    fn bind_search_spatial_bbox_limit_rejects_zero() {
2032        let q =
2033            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
2034        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
2035        assert!(matches!(
2036            err,
2037            UserParamError::TypeMismatch {
2038                slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
2039                ..
2040            }
2041        ));
2042    }
2043
2044    #[test]
2045    fn bind_search_spatial_bbox_limit_rejects_non_integer() {
2046        let q =
2047            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
2048        let err = bind(&q, &[Value::text("five")]).unwrap_err();
2049        assert!(matches!(
2050            err,
2051            UserParamError::TypeMismatch {
2052                slot: "SEARCH SPATIAL BBOX LIMIT parameter",
2053                got: "text"
2054            }
2055        ));
2056    }
2057
2058    #[test]
2059    fn bind_search_similar_text_param() {
2060        // Issue #361: `SEARCH SIMILAR TEXT $N` binds a Value::Text into
2061        // the text slot. The embedding pipeline reads `text` downstream.
2062        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
2063        let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
2064        let QueryExpr::SearchCommand(SearchCommand::Similar {
2065            vector,
2066            text,
2067            text_param,
2068            collection,
2069            limit,
2070            provider,
2071            ..
2072        }) = bound
2073        else {
2074            panic!("expected SearchCommand::Similar");
2075        };
2076        assert!(vector.is_empty());
2077        assert_eq!(text.as_deref(), Some("find vulnerabilities"));
2078        assert_eq!(text_param, None, "text_param must be cleared post-bind");
2079        assert_eq!(collection, "docs");
2080        assert_eq!(limit, 5);
2081        assert_eq!(provider.as_deref(), Some("openai"));
2082    }
2083
2084    #[test]
2085    fn bind_search_similar_text_rejects_non_text() {
2086        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
2087        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
2088        assert!(
2089            matches!(
2090                err,
2091                UserParamError::TypeMismatch {
2092                    slot: "SEARCH SIMILAR TEXT parameter",
2093                    got: "integer"
2094                }
2095            ),
2096            "got {err:?}"
2097        );
2098    }
2099
2100    #[test]
2101    fn bind_search_similar_text_with_limit_param() {
2102        // TEXT $1 + LIMIT $2 — verify both non-Expr param slots bind
2103        // together without cross-talk.
2104        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
2105        let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
2106        let QueryExpr::SearchCommand(SearchCommand::Similar {
2107            text,
2108            text_param,
2109            limit,
2110            limit_param,
2111            ..
2112        }) = bound
2113        else {
2114            panic!("expected SearchCommand::Similar");
2115        };
2116        assert_eq!(text.as_deref(), Some("hello"));
2117        assert_eq!(text_param, None);
2118        assert_eq!(limit, 11);
2119        assert_eq!(limit_param, None);
2120    }
2121
2122    #[test]
2123    fn bind_insert_values_with_vector_param() {
2124        // Issue #355 INSERT half: $1 in VALUES is bound to a Value::Vector
2125        // and surfaces in both `value_exprs` (as a Literal) and `values`.
2126        let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
2127        let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
2128        let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
2129        let QueryExpr::Insert(insert) = bound else {
2130            panic!("expected Insert");
2131        };
2132        assert_eq!(insert.values.len(), 1);
2133        assert_eq!(insert.values[0].len(), 2);
2134        assert!(
2135            matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
2136        );
2137        assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
2138        // value_exprs row 0 col 0 is now a Literal carrying the vector.
2139        let row0 = &insert.value_exprs[0];
2140        assert!(matches!(
2141            &row0[0],
2142            Expr::Literal {
2143                value: Value::Vector(_),
2144                ..
2145            }
2146        ));
2147    }
2148
2149    #[test]
2150    fn bind_insert_on_conflict_update_assignment_param() {
2151        let query = parse(
2152            "INSERT INTO users (email, name) VALUES ($1, $2) \
2153             ON CONFLICT (email) DO UPDATE SET name = $3",
2154        );
2155        let bound = bind(
2156            &query,
2157            &[
2158                Value::text("ada@example.com"),
2159                Value::text("Ada"),
2160                Value::text("Grace"),
2161            ],
2162        )
2163        .expect("all INSERT conflict parameters should bind");
2164        let QueryExpr::Insert(insert) = bound else {
2165            panic!("expected insert");
2166        };
2167        let Some(reddb_rql::ast::OnConflictClause {
2168            action: reddb_rql::ast::OnConflictAction::DoUpdate { assignments },
2169            ..
2170        }) = insert.on_conflict
2171        else {
2172            panic!("expected conflict update");
2173        };
2174
2175        assert!(matches!(
2176            assignments.as_slice(),
2177            [(column, Expr::Literal { value: Value::Text(value), .. })]
2178                if column == "name" && value.as_ref() == "Grace"
2179        ));
2180    }
2181
2182    #[test]
2183    fn bind_insert_arity_mismatch() {
2184        let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
2185        let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
2186        assert!(matches!(
2187            err,
2188            UserParamError::Arity {
2189                expected: 2,
2190                got: 1
2191            }
2192        ));
2193    }
2194
2195    #[test]
2196    fn bind_update_assignments_and_where_params() {
2197        let q = parse("UPDATE users SET age = $1, active = $2 WHERE name = $3");
2198        let bound = bind(
2199            &q,
2200            &[
2201                Value::Integer(31),
2202                Value::Boolean(true),
2203                Value::text("Alice"),
2204            ],
2205        )
2206        .unwrap();
2207        let QueryExpr::Update(update) = bound else {
2208            panic!("expected Update");
2209        };
2210        assert_eq!(update.assignments.len(), 2);
2211        assert!(matches!(update.assignments[0].1, Value::Integer(31)));
2212        assert!(matches!(update.assignments[1].1, Value::Boolean(true)));
2213        assert!(update.where_expr.is_some());
2214        assert!(update.filter.is_some());
2215    }
2216
2217    #[test]
2218    fn bind_delete_where_param() {
2219        let q = parse("DELETE FROM users WHERE active = $1");
2220        let bound = bind(&q, &[Value::Boolean(false)]).unwrap();
2221        let QueryExpr::Delete(delete) = bound else {
2222            panic!("expected Delete");
2223        };
2224        assert!(delete.where_expr.is_some());
2225        assert!(delete.filter.is_some());
2226    }
2227
2228    #[test]
2229    fn bind_select_limit_param() {
2230        let q = parse("SELECT * FROM users LIMIT $1");
2231        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
2232        let QueryExpr::Table(t) = bound else {
2233            panic!("expected Table");
2234        };
2235        assert_eq!(t.limit, Some(7));
2236        assert_eq!(t.limit_param, None, "limit_param must be cleared post-bind");
2237        assert_eq!(t.offset, None);
2238        assert_eq!(t.offset_param, None);
2239    }
2240
2241    #[test]
2242    fn bind_select_offset_param() {
2243        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2244        let bound = bind(&q, &[Value::Integer(20)]).unwrap();
2245        let QueryExpr::Table(t) = bound else {
2246            panic!("expected Table");
2247        };
2248        assert_eq!(t.limit, Some(10));
2249        assert_eq!(t.offset, Some(20));
2250        assert_eq!(t.offset_param, None);
2251    }
2252
2253    #[test]
2254    fn bind_select_limit_and_offset_params_together() {
2255        let q = parse("SELECT * FROM users WHERE id = $1 LIMIT $2 OFFSET $3");
2256        let bound = bind(
2257            &q,
2258            &[Value::Integer(5), Value::Integer(10), Value::Integer(20)],
2259        )
2260        .unwrap();
2261        let QueryExpr::Table(t) = bound else {
2262            panic!("expected Table");
2263        };
2264        assert_eq!(t.limit, Some(10));
2265        assert_eq!(t.offset, Some(20));
2266        assert_eq!(t.limit_param, None);
2267        assert_eq!(t.offset_param, None);
2268        // WHERE id = $1 → Expr-tree bind also ran.
2269        assert!(t.where_expr.is_some());
2270    }
2271
2272    #[test]
2273    fn bind_select_offset_zero_is_valid() {
2274        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2275        let bound = bind(&q, &[Value::Integer(0)]).unwrap();
2276        let QueryExpr::Table(t) = bound else {
2277            panic!("expected Table");
2278        };
2279        assert_eq!(t.offset, Some(0));
2280    }
2281
2282    #[test]
2283    fn bind_select_limit_rejects_zero() {
2284        let q = parse("SELECT * FROM users LIMIT $1");
2285        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
2286        assert!(
2287            matches!(
2288                err,
2289                UserParamError::TypeMismatch {
2290                    slot: "SELECT LIMIT parameter (must be > 0)",
2291                    ..
2292                }
2293            ),
2294            "got {err:?}"
2295        );
2296    }
2297
2298    #[test]
2299    fn bind_select_limit_rejects_non_integer() {
2300        let q = parse("SELECT * FROM users LIMIT $1");
2301        let err = bind(&q, &[Value::text("ten")]).unwrap_err();
2302        assert!(
2303            matches!(
2304                err,
2305                UserParamError::TypeMismatch {
2306                    slot: "SELECT LIMIT parameter",
2307                    got: "text"
2308                }
2309            ),
2310            "got {err:?}"
2311        );
2312    }
2313
2314    #[test]
2315    fn bind_select_offset_rejects_negative() {
2316        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2317        let err = bind(&q, &[Value::Integer(-1)]).unwrap_err();
2318        assert!(
2319            matches!(
2320                err,
2321                UserParamError::TypeMismatch {
2322                    slot: "SELECT OFFSET parameter (must be >= 0)",
2323                    ..
2324                }
2325            ),
2326            "got {err:?}"
2327        );
2328    }
2329
2330    #[test]
2331    fn bind_no_params_is_noop() {
2332        let q = parse("SELECT * FROM users");
2333        let bound = bind(&q, &[]).unwrap();
2334        assert!(matches!(bound, QueryExpr::Table(_)));
2335    }
2336}