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 }`).
224fn collect_non_expr_indices(expr: &QueryExpr, out: &mut Vec<usize>) {
225    match expr {
226        QueryExpr::SearchCommand(SearchCommand::Similar {
227            vector_param,
228            limit_param,
229            min_score_param,
230            text_param,
231            ..
232        }) => {
233            if let Some(idx) = vector_param {
234                out.push(*idx);
235            }
236            if let Some(idx) = limit_param {
237                out.push(*idx);
238            }
239            if let Some(idx) = min_score_param {
240                out.push(*idx);
241            }
242            if let Some(idx) = text_param {
243                out.push(*idx);
244            }
245        }
246        QueryExpr::SearchCommand(SearchCommand::Hybrid { limit_param, .. }) => {
247            if let Some(idx) = limit_param {
248                out.push(*idx);
249            }
250        }
251        QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k_param, .. }) => {
252            if let Some(idx) = k_param {
253                out.push(*idx);
254            }
255        }
256        QueryExpr::SearchCommand(SearchCommand::SpatialRadius { limit_param, .. }) => {
257            if let Some(idx) = limit_param {
258                out.push(*idx);
259            }
260        }
261        QueryExpr::SearchCommand(SearchCommand::SpatialBbox { limit_param, .. }) => {
262            if let Some(idx) = limit_param {
263                out.push(*idx);
264            }
265        }
266        QueryExpr::SearchCommand(SearchCommand::Text { limit_param, .. }) => {
267            if let Some(idx) = limit_param {
268                out.push(*idx);
269            }
270        }
271        QueryExpr::SearchCommand(SearchCommand::Multimodal { limit_param, .. }) => {
272            if let Some(idx) = limit_param {
273                out.push(*idx);
274            }
275        }
276        QueryExpr::SearchCommand(SearchCommand::Index { limit_param, .. }) => {
277            if let Some(idx) = limit_param {
278                out.push(*idx);
279            }
280        }
281        QueryExpr::SearchCommand(SearchCommand::Context { limit_param, .. }) => {
282            if let Some(idx) = limit_param {
283                out.push(*idx);
284            }
285        }
286        _ => {}
287    }
288}
289
290/// Validate that the indices used by the SQL match the caller's
291/// supplied params (contiguous from 0, length match).
292pub fn validate(indices: &[usize], param_count: usize) -> Result<(), UserParamError> {
293    let max_used = indices.iter().copied().max();
294
295    let expected = match max_used {
296        Some(m) => m + 1,
297        None => 0,
298    };
299
300    if expected != param_count {
301        return Err(UserParamError::Arity {
302            expected,
303            got: param_count,
304        });
305    }
306
307    if let Some(max) = max_used {
308        let mut seen = vec![false; max + 1];
309        for &i in indices {
310            seen[i] = true;
311        }
312        for (i, used) in seen.iter().enumerate() {
313            if !used {
314                return Err(UserParamError::Gap {
315                    missing: i + 1,
316                    max: max + 1,
317                });
318            }
319        }
320    }
321
322    Ok(())
323}
324
325/// One-shot helper: validate arity/gaps then substitute the values.
326pub fn bind(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, UserParamError> {
327    let indices = collect_indices(expr);
328    validate(&indices, params.len())?;
329
330    if indices.is_empty() {
331        return Ok(expr.clone());
332    }
333
334    // SEARCH SIMILAR $N has its parameter slot outside the `Expr`
335    // tree — handle it here rather than threading the binds through
336    // the planner's shape binder, which only knows about `Expr` slots.
337    if let QueryExpr::SearchCommand(SearchCommand::Similar {
338        vector,
339        text,
340        provider,
341        collection,
342        limit,
343        min_score,
344        vector_param,
345        limit_param,
346        min_score_param,
347        text_param,
348    }) = expr
349    {
350        let mut bound_vector = vector.clone();
351        if let Some(idx) = vector_param {
352            let value = params.get(*idx).ok_or(UserParamError::Arity {
353                expected: idx + 1,
354                got: params.len(),
355            })?;
356            bound_vector = match value {
357                Value::Vector(v) => v.clone(),
358                other => {
359                    return Err(UserParamError::TypeMismatch {
360                        slot: "SEARCH SIMILAR vector parameter",
361                        got: value_variant_name(other),
362                    });
363                }
364            };
365        }
366        let bound_limit = if let Some(idx) = limit_param {
367            let value = params.get(*idx).ok_or(UserParamError::Arity {
368                expected: idx + 1,
369                got: params.len(),
370            })?;
371            match value {
372                Value::Integer(n) if *n > 0 => *n as usize,
373                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
374                Value::BigInt(n) if *n > 0 => *n as usize,
375                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
376                    return Err(UserParamError::TypeMismatch {
377                        slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
378                        got: value_variant_name(value),
379                    });
380                }
381                other => {
382                    return Err(UserParamError::TypeMismatch {
383                        slot: "SEARCH SIMILAR LIMIT parameter",
384                        got: value_variant_name(other),
385                    });
386                }
387            }
388        } else {
389            *limit
390        };
391        let bound_min_score = if let Some(idx) = min_score_param {
392            let value = params.get(*idx).ok_or(UserParamError::Arity {
393                expected: idx + 1,
394                got: params.len(),
395            })?;
396            match value {
397                Value::Float(f) => *f as f32,
398                Value::Integer(n) => *n as f32,
399                Value::UnsignedInteger(n) => *n as f32,
400                Value::BigInt(n) => *n as f32,
401                other => {
402                    return Err(UserParamError::TypeMismatch {
403                        slot: "SEARCH SIMILAR MIN_SCORE parameter",
404                        got: value_variant_name(other),
405                    });
406                }
407            }
408        } else {
409            *min_score
410        };
411        let bound_text = if let Some(idx) = text_param {
412            let value = params.get(*idx).ok_or(UserParamError::Arity {
413                expected: idx + 1,
414                got: params.len(),
415            })?;
416            match value {
417                Value::Text(s) => Some(s.to_string()),
418                other => {
419                    return Err(UserParamError::TypeMismatch {
420                        slot: "SEARCH SIMILAR TEXT parameter",
421                        got: value_variant_name(other),
422                    });
423                }
424            }
425        } else {
426            text.clone()
427        };
428        return Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
429            vector: bound_vector,
430            text: bound_text,
431            provider: provider.clone(),
432            collection: collection.clone(),
433            limit: bound_limit,
434            min_score: bound_min_score,
435            vector_param: None,
436            limit_param: None,
437            min_score_param: None,
438            text_param: None,
439        }));
440    }
441
442    if let QueryExpr::SearchCommand(SearchCommand::Hybrid {
443        vector,
444        query,
445        collection,
446        limit,
447        limit_param,
448    }) = expr
449    {
450        let bound_limit = if let Some(idx) = limit_param {
451            let value = params.get(*idx).ok_or(UserParamError::Arity {
452                expected: idx + 1,
453                got: params.len(),
454            })?;
455            match value {
456                Value::Integer(n) if *n > 0 => *n as usize,
457                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
458                Value::BigInt(n) if *n > 0 => *n as usize,
459                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
460                    return Err(UserParamError::TypeMismatch {
461                        slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
462                        got: value_variant_name(value),
463                    });
464                }
465                other => {
466                    return Err(UserParamError::TypeMismatch {
467                        slot: "SEARCH HYBRID LIMIT parameter",
468                        got: value_variant_name(other),
469                    });
470                }
471            }
472        } else {
473            *limit
474        };
475        return Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
476            vector: vector.clone(),
477            query: query.clone(),
478            collection: collection.clone(),
479            limit: bound_limit,
480            limit_param: None,
481        }));
482    }
483
484    if let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
485        lat,
486        lon,
487        k,
488        collection,
489        column,
490        k_param,
491    }) = expr
492    {
493        let bound_k = if let Some(idx) = k_param {
494            let value = params.get(*idx).ok_or(UserParamError::Arity {
495                expected: idx + 1,
496                got: params.len(),
497            })?;
498            match value {
499                Value::Integer(n) if *n > 0 => *n as usize,
500                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
501                Value::BigInt(n) if *n > 0 => *n as usize,
502                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
503                    return Err(UserParamError::TypeMismatch {
504                        slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
505                        got: value_variant_name(value),
506                    });
507                }
508                other => {
509                    return Err(UserParamError::TypeMismatch {
510                        slot: "SEARCH SPATIAL NEAREST K parameter",
511                        got: value_variant_name(other),
512                    });
513                }
514            }
515        } else {
516            *k
517        };
518        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
519            lat: *lat,
520            lon: *lon,
521            k: bound_k,
522            collection: collection.clone(),
523            column: column.clone(),
524            k_param: None,
525        }));
526    }
527
528    if let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
529        center_lat,
530        center_lon,
531        radius_km,
532        collection,
533        column,
534        limit,
535        limit_param,
536    }) = expr
537    {
538        let bound_limit = if let Some(idx) = limit_param {
539            let value = params.get(*idx).ok_or(UserParamError::Arity {
540                expected: idx + 1,
541                got: params.len(),
542            })?;
543            match value {
544                Value::Integer(n) if *n > 0 => *n as usize,
545                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
546                Value::BigInt(n) if *n > 0 => *n as usize,
547                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
548                    return Err(UserParamError::TypeMismatch {
549                        slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
550                        got: value_variant_name(value),
551                    });
552                }
553                other => {
554                    return Err(UserParamError::TypeMismatch {
555                        slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
556                        got: value_variant_name(other),
557                    });
558                }
559            }
560        } else {
561            *limit
562        };
563        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
564            center_lat: *center_lat,
565            center_lon: *center_lon,
566            radius_km: *radius_km,
567            collection: collection.clone(),
568            column: column.clone(),
569            limit: bound_limit,
570            limit_param: None,
571        }));
572    }
573
574    if let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
575        min_lat,
576        min_lon,
577        max_lat,
578        max_lon,
579        collection,
580        column,
581        limit,
582        limit_param,
583    }) = expr
584    {
585        let bound_limit = if let Some(idx) = limit_param {
586            let value = params.get(*idx).ok_or(UserParamError::Arity {
587                expected: idx + 1,
588                got: params.len(),
589            })?;
590            match value {
591                Value::Integer(n) if *n > 0 => *n as usize,
592                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
593                Value::BigInt(n) if *n > 0 => *n as usize,
594                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
595                    return Err(UserParamError::TypeMismatch {
596                        slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
597                        got: value_variant_name(value),
598                    });
599                }
600                other => {
601                    return Err(UserParamError::TypeMismatch {
602                        slot: "SEARCH SPATIAL BBOX LIMIT parameter",
603                        got: value_variant_name(other),
604                    });
605                }
606            }
607        } else {
608            *limit
609        };
610        return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
611            min_lat: *min_lat,
612            min_lon: *min_lon,
613            max_lat: *max_lat,
614            max_lon: *max_lon,
615            collection: collection.clone(),
616            column: column.clone(),
617            limit: bound_limit,
618            limit_param: None,
619        }));
620    }
621
622    if let QueryExpr::SearchCommand(SearchCommand::Text {
623        query,
624        collection,
625        limit,
626        fuzzy,
627        limit_param,
628    }) = expr
629    {
630        let bound_limit = if let Some(idx) = limit_param {
631            let value = params.get(*idx).ok_or(UserParamError::Arity {
632                expected: idx + 1,
633                got: params.len(),
634            })?;
635            match value {
636                Value::Integer(n) if *n > 0 => *n as usize,
637                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
638                Value::BigInt(n) if *n > 0 => *n as usize,
639                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
640                    return Err(UserParamError::TypeMismatch {
641                        slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
642                        got: value_variant_name(value),
643                    });
644                }
645                other => {
646                    return Err(UserParamError::TypeMismatch {
647                        slot: "SEARCH TEXT LIMIT parameter",
648                        got: value_variant_name(other),
649                    });
650                }
651            }
652        } else {
653            *limit
654        };
655        return Ok(QueryExpr::SearchCommand(SearchCommand::Text {
656            query: query.clone(),
657            collection: collection.clone(),
658            limit: bound_limit,
659            fuzzy: *fuzzy,
660            limit_param: None,
661        }));
662    }
663
664    if let QueryExpr::SearchCommand(SearchCommand::Multimodal {
665        query,
666        collection,
667        limit,
668        limit_param,
669    }) = expr
670    {
671        let bound_limit = if let Some(idx) = limit_param {
672            let value = params.get(*idx).ok_or(UserParamError::Arity {
673                expected: idx + 1,
674                got: params.len(),
675            })?;
676            match value {
677                Value::Integer(n) if *n > 0 => *n as usize,
678                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
679                Value::BigInt(n) if *n > 0 => *n as usize,
680                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
681                    return Err(UserParamError::TypeMismatch {
682                        slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
683                        got: value_variant_name(value),
684                    });
685                }
686                other => {
687                    return Err(UserParamError::TypeMismatch {
688                        slot: "SEARCH MULTIMODAL LIMIT parameter",
689                        got: value_variant_name(other),
690                    });
691                }
692            }
693        } else {
694            *limit
695        };
696        return Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
697            query: query.clone(),
698            collection: collection.clone(),
699            limit: bound_limit,
700            limit_param: None,
701        }));
702    }
703
704    if let QueryExpr::SearchCommand(SearchCommand::Index {
705        index,
706        value,
707        collection,
708        limit,
709        exact,
710        limit_param,
711    }) = expr
712    {
713        let bound_limit = if let Some(idx) = limit_param {
714            let value = params.get(*idx).ok_or(UserParamError::Arity {
715                expected: idx + 1,
716                got: params.len(),
717            })?;
718            match value {
719                Value::Integer(n) if *n > 0 => *n as usize,
720                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
721                Value::BigInt(n) if *n > 0 => *n as usize,
722                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
723                    return Err(UserParamError::TypeMismatch {
724                        slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
725                        got: value_variant_name(value),
726                    });
727                }
728                other => {
729                    return Err(UserParamError::TypeMismatch {
730                        slot: "SEARCH INDEX LIMIT parameter",
731                        got: value_variant_name(other),
732                    });
733                }
734            }
735        } else {
736            *limit
737        };
738        return Ok(QueryExpr::SearchCommand(SearchCommand::Index {
739            index: index.clone(),
740            value: value.clone(),
741            collection: collection.clone(),
742            limit: bound_limit,
743            exact: *exact,
744            limit_param: None,
745        }));
746    }
747
748    if let QueryExpr::SearchCommand(SearchCommand::Context {
749        query,
750        field,
751        collection,
752        limit,
753        depth,
754        limit_param,
755    }) = expr
756    {
757        let bound_limit = if let Some(idx) = limit_param {
758            let value = params.get(*idx).ok_or(UserParamError::Arity {
759                expected: idx + 1,
760                got: params.len(),
761            })?;
762            match value {
763                Value::Integer(n) if *n > 0 => *n as usize,
764                Value::UnsignedInteger(n) if *n > 0 => *n as usize,
765                Value::BigInt(n) if *n > 0 => *n as usize,
766                Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
767                    return Err(UserParamError::TypeMismatch {
768                        slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
769                        got: value_variant_name(value),
770                    });
771                }
772                other => {
773                    return Err(UserParamError::TypeMismatch {
774                        slot: "SEARCH CONTEXT LIMIT parameter",
775                        got: value_variant_name(other),
776                    });
777                }
778            }
779        } else {
780            *limit
781        };
782        return Ok(QueryExpr::SearchCommand(SearchCommand::Context {
783            query: query.clone(),
784            field: field.clone(),
785            collection: collection.clone(),
786            limit: bound_limit,
787            depth: *depth,
788            limit_param: None,
789        }));
790    }
791
792    if let QueryExpr::Insert(insert) = expr {
793        let mut bound = insert.clone();
794        let mut new_values: Vec<Vec<Value>> = Vec::with_capacity(bound.value_exprs.len());
795        let new_exprs = bound
796            .value_exprs
797            .into_iter()
798            .map(|row| {
799                row.into_iter()
800                    .map(|e| substitute_params_in_expr(e, params))
801                    .collect::<Result<Vec<_>, _>>()
802            })
803            .collect::<Result<Vec<_>, _>>()?;
804        for row in &new_exprs {
805            let folded = row
806                .iter()
807                .cloned()
808                .map(fold_expr_to_value)
809                .collect::<Result<Vec<_>, _>>()
810                .map_err(|_| UserParamError::UnsupportedShape)?;
811            new_values.push(folded);
812        }
813        bound.value_exprs = new_exprs;
814        bound.values = new_values;
815        return Ok(QueryExpr::Insert(bound));
816    }
817
818    bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
819}
820
821fn value_variant_name(value: &Value) -> &'static str {
822    match value {
823        Value::Null => "null",
824        Value::Integer(_) => "integer",
825        Value::UnsignedInteger(_) => "unsigned integer",
826        Value::BigInt(_) => "bigint",
827        Value::Float(_) => "float",
828        Value::Text(_) => "text",
829        Value::Boolean(_) => "boolean",
830        Value::Vector(_) => "vector",
831        Value::Json(_) => "json",
832        Value::Blob(_) => "bytes",
833        _ => "other",
834    }
835}
836
837fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
838    match expr {
839        QueryExpr::Table(q) => {
840            for item in &q.select_items {
841                if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
842                    visit_expr(expr, visit);
843                }
844            }
845            if let Some(e) = &q.where_expr {
846                visit_expr(e, visit);
847            }
848            for e in &q.group_by_exprs {
849                visit_expr(e, visit);
850            }
851            if let Some(e) = &q.having_expr {
852                visit_expr(e, visit);
853            }
854            for clause in &q.order_by {
855                if let Some(e) = &clause.expr {
856                    visit_expr(e, visit);
857                }
858            }
859            if let Some(crate::storage::query::ast::TableSource::Subquery(inner)) = &q.source {
860                visit_query_expr(inner, visit);
861            }
862        }
863        QueryExpr::Join(q) => {
864            visit_query_expr(&q.left, visit);
865            visit_query_expr(&q.right, visit);
866        }
867        QueryExpr::Hybrid(q) => {
868            visit_query_expr(&q.structured, visit);
869        }
870        QueryExpr::Insert(q) => {
871            for row in &q.value_exprs {
872                for e in row {
873                    visit_expr(e, visit);
874                }
875            }
876        }
877        // Vector / Graph / Path: parameter slots in #355 / later issues.
878        _ => {}
879    }
880}
881
882fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
883    visit(expr);
884    match expr {
885        Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
886        Expr::BinaryOp { lhs, rhs, .. } => {
887            visit_expr(lhs, visit);
888            visit_expr(rhs, visit);
889        }
890        Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
891        Expr::Cast { inner, .. } => visit_expr(inner, visit),
892        Expr::FunctionCall { args, .. } => {
893            for a in args {
894                visit_expr(a, visit);
895            }
896        }
897        Expr::Case {
898            branches, else_, ..
899        } => {
900            for (c, v) in branches {
901                visit_expr(c, visit);
902                visit_expr(v, visit);
903            }
904            if let Some(e) = else_ {
905                visit_expr(e, visit);
906            }
907        }
908        Expr::IsNull { operand, .. } => visit_expr(operand, visit),
909        Expr::InList { target, values, .. } => {
910            visit_expr(target, visit);
911            for v in values {
912                visit_expr(v, visit);
913            }
914        }
915        Expr::Between {
916            target, low, high, ..
917        } => {
918            visit_expr(target, visit);
919            visit_expr(low, visit);
920            visit_expr(high, visit);
921        }
922    }
923}
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use crate::storage::query::modes::parse_multi;
929
930    fn parse(sql: &str) -> QueryExpr {
931        parse_multi(sql).expect("parse")
932    }
933
934    #[test]
935    fn collect_indices_select_where() {
936        let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
937        let mut ix = collect_indices(&q);
938        ix.sort();
939        assert_eq!(ix, vec![0, 1]);
940    }
941
942    #[test]
943    fn validate_ok() {
944        assert!(validate(&[0, 1], 2).is_ok());
945        assert!(validate(&[0, 1, 0], 2).is_ok());
946        assert!(validate(&[], 0).is_ok());
947    }
948
949    #[test]
950    fn validate_arity_too_few() {
951        let err = validate(&[0, 1], 1).unwrap_err();
952        assert!(matches!(
953            err,
954            UserParamError::Arity {
955                expected: 2,
956                got: 1
957            }
958        ));
959    }
960
961    #[test]
962    fn validate_arity_too_many() {
963        let err = validate(&[0], 3).unwrap_err();
964        assert!(matches!(
965            err,
966            UserParamError::Arity {
967                expected: 1,
968                got: 3
969            }
970        ));
971    }
972
973    #[test]
974    fn validate_gap() {
975        // $1 and $3 used, but not $2.
976        let err = validate(&[0, 2], 3).unwrap_err();
977        assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
978    }
979
980    #[test]
981    fn bind_substitutes_int_param() {
982        let q = parse("SELECT * FROM users WHERE id = $1");
983        let bound = bind(&q, &[Value::Integer(42)]).unwrap();
984        let QueryExpr::Table(t) = bound else {
985            panic!("expected Table");
986        };
987        let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
988            panic!("expected BinaryOp");
989        };
990        assert!(matches!(
991            *rhs,
992            Expr::Literal {
993                value: Value::Integer(42),
994                ..
995            }
996        ));
997    }
998
999    #[test]
1000    fn bind_substitutes_text_and_null() {
1001        let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1002        let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1003        let QueryExpr::Table(t) = bound else {
1004            panic!("expected Table");
1005        };
1006        let mut literals: Vec<Value> = Vec::new();
1007        visit_expr(&t.where_expr.unwrap(), &mut |e| {
1008            if let Expr::Literal { value, .. } = e {
1009                literals.push(value.clone());
1010            }
1011        });
1012        assert!(literals
1013            .iter()
1014            .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1015        assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1016    }
1017
1018    #[test]
1019    fn bind_search_similar_vector_param() {
1020        // Tracer for #355: `SEARCH SIMILAR $1 COLLECTION embeddings`
1021        // binds the supplied `Value::Vector` into the vector slot.
1022        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1023        let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1024        let QueryExpr::SearchCommand(SearchCommand::Similar {
1025            vector,
1026            vector_param,
1027            collection,
1028            limit,
1029            ..
1030        }) = bound
1031        else {
1032            panic!("expected SearchCommand::Similar");
1033        };
1034        assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1035        assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1036        assert_eq!(collection, "embeddings");
1037        assert_eq!(limit, 5);
1038    }
1039
1040    #[test]
1041    fn bind_search_similar_limit_param() {
1042        // Issue #361: `LIMIT $N` binds an integer parameter.
1043        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1044        let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1045        let QueryExpr::SearchCommand(SearchCommand::Similar {
1046            limit,
1047            limit_param,
1048            min_score_param,
1049            ..
1050        }) = bound
1051        else {
1052            panic!("expected SearchCommand::Similar");
1053        };
1054        assert_eq!(limit, 25);
1055        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1056        assert_eq!(min_score_param, None);
1057    }
1058
1059    #[test]
1060    fn bind_search_similar_min_score_param() {
1061        let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1062        let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1063        let QueryExpr::SearchCommand(SearchCommand::Similar {
1064            min_score,
1065            min_score_param,
1066            ..
1067        }) = bound
1068        else {
1069            panic!("expected SearchCommand::Similar");
1070        };
1071        assert!((min_score - 0.42_f32).abs() < 1e-6);
1072        assert_eq!(min_score_param, None);
1073    }
1074
1075    #[test]
1076    fn bind_search_similar_limit_and_min_score_together() {
1077        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1078        let bound = bind(
1079            &q,
1080            &[
1081                Value::Vector(vec![0.1, 0.2]),
1082                Value::Integer(7),
1083                Value::Float(0.9),
1084            ],
1085        )
1086        .unwrap();
1087        let QueryExpr::SearchCommand(SearchCommand::Similar {
1088            limit,
1089            min_score,
1090            vector,
1091            vector_param,
1092            limit_param,
1093            min_score_param,
1094            ..
1095        }) = bound
1096        else {
1097            panic!("expected SearchCommand::Similar");
1098        };
1099        assert_eq!(vector, vec![0.1_f32, 0.2]);
1100        assert_eq!(limit, 7);
1101        assert!((min_score - 0.9_f32).abs() < 1e-6);
1102        assert_eq!(vector_param, None);
1103        assert_eq!(limit_param, None);
1104        assert_eq!(min_score_param, None);
1105    }
1106
1107    #[test]
1108    fn bind_search_similar_limit_rejects_non_integer() {
1109        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1110        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1111        assert!(
1112            matches!(
1113                err,
1114                UserParamError::TypeMismatch {
1115                    slot: "SEARCH SIMILAR LIMIT parameter",
1116                    got: "text"
1117                }
1118            ),
1119            "got {err:?}"
1120        );
1121    }
1122
1123    #[test]
1124    fn bind_search_similar_limit_rejects_zero_or_negative() {
1125        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1126        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1127        assert!(matches!(
1128            err,
1129            UserParamError::TypeMismatch {
1130                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1131                ..
1132            }
1133        ));
1134        let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1135        assert!(matches!(
1136            err,
1137            UserParamError::TypeMismatch {
1138                slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1139                ..
1140            }
1141        ));
1142    }
1143
1144    #[test]
1145    fn bind_search_similar_min_score_rejects_non_numeric() {
1146        let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1147        let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1148        assert!(matches!(
1149            err,
1150            UserParamError::TypeMismatch {
1151                slot: "SEARCH SIMILAR MIN_SCORE parameter",
1152                got: "vector"
1153            }
1154        ));
1155    }
1156
1157    // Note: `?` placeholder at LIMIT/MIN_SCORE is correctly handled by
1158    // `parse_param_slot`, but `parse_multi` routes any `?`-bearing input
1159    // to the SPARQL frontend (see modes::detect). Exercising `?` for
1160    // non-Expr slots will land alongside the SPARQL detector tightening
1161    // tracked separately. The Dollar path covers the same code below.
1162
1163    #[test]
1164    fn bind_search_similar_rejects_non_vector_param() {
1165        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1166        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1167        assert!(
1168            matches!(
1169                err,
1170                UserParamError::TypeMismatch {
1171                    slot: "SEARCH SIMILAR vector parameter",
1172                    got: "integer"
1173                }
1174            ),
1175            "got {err:?}"
1176        );
1177    }
1178
1179    #[test]
1180    fn bind_search_similar_empty_vector_param() {
1181        let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1182        let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1183        let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1184            panic!("expected SearchCommand::Similar");
1185        };
1186        assert!(vector.is_empty());
1187    }
1188
1189    #[test]
1190    fn bind_search_hybrid_limit_param() {
1191        // Issue #361: `SEARCH HYBRID ... LIMIT $N` binds integer parameter.
1192        let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1193        let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1194        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1195            limit, limit_param, ..
1196        }) = bound
1197        else {
1198            panic!("expected SearchCommand::Hybrid");
1199        };
1200        assert_eq!(limit, 30);
1201        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1202    }
1203
1204    #[test]
1205    fn bind_search_hybrid_k_param() {
1206        // `K $N` is an alias for LIMIT in HYBRID.
1207        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1208        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1209        let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1210            limit, limit_param, ..
1211        }) = bound
1212        else {
1213            panic!("expected SearchCommand::Hybrid");
1214        };
1215        assert_eq!(limit, 7);
1216        assert_eq!(limit_param, None);
1217    }
1218
1219    #[test]
1220    fn bind_search_hybrid_limit_rejects_non_integer() {
1221        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1222        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1223        assert!(
1224            matches!(
1225                err,
1226                UserParamError::TypeMismatch {
1227                    slot: "SEARCH HYBRID LIMIT parameter",
1228                    got: "text"
1229                }
1230            ),
1231            "got {err:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn bind_search_hybrid_limit_rejects_zero() {
1237        let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1238        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1239        assert!(matches!(
1240            err,
1241            UserParamError::TypeMismatch {
1242                slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1243                ..
1244            }
1245        ));
1246    }
1247
1248    #[test]
1249    fn bind_search_spatial_nearest_k_param() {
1250        // Issue #361: `SEARCH SPATIAL NEAREST ... K $N` binds an integer.
1251        let q =
1252            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1253        let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1254        let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1255        else {
1256            panic!("expected SpatialNearest");
1257        };
1258        assert_eq!(k, 7);
1259        assert_eq!(k_param, None, "k_param must be cleared post-bind");
1260    }
1261
1262    #[test]
1263    fn bind_search_spatial_nearest_k_rejects_zero() {
1264        let q =
1265            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1266        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1267        assert!(matches!(
1268            err,
1269            UserParamError::TypeMismatch {
1270                slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1271                ..
1272            }
1273        ));
1274    }
1275
1276    #[test]
1277    fn bind_search_spatial_nearest_k_rejects_non_integer() {
1278        let q =
1279            parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1280        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1281        assert!(matches!(
1282            err,
1283            UserParamError::TypeMismatch {
1284                slot: "SEARCH SPATIAL NEAREST K parameter",
1285                got: "text"
1286            }
1287        ));
1288    }
1289
1290    #[test]
1291    fn bind_search_text_limit_param() {
1292        // Issue #361: `SEARCH TEXT ... LIMIT $N` binds an integer.
1293        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1294        let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1295        let QueryExpr::SearchCommand(SearchCommand::Text {
1296            limit, limit_param, ..
1297        }) = bound
1298        else {
1299            panic!("expected SearchCommand::Text");
1300        };
1301        assert_eq!(limit, 15);
1302        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1303    }
1304
1305    #[test]
1306    fn bind_search_text_limit_rejects_zero() {
1307        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1308        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1309        assert!(matches!(
1310            err,
1311            UserParamError::TypeMismatch {
1312                slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1313                ..
1314            }
1315        ));
1316    }
1317
1318    #[test]
1319    fn bind_search_text_limit_rejects_non_integer() {
1320        let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1321        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1322        assert!(matches!(
1323            err,
1324            UserParamError::TypeMismatch {
1325                slot: "SEARCH TEXT LIMIT parameter",
1326                got: "text"
1327            }
1328        ));
1329    }
1330
1331    #[test]
1332    fn bind_search_multimodal_limit_param() {
1333        // Issue #361: `SEARCH MULTIMODAL ... LIMIT $N` binds an integer.
1334        let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1335        let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1336        let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1337            limit, limit_param, ..
1338        }) = bound
1339        else {
1340            panic!("expected SearchCommand::Multimodal");
1341        };
1342        assert_eq!(limit, 40);
1343        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1344    }
1345
1346    #[test]
1347    fn bind_search_multimodal_limit_rejects_zero() {
1348        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1349        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1350        assert!(matches!(
1351            err,
1352            UserParamError::TypeMismatch {
1353                slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1354                ..
1355            }
1356        ));
1357    }
1358
1359    #[test]
1360    fn bind_search_multimodal_limit_rejects_non_integer() {
1361        let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1362        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1363        assert!(matches!(
1364            err,
1365            UserParamError::TypeMismatch {
1366                slot: "SEARCH MULTIMODAL LIMIT parameter",
1367                got: "text"
1368            }
1369        ));
1370    }
1371
1372    #[test]
1373    fn bind_search_index_limit_param() {
1374        // Issue #361: `SEARCH INDEX ... LIMIT $N` binds an integer.
1375        let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1376        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1377        let QueryExpr::SearchCommand(SearchCommand::Index {
1378            limit, limit_param, ..
1379        }) = bound
1380        else {
1381            panic!("expected SearchCommand::Index");
1382        };
1383        assert_eq!(limit, 50);
1384        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1385    }
1386
1387    #[test]
1388    fn bind_search_index_limit_rejects_zero() {
1389        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1390        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1391        assert!(matches!(
1392            err,
1393            UserParamError::TypeMismatch {
1394                slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1395                ..
1396            }
1397        ));
1398    }
1399
1400    #[test]
1401    fn bind_search_index_limit_rejects_non_integer() {
1402        let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1403        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1404        assert!(matches!(
1405            err,
1406            UserParamError::TypeMismatch {
1407                slot: "SEARCH INDEX LIMIT parameter",
1408                got: "text"
1409            }
1410        ));
1411    }
1412
1413    #[test]
1414    fn bind_search_context_limit_param() {
1415        // Issue #361: `SEARCH CONTEXT ... LIMIT $N` binds an integer.
1416        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1417        let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1418        let QueryExpr::SearchCommand(SearchCommand::Context {
1419            limit, limit_param, ..
1420        }) = bound
1421        else {
1422            panic!("expected SearchCommand::Context");
1423        };
1424        assert_eq!(limit, 60);
1425        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1426    }
1427
1428    #[test]
1429    fn bind_search_context_limit_rejects_zero() {
1430        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1431        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1432        assert!(matches!(
1433            err,
1434            UserParamError::TypeMismatch {
1435                slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1436                ..
1437            }
1438        ));
1439    }
1440
1441    #[test]
1442    fn bind_search_context_limit_rejects_non_integer() {
1443        let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1444        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1445        assert!(matches!(
1446            err,
1447            UserParamError::TypeMismatch {
1448                slot: "SEARCH CONTEXT LIMIT parameter",
1449                got: "text"
1450            }
1451        ));
1452    }
1453
1454    #[test]
1455    fn bind_search_spatial_radius_limit_param() {
1456        // Issue #361: `SEARCH SPATIAL RADIUS ... LIMIT $N` binds an integer.
1457        let q = parse(
1458            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1459        );
1460        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1461        let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1462            limit, limit_param, ..
1463        }) = bound
1464        else {
1465            panic!("expected SearchCommand::SpatialRadius");
1466        };
1467        assert_eq!(limit, 50);
1468        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1469    }
1470
1471    #[test]
1472    fn bind_search_spatial_radius_limit_rejects_zero() {
1473        let q = parse(
1474            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1475        );
1476        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1477        assert!(matches!(
1478            err,
1479            UserParamError::TypeMismatch {
1480                slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1481                ..
1482            }
1483        ));
1484    }
1485
1486    #[test]
1487    fn bind_search_spatial_radius_limit_rejects_non_integer() {
1488        let q = parse(
1489            "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1490        );
1491        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1492        assert!(matches!(
1493            err,
1494            UserParamError::TypeMismatch {
1495                slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
1496                got: "text"
1497            }
1498        ));
1499    }
1500
1501    #[test]
1502    fn bind_search_spatial_bbox_limit_param() {
1503        // Issue #361: `SEARCH SPATIAL BBOX ... LIMIT $N` binds an integer.
1504        let q =
1505            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1506        let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1507        let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
1508            limit, limit_param, ..
1509        }) = bound
1510        else {
1511            panic!("expected SearchCommand::SpatialBbox");
1512        };
1513        assert_eq!(limit, 50);
1514        assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1515    }
1516
1517    #[test]
1518    fn bind_search_spatial_bbox_limit_rejects_zero() {
1519        let q =
1520            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1521        let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1522        assert!(matches!(
1523            err,
1524            UserParamError::TypeMismatch {
1525                slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
1526                ..
1527            }
1528        ));
1529    }
1530
1531    #[test]
1532    fn bind_search_spatial_bbox_limit_rejects_non_integer() {
1533        let q =
1534            parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1535        let err = bind(&q, &[Value::text("five")]).unwrap_err();
1536        assert!(matches!(
1537            err,
1538            UserParamError::TypeMismatch {
1539                slot: "SEARCH SPATIAL BBOX LIMIT parameter",
1540                got: "text"
1541            }
1542        ));
1543    }
1544
1545    #[test]
1546    fn bind_search_similar_text_param() {
1547        // Issue #361: `SEARCH SIMILAR TEXT $N` binds a Value::Text into
1548        // the text slot. The embedding pipeline reads `text` downstream.
1549        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
1550        let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
1551        let QueryExpr::SearchCommand(SearchCommand::Similar {
1552            vector,
1553            text,
1554            text_param,
1555            collection,
1556            limit,
1557            provider,
1558            ..
1559        }) = bound
1560        else {
1561            panic!("expected SearchCommand::Similar");
1562        };
1563        assert!(vector.is_empty());
1564        assert_eq!(text.as_deref(), Some("find vulnerabilities"));
1565        assert_eq!(text_param, None, "text_param must be cleared post-bind");
1566        assert_eq!(collection, "docs");
1567        assert_eq!(limit, 5);
1568        assert_eq!(provider.as_deref(), Some("openai"));
1569    }
1570
1571    #[test]
1572    fn bind_search_similar_text_rejects_non_text() {
1573        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
1574        let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1575        assert!(
1576            matches!(
1577                err,
1578                UserParamError::TypeMismatch {
1579                    slot: "SEARCH SIMILAR TEXT parameter",
1580                    got: "integer"
1581                }
1582            ),
1583            "got {err:?}"
1584        );
1585    }
1586
1587    #[test]
1588    fn bind_search_similar_text_with_limit_param() {
1589        // TEXT $1 + LIMIT $2 — verify both non-Expr param slots bind
1590        // together without cross-talk.
1591        let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
1592        let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
1593        let QueryExpr::SearchCommand(SearchCommand::Similar {
1594            text,
1595            text_param,
1596            limit,
1597            limit_param,
1598            ..
1599        }) = bound
1600        else {
1601            panic!("expected SearchCommand::Similar");
1602        };
1603        assert_eq!(text.as_deref(), Some("hello"));
1604        assert_eq!(text_param, None);
1605        assert_eq!(limit, 11);
1606        assert_eq!(limit_param, None);
1607    }
1608
1609    #[test]
1610    fn bind_insert_values_with_vector_param() {
1611        // Issue #355 INSERT half: $1 in VALUES is bound to a Value::Vector
1612        // and surfaces in both `value_exprs` (as a Literal) and `values`.
1613        let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
1614        let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
1615        let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
1616        let QueryExpr::Insert(insert) = bound else {
1617            panic!("expected Insert");
1618        };
1619        assert_eq!(insert.values.len(), 1);
1620        assert_eq!(insert.values[0].len(), 2);
1621        assert!(
1622            matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
1623        );
1624        assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
1625        // value_exprs row 0 col 0 is now a Literal carrying the vector.
1626        let row0 = &insert.value_exprs[0];
1627        assert!(matches!(
1628            &row0[0],
1629            Expr::Literal {
1630                value: Value::Vector(_),
1631                ..
1632            }
1633        ));
1634    }
1635
1636    #[test]
1637    fn bind_insert_arity_mismatch() {
1638        let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
1639        let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
1640        assert!(matches!(
1641            err,
1642            UserParamError::Arity {
1643                expected: 2,
1644                got: 1
1645            }
1646        ));
1647    }
1648
1649    #[test]
1650    fn bind_no_params_is_noop() {
1651        let q = parse("SELECT * FROM users");
1652        let bound = bind(&q, &[]).unwrap();
1653        assert!(matches!(bound, QueryExpr::Table(_)));
1654    }
1655}