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            .filter_map(|(column, expr)| {
873                fold_expr_to_value(expr.clone())
874                    .ok()
875                    .map(|value| (column.clone(), value))
876            })
877            .collect();
878        let where_expr = bound
879            .where_expr
880            .map(|expr| substitute_params_in_expr(expr, params))
881            .transpose()?;
882        let filter = where_expr.as_ref().map(expr_to_filter);
883        bound.assignment_exprs = assignment_exprs;
884        bound.assignments = assignments;
885        bound.where_expr = where_expr;
886        bound.filter = filter;
887        return Ok(QueryExpr::Update(bound));
888    }
889
890    if let QueryExpr::Delete(delete) = expr {
891        let mut bound = delete.clone();
892        let where_expr = bound
893            .where_expr
894            .map(|expr| substitute_params_in_expr(expr, params))
895            .transpose()?;
896        let filter = where_expr.as_ref().map(expr_to_filter);
897        bound.where_expr = where_expr;
898        bound.filter = filter;
899        return Ok(QueryExpr::Delete(bound));
900    }
901
902    if let QueryExpr::Ask(ask) = expr {
903        let Some(idx) = ask.question_param else {
904            return Ok(QueryExpr::Ask(ask.clone()));
905        };
906        let value = params.get(idx).ok_or(UserParamError::Arity {
907            expected: idx + 1,
908            got: params.len(),
909        })?;
910        let question = match value {
911            Value::Text(s) => s.to_string(),
912            other => {
913                return Err(UserParamError::TypeMismatch {
914                    slot: "ASK question parameter",
915                    got: value_variant_name(other),
916                });
917            }
918        };
919        let mut bound = ask.clone();
920        bound.question = question;
921        bound.question_param = None;
922        return Ok(QueryExpr::Ask(bound));
923    }
924
925    // SELECT LIMIT / OFFSET $N — the planner's Expr-tree binder doesn't
926    // see these slots (they live on TableQuery, not inside any Expr).
927    // Run the Expr-tree bind first, then substitute the non-Expr slots
928    // post-hoc. Mirrors the SearchCommand::Similar pattern above.
929    if let QueryExpr::Table(table) = expr {
930        if table.limit_param.is_some() || table.offset_param.is_some() {
931            let bound_inner =
932                bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)?;
933            let mut bound_table = match bound_inner {
934                QueryExpr::Table(t) => t,
935                _ => return Err(UserParamError::UnsupportedShape),
936            };
937            if let Some(idx) = table.limit_param {
938                let value = params.get(idx).ok_or(UserParamError::Arity {
939                    expected: idx + 1,
940                    got: params.len(),
941                })?;
942                let n = match value {
943                    Value::Integer(n) if *n > 0 => *n as u64,
944                    Value::UnsignedInteger(n) if *n > 0 => *n,
945                    Value::BigInt(n) if *n > 0 => *n as u64,
946                    Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
947                        return Err(UserParamError::TypeMismatch {
948                            slot: "SELECT LIMIT parameter (must be > 0)",
949                            got: value_variant_name(value),
950                        });
951                    }
952                    other => {
953                        return Err(UserParamError::TypeMismatch {
954                            slot: "SELECT LIMIT parameter",
955                            got: value_variant_name(other),
956                        });
957                    }
958                };
959                bound_table.limit = Some(n);
960                bound_table.limit_param = None;
961            }
962            if let Some(idx) = table.offset_param {
963                let value = params.get(idx).ok_or(UserParamError::Arity {
964                    expected: idx + 1,
965                    got: params.len(),
966                })?;
967                let n = match value {
968                    Value::Integer(n) if *n >= 0 => *n as u64,
969                    Value::UnsignedInteger(n) => *n,
970                    Value::BigInt(n) if *n >= 0 => *n as u64,
971                    Value::Integer(_) | Value::BigInt(_) => {
972                        return Err(UserParamError::TypeMismatch {
973                            slot: "SELECT OFFSET parameter (must be >= 0)",
974                            got: value_variant_name(value),
975                        });
976                    }
977                    other => {
978                        return Err(UserParamError::TypeMismatch {
979                            slot: "SELECT OFFSET parameter",
980                            got: value_variant_name(other),
981                        });
982                    }
983                };
984                bound_table.offset = Some(n);
985                bound_table.offset_param = None;
986            }
987            return Ok(QueryExpr::Table(bound_table));
988        }
989    }
990
991    bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
992}
993
994/// One-shot helper matching the parameter-contract ADR wording.
995pub fn bind_parameters(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, BindError> {
996    bind(expr, params)
997}
998
999fn value_variant_name(value: &Value) -> &'static str {
1000    match value {
1001        Value::Null => "null",
1002        Value::Integer(_) => "integer",
1003        Value::UnsignedInteger(_) => "unsigned integer",
1004        Value::BigInt(_) => "bigint",
1005        Value::Float(_) => "float",
1006        Value::Text(_) => "text",
1007        Value::Boolean(_) => "boolean",
1008        Value::Vector(_) => "vector",
1009        Value::Json(_) => "json",
1010        Value::Blob(_) => "bytes",
1011        _ => "other",
1012    }
1013}
1014
1015fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
1016    match expr {
1017        QueryExpr::Table(q) => {
1018            for item in &q.select_items {
1019                if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
1020                    visit_expr(expr, visit);
1021                }
1022            }
1023            if let Some(e) = &q.where_expr {
1024                visit_expr(e, visit);
1025            }
1026            for e in &q.group_by_exprs {
1027                visit_expr(e, visit);
1028            }
1029            if let Some(e) = &q.having_expr {
1030                visit_expr(e, visit);
1031            }
1032            for clause in &q.order_by {
1033                if let Some(e) = &clause.expr {
1034                    visit_expr(e, visit);
1035                }
1036            }
1037            if let Some(crate::storage::query::ast::TableSource::Subquery(inner)) = &q.source {
1038                visit_query_expr(inner, visit);
1039            }
1040        }
1041        QueryExpr::Join(q) => {
1042            visit_query_expr(&q.left, visit);
1043            visit_query_expr(&q.right, visit);
1044        }
1045        QueryExpr::Hybrid(q) => {
1046            visit_query_expr(&q.structured, visit);
1047        }
1048        QueryExpr::Insert(q) => {
1049            for row in &q.value_exprs {
1050                for e in row {
1051                    visit_expr(e, visit);
1052                }
1053            }
1054        }
1055        QueryExpr::Update(q) => {
1056            for (_, e) in &q.assignment_exprs {
1057                visit_expr(e, visit);
1058            }
1059            if let Some(e) = &q.where_expr {
1060                visit_expr(e, visit);
1061            }
1062        }
1063        QueryExpr::Delete(q) => {
1064            if let Some(e) = &q.where_expr {
1065                visit_expr(e, visit);
1066            }
1067        }
1068        // Vector / Graph / Path: parameter slots in #355 / later issues.
1069        _ => {}
1070    }
1071}
1072
1073fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
1074    visit(expr);
1075    match expr {
1076        Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
1077        Expr::BinaryOp { lhs, rhs, .. } => {
1078            visit_expr(lhs, visit);
1079            visit_expr(rhs, visit);
1080        }
1081        Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
1082        Expr::Cast { inner, .. } => visit_expr(inner, visit),
1083        Expr::FunctionCall { args, .. } => {
1084            for a in args {
1085                visit_expr(a, visit);
1086            }
1087        }
1088        Expr::Case {
1089            branches, else_, ..
1090        } => {
1091            for (c, v) in branches {
1092                visit_expr(c, visit);
1093                visit_expr(v, visit);
1094            }
1095            if let Some(e) = else_ {
1096                visit_expr(e, visit);
1097            }
1098        }
1099        Expr::IsNull { operand, .. } => visit_expr(operand, visit),
1100        Expr::InList { target, values, .. } => {
1101            visit_expr(target, visit);
1102            for v in values {
1103                visit_expr(v, visit);
1104            }
1105        }
1106        Expr::Between {
1107            target, low, high, ..
1108        } => {
1109            visit_expr(target, visit);
1110            visit_expr(low, visit);
1111            visit_expr(high, visit);
1112        }
1113        Expr::Subquery { .. } => {}
1114    }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120    use crate::storage::query::modes::parse_multi;
1121
1122    fn parse(sql: &str) -> QueryExpr {
1123        parse_multi(sql).expect("parse")
1124    }
1125
1126    #[test]
1127    fn collect_indices_select_where() {
1128        let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
1129        let mut ix = collect_indices(&q);
1130        ix.sort();
1131        assert_eq!(ix, vec![0, 1]);
1132    }
1133
1134    #[test]
1135    fn scan_parameters_reports_index_and_span() {
1136        let sql = "SELECT * FROM users WHERE id = $1 AND name = $2";
1137        let q = parse(sql);
1138        let params = scan_parameters(&q);
1139        assert_eq!(
1140            params.iter().map(|param| param.index).collect::<Vec<_>>(),
1141            vec![0, 1]
1142        );
1143        assert_eq!(
1144            sql[params[0].span.start.offset as usize..params[0].span.end.offset as usize].trim(),
1145            "$1"
1146        );
1147        assert_eq!(
1148            sql[params[1].span.start.offset as usize..params[1].span.end.offset as usize].trim(),
1149            "$2"
1150        );
1151    }
1152
1153    #[test]
1154    fn validate_ok() {
1155        assert!(validate(&[0, 1], 2).is_ok());
1156        assert!(validate(&[0, 1, 0], 2).is_ok());
1157        assert!(validate(&[], 0).is_ok());
1158    }
1159
1160    #[test]
1161    fn validate_arity_too_few() {
1162        let err = validate(&[0, 1], 1).unwrap_err();
1163        assert!(matches!(
1164            err,
1165            UserParamError::Arity {
1166                expected: 2,
1167                got: 1
1168            }
1169        ));
1170    }
1171
1172    #[test]
1173    fn validate_arity_too_many() {
1174        let err = validate(&[0], 3).unwrap_err();
1175        assert!(matches!(
1176            err,
1177            UserParamError::Arity {
1178                expected: 1,
1179                got: 3
1180            }
1181        ));
1182    }
1183
1184    #[test]
1185    fn validate_gap() {
1186        // $1 and $3 used, but not $2.
1187        let err = validate(&[0, 2], 3).unwrap_err();
1188        assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
1189    }
1190
1191    #[test]
1192    fn bind_substitutes_int_param() {
1193        let q = parse("SELECT * FROM users WHERE id = $1");
1194        let bound = bind(&q, &[Value::Integer(42)]).unwrap();
1195        let QueryExpr::Table(t) = bound else {
1196            panic!("expected Table");
1197        };
1198        let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
1199            panic!("expected BinaryOp");
1200        };
1201        assert!(matches!(
1202            *rhs,
1203            Expr::Literal {
1204                value: Value::Integer(42),
1205                ..
1206            }
1207        ));
1208    }
1209
1210    #[test]
1211    fn bind_substitutes_question_numbered_param() {
1212        let q = parse("SELECT * FROM users WHERE id = ?1 AND name = ?2");
1213        let bound = bind(&q, &[Value::Integer(42), Value::text("Alice")]).unwrap();
1214        let QueryExpr::Table(t) = bound else {
1215            panic!("expected Table");
1216        };
1217        let mut literals: Vec<Value> = Vec::new();
1218        visit_expr(&t.where_expr.unwrap(), &mut |e| {
1219            if let Expr::Literal { value, .. } = e {
1220                literals.push(value.clone());
1221            }
1222        });
1223        assert!(literals.iter().any(|v| matches!(v, Value::Integer(42))));
1224        assert!(literals
1225            .iter()
1226            .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1227    }
1228
1229    #[test]
1230    fn bind_substitutes_text_and_null() {
1231        let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1232        let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1233        let QueryExpr::Table(t) = bound else {
1234            panic!("expected Table");
1235        };
1236        let mut literals: Vec<Value> = Vec::new();
1237        visit_expr(&t.where_expr.unwrap(), &mut |e| {
1238            if let Expr::Literal { value, .. } = e {
1239                literals.push(value.clone());
1240            }
1241        });
1242        assert!(literals
1243            .iter()
1244            .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1245        assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1246    }
1247
1248    #[test]
1249    fn bind_search_similar_vector_param() {
1250        // Tracer for #355: `SEARCH SIMILAR $1 COLLECTION embeddings`
1251        // binds the supplied `Value::Vector` into the vector slot.
1252        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1253        let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1254        let QueryExpr::SearchCommand(SearchCommand::Similar {
1255            vector,
1256            vector_param,
1257            collection,
1258            limit,
1259            ..
1260        }) = bound
1261        else {
1262            panic!("expected SearchCommand::Similar");
1263        };
1264        assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1265        assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1266        assert_eq!(collection, "embeddings");
1267        assert_eq!(limit, 5);
1268    }
1269
1270    #[test]
1271    fn bind_search_similar_limit_param() {
1272        // Issue #361: `LIMIT $N` binds an integer parameter.
1273        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1274        let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1275        let QueryExpr::SearchCommand(SearchCommand::Similar {
1276            limit,
1277            limit_param,
1278            min_score_param,
1279            ..
1280        }) = bound
1281        else {
1282            panic!("expected SearchCommand::Similar");
1283        };
1284        assert_eq!(limit, 25);
1285        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1286        assert_eq!(min_score_param, None);
1287    }
1288
1289    #[test]
1290    fn bind_search_similar_min_score_param() {
1291        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1292        let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1293        let QueryExpr::SearchCommand(SearchCommand::Similar {
1294            min_score,
1295            min_score_param,
1296            ..
1297        }) = bound
1298        else {
1299            panic!("expected SearchCommand::Similar");
1300        };
1301        assert!((min_score - 0.42_f32).abs() < 1e-6);
1302        assert_eq!(min_score_param, None);
1303    }
1304
1305    #[test]
1306    fn bind_search_similar_limit_and_min_score_together() {
1307        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1308        let bound = bind(
1309            &q,
1310            &[
1311                Value::Vector(vec![0.1, 0.2]),
1312                Value::Integer(7),
1313                Value::Float(0.9),
1314            ],
1315        )
1316        .unwrap();
1317        let QueryExpr::SearchCommand(SearchCommand::Similar {
1318            limit,
1319            min_score,
1320            vector,
1321            vector_param,
1322            limit_param,
1323            min_score_param,
1324            ..
1325        }) = bound
1326        else {
1327            panic!("expected SearchCommand::Similar");
1328        };
1329        assert_eq!(vector, vec![0.1_f32, 0.2]);
1330        assert_eq!(limit, 7);
1331        assert!((min_score - 0.9_f32).abs() < 1e-6);
1332        assert_eq!(vector_param, None);
1333        assert_eq!(limit_param, None);
1334        assert_eq!(min_score_param, None);
1335    }
1336
1337    #[test]
1338    fn bind_ask_question_param() {
1339        let q = parse("ASK $1 USING openai LIMIT 1");
1340        let bound = bind(&q, &[Value::text("why did incident FDD-12313 fail?")]).unwrap();
1341        let QueryExpr::Ask(ask) = bound else {
1342            panic!("expected Ask");
1343        };
1344        assert_eq!(ask.question, "why did incident FDD-12313 fail?");
1345        assert_eq!(ask.question_param, None);
1346        assert_eq!(ask.provider.as_deref(), Some("openai"));
1347    }
1348
1349    #[test]
1350    fn bind_ask_question_param_rejects_non_text() {
1351        let q = parse("ASK $1 USING openai LIMIT 1");
1352        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1353        assert!(matches!(
1354            err,
1355            UserParamError::TypeMismatch {
1356                slot: "ASK question parameter",
1357                got: "integer"
1358            }
1359        ));
1360    }
1361
1362    #[test]
1363    fn bind_search_similar_limit_rejects_non_integer() {
1364        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1365        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1366        assert!(
1367            matches!(
1368                err,
1369                UserParamError::TypeMismatch {
1370                    slot: "SEARCH SIMILAR LIMIT parameter",
1371                    got: "text"
1372                }
1373            ),
1374            "got {err:?}"
1375        );
1376    }
1377
1378    #[test]
1379    fn bind_search_similar_limit_rejects_zero_or_negative() {
1380        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1381        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1382        assert!(matches!(
1383            err,
1384            UserParamError::TypeMismatch {
1385                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1386                ..
1387            }
1388        ));
1389        let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1390        assert!(matches!(
1391            err,
1392            UserParamError::TypeMismatch {
1393                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1394                ..
1395            }
1396        ));
1397    }
1398
1399    #[test]
1400    fn bind_search_similar_min_score_rejects_non_numeric() {
1401        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1402        let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1403        assert!(matches!(
1404            err,
1405            UserParamError::TypeMismatch {
1406                slot: "SEARCH SIMILAR MIN_SCORE parameter",
1407                got: "vector"
1408            }
1409        ));
1410    }
1411
1412    // Note: `?` placeholder at LIMIT/MIN_SCORE is correctly handled by
1413    // `parse_param_slot`, but `parse_multi` routes any `?`-bearing input
1414    // to the SPARQL frontend (see modes::detect). Exercising `?` for
1415    // non-Expr slots will land alongside the SPARQL detector tightening
1416    // tracked separately. The Dollar path covers the same code below.
1417
1418    #[test]
1419    fn bind_search_similar_rejects_non_vector_param() {
1420        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1421        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1422        assert!(
1423            matches!(
1424                err,
1425                UserParamError::TypeMismatch {
1426                    slot: "SEARCH SIMILAR vector parameter",
1427                    got: "integer"
1428                }
1429            ),
1430            "got {err:?}"
1431        );
1432    }
1433
1434    #[test]
1435    fn bind_search_similar_empty_vector_param() {
1436        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1437        let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1438        let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1439            panic!("expected SearchCommand::Similar");
1440        };
1441        assert!(vector.is_empty());
1442    }
1443
1444    #[test]
1445    fn bind_parameters_substitutes_all_wire_value_variants() {
1446        let q = parse(
1447            "INSERT INTO value_params \
1448             (n, ok, count, score, name, payload, dense, body, seen_at, ident) \
1449             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1450        );
1451        let uuid = [1_u8; 16];
1452        let params = vec![
1453            Value::Null,
1454            Value::Boolean(true),
1455            Value::Integer(42),
1456            Value::Float(1.5),
1457            Value::text("alice"),
1458            Value::Blob(vec![0, 1, 2]),
1459            Value::Vector(vec![0.25, 0.5]),
1460            Value::Json(br#"{"a":1}"#.to_vec()),
1461            Value::Timestamp(1_700_000_000),
1462            Value::Uuid(uuid),
1463        ];
1464        let bound = bind_parameters(&q, &params).unwrap();
1465        let QueryExpr::Insert(insert) = bound else {
1466            panic!("expected Insert");
1467        };
1468        assert_eq!(insert.values, vec![params]);
1469    }
1470
1471    #[test]
1472    fn bind_parameters_reuses_duplicate_index() {
1473        let q = parse("SELECT * FROM users WHERE id = $1 OR manager_id = $1");
1474        let bound = bind_parameters(&q, &[Value::Integer(7)]).unwrap();
1475        let QueryExpr::Table(table) = bound else {
1476            panic!("expected Table");
1477        };
1478        assert!(table.where_expr.is_some());
1479        assert_eq!(
1480            collect_indices(&QueryExpr::Table(table)),
1481            Vec::<usize>::new()
1482        );
1483    }
1484
1485    #[test]
1486    fn bind_search_hybrid_limit_param() {
1487        // Issue #361: `SEARCH HYBRID ... LIMIT $N` binds integer parameter.
1488        let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1489        let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1490        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1491            limit, limit_param, ..
1492        }) = bound
1493        else {
1494            panic!("expected SearchCommand::Hybrid");
1495        };
1496        assert_eq!(limit, 30);
1497        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1498    }
1499
1500    #[test]
1501    fn bind_search_hybrid_k_param() {
1502        // `K $N` is an alias for LIMIT in HYBRID.
1503        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1504        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1505        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1506            limit, limit_param, ..
1507        }) = bound
1508        else {
1509            panic!("expected SearchCommand::Hybrid");
1510        };
1511        assert_eq!(limit, 7);
1512        assert_eq!(limit_param, None);
1513    }
1514
1515    #[test]
1516    fn bind_search_hybrid_limit_rejects_non_integer() {
1517        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1518        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1519        assert!(
1520            matches!(
1521                err,
1522                UserParamError::TypeMismatch {
1523                    slot: "SEARCH HYBRID LIMIT parameter",
1524                    got: "text"
1525                }
1526            ),
1527            "got {err:?}"
1528        );
1529    }
1530
1531    #[test]
1532    fn bind_search_hybrid_limit_rejects_zero() {
1533        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1534        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1535        assert!(matches!(
1536            err,
1537            UserParamError::TypeMismatch {
1538                slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1539                ..
1540            }
1541        ));
1542    }
1543
1544    #[test]
1545    fn bind_search_spatial_nearest_k_param() {
1546        // Issue #361: `SEARCH SPATIAL NEAREST ... K $N` binds an integer.
1547        let q =
1548            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1549        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1550        let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1551        else {
1552            panic!("expected SpatialNearest");
1553        };
1554        assert_eq!(k, 7);
1555        assert_eq!(k_param, None, "k_param must be cleared post-bind");
1556    }
1557
1558    #[test]
1559    fn bind_search_spatial_nearest_k_rejects_zero() {
1560        let q =
1561            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1562        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1563        assert!(matches!(
1564            err,
1565            UserParamError::TypeMismatch {
1566                slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1567                ..
1568            }
1569        ));
1570    }
1571
1572    #[test]
1573    fn bind_search_spatial_nearest_k_rejects_non_integer() {
1574        let q =
1575            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1576        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1577        assert!(matches!(
1578            err,
1579            UserParamError::TypeMismatch {
1580                slot: "SEARCH SPATIAL NEAREST K parameter",
1581                got: "text"
1582            }
1583        ));
1584    }
1585
1586    #[test]
1587    fn bind_search_text_limit_param() {
1588        // Issue #361: `SEARCH TEXT ... LIMIT $N` binds an integer.
1589        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1590        let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1591        let QueryExpr::SearchCommand(SearchCommand::Text {
1592            limit, limit_param, ..
1593        }) = bound
1594        else {
1595            panic!("expected SearchCommand::Text");
1596        };
1597        assert_eq!(limit, 15);
1598        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1599    }
1600
1601    #[test]
1602    fn bind_search_text_limit_rejects_zero() {
1603        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1604        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1605        assert!(matches!(
1606            err,
1607            UserParamError::TypeMismatch {
1608                slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1609                ..
1610            }
1611        ));
1612    }
1613
1614    #[test]
1615    fn bind_search_text_limit_rejects_non_integer() {
1616        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1617        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1618        assert!(matches!(
1619            err,
1620            UserParamError::TypeMismatch {
1621                slot: "SEARCH TEXT LIMIT parameter",
1622                got: "text"
1623            }
1624        ));
1625    }
1626
1627    #[test]
1628    fn bind_search_multimodal_limit_param() {
1629        // Issue #361: `SEARCH MULTIMODAL ... LIMIT $N` binds an integer.
1630        let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1631        let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1632        let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1633            limit, limit_param, ..
1634        }) = bound
1635        else {
1636            panic!("expected SearchCommand::Multimodal");
1637        };
1638        assert_eq!(limit, 40);
1639        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1640    }
1641
1642    #[test]
1643    fn bind_search_multimodal_limit_rejects_zero() {
1644        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1645        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1646        assert!(matches!(
1647            err,
1648            UserParamError::TypeMismatch {
1649                slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1650                ..
1651            }
1652        ));
1653    }
1654
1655    #[test]
1656    fn bind_search_multimodal_limit_rejects_non_integer() {
1657        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1658        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1659        assert!(matches!(
1660            err,
1661            UserParamError::TypeMismatch {
1662                slot: "SEARCH MULTIMODAL LIMIT parameter",
1663                got: "text"
1664            }
1665        ));
1666    }
1667
1668    #[test]
1669    fn bind_search_index_limit_param() {
1670        // Issue #361: `SEARCH INDEX ... LIMIT $N` binds an integer.
1671        let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1672        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1673        let QueryExpr::SearchCommand(SearchCommand::Index {
1674            limit, limit_param, ..
1675        }) = bound
1676        else {
1677            panic!("expected SearchCommand::Index");
1678        };
1679        assert_eq!(limit, 50);
1680        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1681    }
1682
1683    #[test]
1684    fn bind_search_index_limit_rejects_zero() {
1685        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1686        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1687        assert!(matches!(
1688            err,
1689            UserParamError::TypeMismatch {
1690                slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1691                ..
1692            }
1693        ));
1694    }
1695
1696    #[test]
1697    fn bind_search_index_limit_rejects_non_integer() {
1698        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1699        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1700        assert!(matches!(
1701            err,
1702            UserParamError::TypeMismatch {
1703                slot: "SEARCH INDEX LIMIT parameter",
1704                got: "text"
1705            }
1706        ));
1707    }
1708
1709    #[test]
1710    fn bind_search_context_limit_param() {
1711        // Issue #361: `SEARCH CONTEXT ... LIMIT $N` binds an integer.
1712        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1713        let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1714        let QueryExpr::SearchCommand(SearchCommand::Context {
1715            limit, limit_param, ..
1716        }) = bound
1717        else {
1718            panic!("expected SearchCommand::Context");
1719        };
1720        assert_eq!(limit, 60);
1721        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1722    }
1723
1724    #[test]
1725    fn bind_search_context_limit_rejects_zero() {
1726        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1727        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1728        assert!(matches!(
1729            err,
1730            UserParamError::TypeMismatch {
1731                slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1732                ..
1733            }
1734        ));
1735    }
1736
1737    #[test]
1738    fn bind_search_context_limit_rejects_non_integer() {
1739        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1740        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1741        assert!(matches!(
1742            err,
1743            UserParamError::TypeMismatch {
1744                slot: "SEARCH CONTEXT LIMIT parameter",
1745                got: "text"
1746            }
1747        ));
1748    }
1749
1750    #[test]
1751    fn bind_search_spatial_radius_limit_param() {
1752        // Issue #361: `SEARCH SPATIAL RADIUS ... LIMIT $N` binds an integer.
1753        let q = parse(
1754            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1755        );
1756        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1757        let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1758            limit, limit_param, ..
1759        }) = bound
1760        else {
1761            panic!("expected SearchCommand::SpatialRadius");
1762        };
1763        assert_eq!(limit, 50);
1764        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1765    }
1766
1767    #[test]
1768    fn bind_search_spatial_radius_limit_rejects_zero() {
1769        let q = parse(
1770            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1771        );
1772        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1773        assert!(matches!(
1774            err,
1775            UserParamError::TypeMismatch {
1776                slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1777                ..
1778            }
1779        ));
1780    }
1781
1782    #[test]
1783    fn bind_search_spatial_radius_limit_rejects_non_integer() {
1784        let q = parse(
1785            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1786        );
1787        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1788        assert!(matches!(
1789            err,
1790            UserParamError::TypeMismatch {
1791                slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
1792                got: "text"
1793            }
1794        ));
1795    }
1796
1797    #[test]
1798    fn bind_search_spatial_bbox_limit_param() {
1799        // Issue #361: `SEARCH SPATIAL BBOX ... LIMIT $N` binds an integer.
1800        let q =
1801            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1802        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1803        let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
1804            limit, limit_param, ..
1805        }) = bound
1806        else {
1807            panic!("expected SearchCommand::SpatialBbox");
1808        };
1809        assert_eq!(limit, 50);
1810        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1811    }
1812
1813    #[test]
1814    fn bind_search_spatial_bbox_limit_rejects_zero() {
1815        let q =
1816            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1817        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1818        assert!(matches!(
1819            err,
1820            UserParamError::TypeMismatch {
1821                slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
1822                ..
1823            }
1824        ));
1825    }
1826
1827    #[test]
1828    fn bind_search_spatial_bbox_limit_rejects_non_integer() {
1829        let q =
1830            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1831        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1832        assert!(matches!(
1833            err,
1834            UserParamError::TypeMismatch {
1835                slot: "SEARCH SPATIAL BBOX LIMIT parameter",
1836                got: "text"
1837            }
1838        ));
1839    }
1840
1841    #[test]
1842    fn bind_search_similar_text_param() {
1843        // Issue #361: `SEARCH SIMILAR TEXT $N` binds a Value::Text into
1844        // the text slot. The embedding pipeline reads `text` downstream.
1845        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
1846        let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
1847        let QueryExpr::SearchCommand(SearchCommand::Similar {
1848            vector,
1849            text,
1850            text_param,
1851            collection,
1852            limit,
1853            provider,
1854            ..
1855        }) = bound
1856        else {
1857            panic!("expected SearchCommand::Similar");
1858        };
1859        assert!(vector.is_empty());
1860        assert_eq!(text.as_deref(), Some("find vulnerabilities"));
1861        assert_eq!(text_param, None, "text_param must be cleared post-bind");
1862        assert_eq!(collection, "docs");
1863        assert_eq!(limit, 5);
1864        assert_eq!(provider.as_deref(), Some("openai"));
1865    }
1866
1867    #[test]
1868    fn bind_search_similar_text_rejects_non_text() {
1869        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
1870        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1871        assert!(
1872            matches!(
1873                err,
1874                UserParamError::TypeMismatch {
1875                    slot: "SEARCH SIMILAR TEXT parameter",
1876                    got: "integer"
1877                }
1878            ),
1879            "got {err:?}"
1880        );
1881    }
1882
1883    #[test]
1884    fn bind_search_similar_text_with_limit_param() {
1885        // TEXT $1 + LIMIT $2 — verify both non-Expr param slots bind
1886        // together without cross-talk.
1887        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
1888        let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
1889        let QueryExpr::SearchCommand(SearchCommand::Similar {
1890            text,
1891            text_param,
1892            limit,
1893            limit_param,
1894            ..
1895        }) = bound
1896        else {
1897            panic!("expected SearchCommand::Similar");
1898        };
1899        assert_eq!(text.as_deref(), Some("hello"));
1900        assert_eq!(text_param, None);
1901        assert_eq!(limit, 11);
1902        assert_eq!(limit_param, None);
1903    }
1904
1905    #[test]
1906    fn bind_insert_values_with_vector_param() {
1907        // Issue #355 INSERT half: $1 in VALUES is bound to a Value::Vector
1908        // and surfaces in both `value_exprs` (as a Literal) and `values`.
1909        let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
1910        let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
1911        let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
1912        let QueryExpr::Insert(insert) = bound else {
1913            panic!("expected Insert");
1914        };
1915        assert_eq!(insert.values.len(), 1);
1916        assert_eq!(insert.values[0].len(), 2);
1917        assert!(
1918            matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
1919        );
1920        assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
1921        // value_exprs row 0 col 0 is now a Literal carrying the vector.
1922        let row0 = &insert.value_exprs[0];
1923        assert!(matches!(
1924            &row0[0],
1925            Expr::Literal {
1926                value: Value::Vector(_),
1927                ..
1928            }
1929        ));
1930    }
1931
1932    #[test]
1933    fn bind_insert_arity_mismatch() {
1934        let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
1935        let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
1936        assert!(matches!(
1937            err,
1938            UserParamError::Arity {
1939                expected: 2,
1940                got: 1
1941            }
1942        ));
1943    }
1944
1945    #[test]
1946    fn bind_update_assignments_and_where_params() {
1947        let q = parse("UPDATE users SET age = $1, active = $2 WHERE name = $3");
1948        let bound = bind(
1949            &q,
1950            &[
1951                Value::Integer(31),
1952                Value::Boolean(true),
1953                Value::text("Alice"),
1954            ],
1955        )
1956        .unwrap();
1957        let QueryExpr::Update(update) = bound else {
1958            panic!("expected Update");
1959        };
1960        assert_eq!(update.assignments.len(), 2);
1961        assert!(matches!(update.assignments[0].1, Value::Integer(31)));
1962        assert!(matches!(update.assignments[1].1, Value::Boolean(true)));
1963        assert!(update.where_expr.is_some());
1964        assert!(update.filter.is_some());
1965    }
1966
1967    #[test]
1968    fn bind_delete_where_param() {
1969        let q = parse("DELETE FROM users WHERE active = $1");
1970        let bound = bind(&q, &[Value::Boolean(false)]).unwrap();
1971        let QueryExpr::Delete(delete) = bound else {
1972            panic!("expected Delete");
1973        };
1974        assert!(delete.where_expr.is_some());
1975        assert!(delete.filter.is_some());
1976    }
1977
1978    #[test]
1979    fn bind_select_limit_param() {
1980        let q = parse("SELECT * FROM users LIMIT $1");
1981        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1982        let QueryExpr::Table(t) = bound else {
1983            panic!("expected Table");
1984        };
1985        assert_eq!(t.limit, Some(7));
1986        assert_eq!(t.limit_param, None, "limit_param must be cleared post-bind");
1987        assert_eq!(t.offset, None);
1988        assert_eq!(t.offset_param, None);
1989    }
1990
1991    #[test]
1992    fn bind_select_offset_param() {
1993        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
1994        let bound = bind(&q, &[Value::Integer(20)]).unwrap();
1995        let QueryExpr::Table(t) = bound else {
1996            panic!("expected Table");
1997        };
1998        assert_eq!(t.limit, Some(10));
1999        assert_eq!(t.offset, Some(20));
2000        assert_eq!(t.offset_param, None);
2001    }
2002
2003    #[test]
2004    fn bind_select_limit_and_offset_params_together() {
2005        let q = parse("SELECT * FROM users WHERE id = $1 LIMIT $2 OFFSET $3");
2006        let bound = bind(
2007            &q,
2008            &[Value::Integer(5), Value::Integer(10), Value::Integer(20)],
2009        )
2010        .unwrap();
2011        let QueryExpr::Table(t) = bound else {
2012            panic!("expected Table");
2013        };
2014        assert_eq!(t.limit, Some(10));
2015        assert_eq!(t.offset, Some(20));
2016        assert_eq!(t.limit_param, None);
2017        assert_eq!(t.offset_param, None);
2018        // WHERE id = $1 → Expr-tree bind also ran.
2019        assert!(t.where_expr.is_some());
2020    }
2021
2022    #[test]
2023    fn bind_select_offset_zero_is_valid() {
2024        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2025        let bound = bind(&q, &[Value::Integer(0)]).unwrap();
2026        let QueryExpr::Table(t) = bound else {
2027            panic!("expected Table");
2028        };
2029        assert_eq!(t.offset, Some(0));
2030    }
2031
2032    #[test]
2033    fn bind_select_limit_rejects_zero() {
2034        let q = parse("SELECT * FROM users LIMIT $1");
2035        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
2036        assert!(
2037            matches!(
2038                err,
2039                UserParamError::TypeMismatch {
2040                    slot: "SELECT LIMIT parameter (must be > 0)",
2041                    ..
2042                }
2043            ),
2044            "got {err:?}"
2045        );
2046    }
2047
2048    #[test]
2049    fn bind_select_limit_rejects_non_integer() {
2050        let q = parse("SELECT * FROM users LIMIT $1");
2051        let err = bind(&q, &[Value::text("ten")]).unwrap_err();
2052        assert!(
2053            matches!(
2054                err,
2055                UserParamError::TypeMismatch {
2056                    slot: "SELECT LIMIT parameter",
2057                    got: "text"
2058                }
2059            ),
2060            "got {err:?}"
2061        );
2062    }
2063
2064    #[test]
2065    fn bind_select_offset_rejects_negative() {
2066        let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2067        let err = bind(&q, &[Value::Integer(-1)]).unwrap_err();
2068        assert!(
2069            matches!(
2070                err,
2071                UserParamError::TypeMismatch {
2072                    slot: "SELECT OFFSET parameter (must be >= 0)",
2073                    ..
2074                }
2075            ),
2076            "got {err:?}"
2077        );
2078    }
2079
2080    #[test]
2081    fn bind_no_params_is_noop() {
2082        let q = parse("SELECT * FROM users");
2083        let bound = bind(&q, &[]).unwrap();
2084        assert!(matches!(bound, QueryExpr::Table(_)));
2085    }
2086}