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