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