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