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