Skip to main content

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