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