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