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