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