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