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 .zip(bound.compound_assignment_ops.iter())
873 .filter_map(|((column, expr), compound_op)| {
874 if compound_op.is_some() {
875 return None;
876 }
877 fold_expr_to_value(expr.clone())
878 .ok()
879 .map(|value| (column.clone(), value))
880 })
881 .collect();
882 let where_expr = bound
883 .where_expr
884 .map(|expr| substitute_params_in_expr(expr, params))
885 .transpose()?;
886 let filter = where_expr.as_ref().map(expr_to_filter);
887 bound.assignment_exprs = assignment_exprs;
888 bound.assignments = assignments;
889 bound.where_expr = where_expr;
890 bound.filter = filter;
891 return Ok(QueryExpr::Update(bound));
892 }
893
894 if let QueryExpr::Delete(delete) = expr {
895 let mut bound = delete.clone();
896 let where_expr = bound
897 .where_expr
898 .map(|expr| substitute_params_in_expr(expr, params))
899 .transpose()?;
900 let filter = where_expr.as_ref().map(expr_to_filter);
901 bound.where_expr = where_expr;
902 bound.filter = filter;
903 return Ok(QueryExpr::Delete(bound));
904 }
905
906 if let QueryExpr::Ask(ask) = expr {
907 let Some(idx) = ask.question_param else {
908 return Ok(QueryExpr::Ask(ask.clone()));
909 };
910 let value = params.get(idx).ok_or(UserParamError::Arity {
911 expected: idx + 1,
912 got: params.len(),
913 })?;
914 let question = match value {
915 Value::Text(s) => s.to_string(),
916 other => {
917 return Err(UserParamError::TypeMismatch {
918 slot: "ASK question parameter",
919 got: value_variant_name(other),
920 });
921 }
922 };
923 let mut bound = ask.clone();
924 bound.question = question;
925 bound.question_param = None;
926 return Ok(QueryExpr::Ask(bound));
927 }
928
929 if let QueryExpr::Table(table) = expr {
934 if table.limit_param.is_some() || table.offset_param.is_some() {
935 let bound_inner =
936 bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)?;
937 let mut bound_table = match bound_inner {
938 QueryExpr::Table(t) => t,
939 _ => return Err(UserParamError::UnsupportedShape),
940 };
941 if let Some(idx) = table.limit_param {
942 let value = params.get(idx).ok_or(UserParamError::Arity {
943 expected: idx + 1,
944 got: params.len(),
945 })?;
946 let n = match value {
947 Value::Integer(n) if *n > 0 => *n as u64,
948 Value::UnsignedInteger(n) if *n > 0 => *n,
949 Value::BigInt(n) if *n > 0 => *n as u64,
950 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
951 return Err(UserParamError::TypeMismatch {
952 slot: "SELECT LIMIT parameter (must be > 0)",
953 got: value_variant_name(value),
954 });
955 }
956 other => {
957 return Err(UserParamError::TypeMismatch {
958 slot: "SELECT LIMIT parameter",
959 got: value_variant_name(other),
960 });
961 }
962 };
963 bound_table.limit = Some(n);
964 bound_table.limit_param = None;
965 }
966 if let Some(idx) = table.offset_param {
967 let value = params.get(idx).ok_or(UserParamError::Arity {
968 expected: idx + 1,
969 got: params.len(),
970 })?;
971 let n = match value {
972 Value::Integer(n) if *n >= 0 => *n as u64,
973 Value::UnsignedInteger(n) => *n,
974 Value::BigInt(n) if *n >= 0 => *n as u64,
975 Value::Integer(_) | Value::BigInt(_) => {
976 return Err(UserParamError::TypeMismatch {
977 slot: "SELECT OFFSET parameter (must be >= 0)",
978 got: value_variant_name(value),
979 });
980 }
981 other => {
982 return Err(UserParamError::TypeMismatch {
983 slot: "SELECT OFFSET parameter",
984 got: value_variant_name(other),
985 });
986 }
987 };
988 bound_table.offset = Some(n);
989 bound_table.offset_param = None;
990 }
991 return Ok(QueryExpr::Table(bound_table));
992 }
993 }
994
995 bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
996}
997
998pub fn bind_parameters(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, BindError> {
1000 bind(expr, params)
1001}
1002
1003fn value_variant_name(value: &Value) -> &'static str {
1004 match value {
1005 Value::Null => "null",
1006 Value::Integer(_) => "integer",
1007 Value::UnsignedInteger(_) => "unsigned integer",
1008 Value::BigInt(_) => "bigint",
1009 Value::Float(_) => "float",
1010 Value::Text(_) => "text",
1011 Value::Boolean(_) => "boolean",
1012 Value::Vector(_) => "vector",
1013 Value::Json(_) => "json",
1014 Value::Blob(_) => "bytes",
1015 _ => "other",
1016 }
1017}
1018
1019fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
1020 match expr {
1021 QueryExpr::Table(q) => {
1022 for item in &q.select_items {
1023 if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
1024 visit_expr(expr, visit);
1025 }
1026 }
1027 if let Some(e) = &q.where_expr {
1028 visit_expr(e, visit);
1029 }
1030 for e in &q.group_by_exprs {
1031 visit_expr(e, visit);
1032 }
1033 if let Some(e) = &q.having_expr {
1034 visit_expr(e, visit);
1035 }
1036 for clause in &q.order_by {
1037 if let Some(e) = &clause.expr {
1038 visit_expr(e, visit);
1039 }
1040 }
1041 if let Some(crate::storage::query::ast::TableSource::Subquery(inner)) = &q.source {
1042 visit_query_expr(inner, visit);
1043 }
1044 }
1045 QueryExpr::Join(q) => {
1046 visit_query_expr(&q.left, visit);
1047 visit_query_expr(&q.right, visit);
1048 }
1049 QueryExpr::Hybrid(q) => {
1050 visit_query_expr(&q.structured, visit);
1051 }
1052 QueryExpr::Insert(q) => {
1053 for row in &q.value_exprs {
1054 for e in row {
1055 visit_expr(e, visit);
1056 }
1057 }
1058 }
1059 QueryExpr::Update(q) => {
1060 for (_, e) in &q.assignment_exprs {
1061 visit_expr(e, visit);
1062 }
1063 if let Some(e) = &q.where_expr {
1064 visit_expr(e, visit);
1065 }
1066 }
1067 QueryExpr::Delete(q) => {
1068 if let Some(e) = &q.where_expr {
1069 visit_expr(e, visit);
1070 }
1071 }
1072 _ => {}
1074 }
1075}
1076
1077fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
1078 visit(expr);
1079 match expr {
1080 Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
1081 Expr::BinaryOp { lhs, rhs, .. } => {
1082 visit_expr(lhs, visit);
1083 visit_expr(rhs, visit);
1084 }
1085 Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
1086 Expr::Cast { inner, .. } => visit_expr(inner, visit),
1087 Expr::FunctionCall { args, .. } => {
1088 for a in args {
1089 visit_expr(a, visit);
1090 }
1091 }
1092 Expr::Case {
1093 branches, else_, ..
1094 } => {
1095 for (c, v) in branches {
1096 visit_expr(c, visit);
1097 visit_expr(v, visit);
1098 }
1099 if let Some(e) = else_ {
1100 visit_expr(e, visit);
1101 }
1102 }
1103 Expr::IsNull { operand, .. } => visit_expr(operand, visit),
1104 Expr::InList { target, values, .. } => {
1105 visit_expr(target, visit);
1106 for v in values {
1107 visit_expr(v, visit);
1108 }
1109 }
1110 Expr::Between {
1111 target, low, high, ..
1112 } => {
1113 visit_expr(target, visit);
1114 visit_expr(low, visit);
1115 visit_expr(high, visit);
1116 }
1117 Expr::Subquery { .. } => {}
1118 }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124 use crate::storage::query::modes::parse_multi;
1125
1126 fn parse(sql: &str) -> QueryExpr {
1127 parse_multi(sql).expect("parse")
1128 }
1129
1130 #[test]
1131 fn collect_indices_select_where() {
1132 let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
1133 let mut ix = collect_indices(&q);
1134 ix.sort();
1135 assert_eq!(ix, vec![0, 1]);
1136 }
1137
1138 #[test]
1139 fn scan_parameters_reports_index_and_span() {
1140 let sql = "SELECT * FROM users WHERE id = $1 AND name = $2";
1141 let q = parse(sql);
1142 let params = scan_parameters(&q);
1143 assert_eq!(
1144 params.iter().map(|param| param.index).collect::<Vec<_>>(),
1145 vec![0, 1]
1146 );
1147 assert_eq!(
1148 sql[params[0].span.start.offset as usize..params[0].span.end.offset as usize].trim(),
1149 "$1"
1150 );
1151 assert_eq!(
1152 sql[params[1].span.start.offset as usize..params[1].span.end.offset as usize].trim(),
1153 "$2"
1154 );
1155 }
1156
1157 #[test]
1158 fn validate_ok() {
1159 assert!(validate(&[0, 1], 2).is_ok());
1160 assert!(validate(&[0, 1, 0], 2).is_ok());
1161 assert!(validate(&[], 0).is_ok());
1162 }
1163
1164 #[test]
1165 fn validate_arity_too_few() {
1166 let err = validate(&[0, 1], 1).unwrap_err();
1167 assert!(matches!(
1168 err,
1169 UserParamError::Arity {
1170 expected: 2,
1171 got: 1
1172 }
1173 ));
1174 }
1175
1176 #[test]
1177 fn validate_arity_too_many() {
1178 let err = validate(&[0], 3).unwrap_err();
1179 assert!(matches!(
1180 err,
1181 UserParamError::Arity {
1182 expected: 1,
1183 got: 3
1184 }
1185 ));
1186 }
1187
1188 #[test]
1189 fn validate_gap() {
1190 let err = validate(&[0, 2], 3).unwrap_err();
1192 assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
1193 }
1194
1195 #[test]
1196 fn bind_substitutes_int_param() {
1197 let q = parse("SELECT * FROM users WHERE id = $1");
1198 let bound = bind(&q, &[Value::Integer(42)]).unwrap();
1199 let QueryExpr::Table(t) = bound else {
1200 panic!("expected Table");
1201 };
1202 let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
1203 panic!("expected BinaryOp");
1204 };
1205 assert!(matches!(
1206 *rhs,
1207 Expr::Literal {
1208 value: Value::Integer(42),
1209 ..
1210 }
1211 ));
1212 }
1213
1214 #[test]
1215 fn bind_substitutes_question_numbered_param() {
1216 let q = parse("SELECT * FROM users WHERE id = ?1 AND name = ?2");
1217 let bound = bind(&q, &[Value::Integer(42), Value::text("Alice")]).unwrap();
1218 let QueryExpr::Table(t) = bound else {
1219 panic!("expected Table");
1220 };
1221 let mut literals: Vec<Value> = Vec::new();
1222 visit_expr(&t.where_expr.unwrap(), &mut |e| {
1223 if let Expr::Literal { value, .. } = e {
1224 literals.push(value.clone());
1225 }
1226 });
1227 assert!(literals.iter().any(|v| matches!(v, Value::Integer(42))));
1228 assert!(literals
1229 .iter()
1230 .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1231 }
1232
1233 #[test]
1234 fn bind_substitutes_text_and_null() {
1235 let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1236 let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1237 let QueryExpr::Table(t) = bound else {
1238 panic!("expected Table");
1239 };
1240 let mut literals: Vec<Value> = Vec::new();
1241 visit_expr(&t.where_expr.unwrap(), &mut |e| {
1242 if let Expr::Literal { value, .. } = e {
1243 literals.push(value.clone());
1244 }
1245 });
1246 assert!(literals
1247 .iter()
1248 .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1249 assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1250 }
1251
1252 #[test]
1253 fn bind_search_similar_vector_param() {
1254 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1257 let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1258 let QueryExpr::SearchCommand(SearchCommand::Similar {
1259 vector,
1260 vector_param,
1261 collection,
1262 limit,
1263 ..
1264 }) = bound
1265 else {
1266 panic!("expected SearchCommand::Similar");
1267 };
1268 assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1269 assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1270 assert_eq!(collection, "embeddings");
1271 assert_eq!(limit, 5);
1272 }
1273
1274 #[test]
1275 fn bind_search_similar_limit_param() {
1276 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1278 let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1279 let QueryExpr::SearchCommand(SearchCommand::Similar {
1280 limit,
1281 limit_param,
1282 min_score_param,
1283 ..
1284 }) = bound
1285 else {
1286 panic!("expected SearchCommand::Similar");
1287 };
1288 assert_eq!(limit, 25);
1289 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1290 assert_eq!(min_score_param, None);
1291 }
1292
1293 #[test]
1294 fn bind_search_similar_min_score_param() {
1295 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1296 let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1297 let QueryExpr::SearchCommand(SearchCommand::Similar {
1298 min_score,
1299 min_score_param,
1300 ..
1301 }) = bound
1302 else {
1303 panic!("expected SearchCommand::Similar");
1304 };
1305 assert!((min_score - 0.42_f32).abs() < 1e-6);
1306 assert_eq!(min_score_param, None);
1307 }
1308
1309 #[test]
1310 fn bind_search_similar_limit_and_min_score_together() {
1311 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1312 let bound = bind(
1313 &q,
1314 &[
1315 Value::Vector(vec![0.1, 0.2]),
1316 Value::Integer(7),
1317 Value::Float(0.9),
1318 ],
1319 )
1320 .unwrap();
1321 let QueryExpr::SearchCommand(SearchCommand::Similar {
1322 limit,
1323 min_score,
1324 vector,
1325 vector_param,
1326 limit_param,
1327 min_score_param,
1328 ..
1329 }) = bound
1330 else {
1331 panic!("expected SearchCommand::Similar");
1332 };
1333 assert_eq!(vector, vec![0.1_f32, 0.2]);
1334 assert_eq!(limit, 7);
1335 assert!((min_score - 0.9_f32).abs() < 1e-6);
1336 assert_eq!(vector_param, None);
1337 assert_eq!(limit_param, None);
1338 assert_eq!(min_score_param, None);
1339 }
1340
1341 #[test]
1342 fn bind_ask_question_param() {
1343 let q = parse("ASK $1 USING openai LIMIT 1");
1344 let bound = bind(&q, &[Value::text("why did incident FDD-12313 fail?")]).unwrap();
1345 let QueryExpr::Ask(ask) = bound else {
1346 panic!("expected Ask");
1347 };
1348 assert_eq!(ask.question, "why did incident FDD-12313 fail?");
1349 assert_eq!(ask.question_param, None);
1350 assert_eq!(ask.provider.as_deref(), Some("openai"));
1351 }
1352
1353 #[test]
1354 fn bind_ask_question_param_rejects_non_text() {
1355 let q = parse("ASK $1 USING openai LIMIT 1");
1356 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1357 assert!(matches!(
1358 err,
1359 UserParamError::TypeMismatch {
1360 slot: "ASK question parameter",
1361 got: "integer"
1362 }
1363 ));
1364 }
1365
1366 #[test]
1367 fn bind_search_similar_limit_rejects_non_integer() {
1368 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1369 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1370 assert!(
1371 matches!(
1372 err,
1373 UserParamError::TypeMismatch {
1374 slot: "SEARCH SIMILAR LIMIT parameter",
1375 got: "text"
1376 }
1377 ),
1378 "got {err:?}"
1379 );
1380 }
1381
1382 #[test]
1383 fn bind_search_similar_limit_rejects_zero_or_negative() {
1384 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1385 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1386 assert!(matches!(
1387 err,
1388 UserParamError::TypeMismatch {
1389 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1390 ..
1391 }
1392 ));
1393 let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1394 assert!(matches!(
1395 err,
1396 UserParamError::TypeMismatch {
1397 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1398 ..
1399 }
1400 ));
1401 }
1402
1403 #[test]
1404 fn bind_search_similar_min_score_rejects_non_numeric() {
1405 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1406 let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1407 assert!(matches!(
1408 err,
1409 UserParamError::TypeMismatch {
1410 slot: "SEARCH SIMILAR MIN_SCORE parameter",
1411 got: "vector"
1412 }
1413 ));
1414 }
1415
1416 #[test]
1423 fn bind_search_similar_rejects_non_vector_param() {
1424 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1425 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1426 assert!(
1427 matches!(
1428 err,
1429 UserParamError::TypeMismatch {
1430 slot: "SEARCH SIMILAR vector parameter",
1431 got: "integer"
1432 }
1433 ),
1434 "got {err:?}"
1435 );
1436 }
1437
1438 #[test]
1439 fn bind_search_similar_empty_vector_param() {
1440 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1441 let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1442 let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1443 panic!("expected SearchCommand::Similar");
1444 };
1445 assert!(vector.is_empty());
1446 }
1447
1448 #[test]
1449 fn bind_parameters_substitutes_all_wire_value_variants() {
1450 let q = parse(
1451 "INSERT INTO value_params \
1452 (n, ok, count, score, name, payload, dense, body, seen_at, ident) \
1453 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
1454 );
1455 let uuid = [1_u8; 16];
1456 let params = vec![
1457 Value::Null,
1458 Value::Boolean(true),
1459 Value::Integer(42),
1460 Value::Float(1.5),
1461 Value::text("alice"),
1462 Value::Blob(vec![0, 1, 2]),
1463 Value::Vector(vec![0.25, 0.5]),
1464 Value::Json(br#"{"a":1}"#.to_vec()),
1465 Value::Timestamp(1_700_000_000),
1466 Value::Uuid(uuid),
1467 ];
1468 let bound = bind_parameters(&q, ¶ms).unwrap();
1469 let QueryExpr::Insert(insert) = bound else {
1470 panic!("expected Insert");
1471 };
1472 assert_eq!(insert.values, vec![params]);
1473 }
1474
1475 #[test]
1476 fn bind_parameters_reuses_duplicate_index() {
1477 let q = parse("SELECT * FROM users WHERE id = $1 OR manager_id = $1");
1478 let bound = bind_parameters(&q, &[Value::Integer(7)]).unwrap();
1479 let QueryExpr::Table(table) = bound else {
1480 panic!("expected Table");
1481 };
1482 assert!(table.where_expr.is_some());
1483 assert_eq!(
1484 collect_indices(&QueryExpr::Table(table)),
1485 Vec::<usize>::new()
1486 );
1487 }
1488
1489 #[test]
1490 fn bind_search_hybrid_limit_param() {
1491 let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1493 let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1494 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1495 limit, limit_param, ..
1496 }) = bound
1497 else {
1498 panic!("expected SearchCommand::Hybrid");
1499 };
1500 assert_eq!(limit, 30);
1501 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1502 }
1503
1504 #[test]
1505 fn bind_search_hybrid_k_param() {
1506 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1508 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1509 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1510 limit, limit_param, ..
1511 }) = bound
1512 else {
1513 panic!("expected SearchCommand::Hybrid");
1514 };
1515 assert_eq!(limit, 7);
1516 assert_eq!(limit_param, None);
1517 }
1518
1519 #[test]
1520 fn bind_search_hybrid_limit_rejects_non_integer() {
1521 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1522 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1523 assert!(
1524 matches!(
1525 err,
1526 UserParamError::TypeMismatch {
1527 slot: "SEARCH HYBRID LIMIT parameter",
1528 got: "text"
1529 }
1530 ),
1531 "got {err:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn bind_search_hybrid_limit_rejects_zero() {
1537 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1538 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1539 assert!(matches!(
1540 err,
1541 UserParamError::TypeMismatch {
1542 slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1543 ..
1544 }
1545 ));
1546 }
1547
1548 #[test]
1549 fn bind_search_spatial_nearest_k_param() {
1550 let q =
1552 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1553 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1554 let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1555 else {
1556 panic!("expected SpatialNearest");
1557 };
1558 assert_eq!(k, 7);
1559 assert_eq!(k_param, None, "k_param must be cleared post-bind");
1560 }
1561
1562 #[test]
1563 fn bind_search_spatial_nearest_k_rejects_zero() {
1564 let q =
1565 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1566 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1567 assert!(matches!(
1568 err,
1569 UserParamError::TypeMismatch {
1570 slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1571 ..
1572 }
1573 ));
1574 }
1575
1576 #[test]
1577 fn bind_search_spatial_nearest_k_rejects_non_integer() {
1578 let q =
1579 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1580 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1581 assert!(matches!(
1582 err,
1583 UserParamError::TypeMismatch {
1584 slot: "SEARCH SPATIAL NEAREST K parameter",
1585 got: "text"
1586 }
1587 ));
1588 }
1589
1590 #[test]
1591 fn bind_search_text_limit_param() {
1592 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1594 let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1595 let QueryExpr::SearchCommand(SearchCommand::Text {
1596 limit, limit_param, ..
1597 }) = bound
1598 else {
1599 panic!("expected SearchCommand::Text");
1600 };
1601 assert_eq!(limit, 15);
1602 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1603 }
1604
1605 #[test]
1606 fn bind_search_text_limit_rejects_zero() {
1607 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1608 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1609 assert!(matches!(
1610 err,
1611 UserParamError::TypeMismatch {
1612 slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1613 ..
1614 }
1615 ));
1616 }
1617
1618 #[test]
1619 fn bind_search_text_limit_rejects_non_integer() {
1620 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1621 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1622 assert!(matches!(
1623 err,
1624 UserParamError::TypeMismatch {
1625 slot: "SEARCH TEXT LIMIT parameter",
1626 got: "text"
1627 }
1628 ));
1629 }
1630
1631 #[test]
1632 fn bind_search_multimodal_limit_param() {
1633 let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1635 let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1636 let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1637 limit, limit_param, ..
1638 }) = bound
1639 else {
1640 panic!("expected SearchCommand::Multimodal");
1641 };
1642 assert_eq!(limit, 40);
1643 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1644 }
1645
1646 #[test]
1647 fn bind_search_multimodal_limit_rejects_zero() {
1648 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1649 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1650 assert!(matches!(
1651 err,
1652 UserParamError::TypeMismatch {
1653 slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1654 ..
1655 }
1656 ));
1657 }
1658
1659 #[test]
1660 fn bind_search_multimodal_limit_rejects_non_integer() {
1661 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1662 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1663 assert!(matches!(
1664 err,
1665 UserParamError::TypeMismatch {
1666 slot: "SEARCH MULTIMODAL LIMIT parameter",
1667 got: "text"
1668 }
1669 ));
1670 }
1671
1672 #[test]
1673 fn bind_search_index_limit_param() {
1674 let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1676 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1677 let QueryExpr::SearchCommand(SearchCommand::Index {
1678 limit, limit_param, ..
1679 }) = bound
1680 else {
1681 panic!("expected SearchCommand::Index");
1682 };
1683 assert_eq!(limit, 50);
1684 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1685 }
1686
1687 #[test]
1688 fn bind_search_index_limit_rejects_zero() {
1689 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1690 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1691 assert!(matches!(
1692 err,
1693 UserParamError::TypeMismatch {
1694 slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1695 ..
1696 }
1697 ));
1698 }
1699
1700 #[test]
1701 fn bind_search_index_limit_rejects_non_integer() {
1702 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1703 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1704 assert!(matches!(
1705 err,
1706 UserParamError::TypeMismatch {
1707 slot: "SEARCH INDEX LIMIT parameter",
1708 got: "text"
1709 }
1710 ));
1711 }
1712
1713 #[test]
1714 fn bind_search_context_limit_param() {
1715 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1717 let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1718 let QueryExpr::SearchCommand(SearchCommand::Context {
1719 limit, limit_param, ..
1720 }) = bound
1721 else {
1722 panic!("expected SearchCommand::Context");
1723 };
1724 assert_eq!(limit, 60);
1725 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1726 }
1727
1728 #[test]
1729 fn bind_search_context_limit_rejects_zero() {
1730 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1731 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1732 assert!(matches!(
1733 err,
1734 UserParamError::TypeMismatch {
1735 slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1736 ..
1737 }
1738 ));
1739 }
1740
1741 #[test]
1742 fn bind_search_context_limit_rejects_non_integer() {
1743 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1744 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1745 assert!(matches!(
1746 err,
1747 UserParamError::TypeMismatch {
1748 slot: "SEARCH CONTEXT LIMIT parameter",
1749 got: "text"
1750 }
1751 ));
1752 }
1753
1754 #[test]
1755 fn bind_search_spatial_radius_limit_param() {
1756 let q = parse(
1758 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1759 );
1760 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1761 let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1762 limit, limit_param, ..
1763 }) = bound
1764 else {
1765 panic!("expected SearchCommand::SpatialRadius");
1766 };
1767 assert_eq!(limit, 50);
1768 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1769 }
1770
1771 #[test]
1772 fn bind_search_spatial_radius_limit_rejects_zero() {
1773 let q = parse(
1774 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1775 );
1776 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1777 assert!(matches!(
1778 err,
1779 UserParamError::TypeMismatch {
1780 slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1781 ..
1782 }
1783 ));
1784 }
1785
1786 #[test]
1787 fn bind_search_spatial_radius_limit_rejects_non_integer() {
1788 let q = parse(
1789 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1790 );
1791 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1792 assert!(matches!(
1793 err,
1794 UserParamError::TypeMismatch {
1795 slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
1796 got: "text"
1797 }
1798 ));
1799 }
1800
1801 #[test]
1802 fn bind_search_spatial_bbox_limit_param() {
1803 let q =
1805 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1806 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1807 let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
1808 limit, limit_param, ..
1809 }) = bound
1810 else {
1811 panic!("expected SearchCommand::SpatialBbox");
1812 };
1813 assert_eq!(limit, 50);
1814 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1815 }
1816
1817 #[test]
1818 fn bind_search_spatial_bbox_limit_rejects_zero() {
1819 let q =
1820 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1821 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1822 assert!(matches!(
1823 err,
1824 UserParamError::TypeMismatch {
1825 slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
1826 ..
1827 }
1828 ));
1829 }
1830
1831 #[test]
1832 fn bind_search_spatial_bbox_limit_rejects_non_integer() {
1833 let q =
1834 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1835 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1836 assert!(matches!(
1837 err,
1838 UserParamError::TypeMismatch {
1839 slot: "SEARCH SPATIAL BBOX LIMIT parameter",
1840 got: "text"
1841 }
1842 ));
1843 }
1844
1845 #[test]
1846 fn bind_search_similar_text_param() {
1847 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
1850 let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
1851 let QueryExpr::SearchCommand(SearchCommand::Similar {
1852 vector,
1853 text,
1854 text_param,
1855 collection,
1856 limit,
1857 provider,
1858 ..
1859 }) = bound
1860 else {
1861 panic!("expected SearchCommand::Similar");
1862 };
1863 assert!(vector.is_empty());
1864 assert_eq!(text.as_deref(), Some("find vulnerabilities"));
1865 assert_eq!(text_param, None, "text_param must be cleared post-bind");
1866 assert_eq!(collection, "docs");
1867 assert_eq!(limit, 5);
1868 assert_eq!(provider.as_deref(), Some("openai"));
1869 }
1870
1871 #[test]
1872 fn bind_search_similar_text_rejects_non_text() {
1873 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
1874 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1875 assert!(
1876 matches!(
1877 err,
1878 UserParamError::TypeMismatch {
1879 slot: "SEARCH SIMILAR TEXT parameter",
1880 got: "integer"
1881 }
1882 ),
1883 "got {err:?}"
1884 );
1885 }
1886
1887 #[test]
1888 fn bind_search_similar_text_with_limit_param() {
1889 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
1892 let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
1893 let QueryExpr::SearchCommand(SearchCommand::Similar {
1894 text,
1895 text_param,
1896 limit,
1897 limit_param,
1898 ..
1899 }) = bound
1900 else {
1901 panic!("expected SearchCommand::Similar");
1902 };
1903 assert_eq!(text.as_deref(), Some("hello"));
1904 assert_eq!(text_param, None);
1905 assert_eq!(limit, 11);
1906 assert_eq!(limit_param, None);
1907 }
1908
1909 #[test]
1910 fn bind_insert_values_with_vector_param() {
1911 let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
1914 let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
1915 let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
1916 let QueryExpr::Insert(insert) = bound else {
1917 panic!("expected Insert");
1918 };
1919 assert_eq!(insert.values.len(), 1);
1920 assert_eq!(insert.values[0].len(), 2);
1921 assert!(
1922 matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
1923 );
1924 assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
1925 let row0 = &insert.value_exprs[0];
1927 assert!(matches!(
1928 &row0[0],
1929 Expr::Literal {
1930 value: Value::Vector(_),
1931 ..
1932 }
1933 ));
1934 }
1935
1936 #[test]
1937 fn bind_insert_arity_mismatch() {
1938 let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
1939 let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
1940 assert!(matches!(
1941 err,
1942 UserParamError::Arity {
1943 expected: 2,
1944 got: 1
1945 }
1946 ));
1947 }
1948
1949 #[test]
1950 fn bind_update_assignments_and_where_params() {
1951 let q = parse("UPDATE users SET age = $1, active = $2 WHERE name = $3");
1952 let bound = bind(
1953 &q,
1954 &[
1955 Value::Integer(31),
1956 Value::Boolean(true),
1957 Value::text("Alice"),
1958 ],
1959 )
1960 .unwrap();
1961 let QueryExpr::Update(update) = bound else {
1962 panic!("expected Update");
1963 };
1964 assert_eq!(update.assignments.len(), 2);
1965 assert!(matches!(update.assignments[0].1, Value::Integer(31)));
1966 assert!(matches!(update.assignments[1].1, Value::Boolean(true)));
1967 assert!(update.where_expr.is_some());
1968 assert!(update.filter.is_some());
1969 }
1970
1971 #[test]
1972 fn bind_delete_where_param() {
1973 let q = parse("DELETE FROM users WHERE active = $1");
1974 let bound = bind(&q, &[Value::Boolean(false)]).unwrap();
1975 let QueryExpr::Delete(delete) = bound else {
1976 panic!("expected Delete");
1977 };
1978 assert!(delete.where_expr.is_some());
1979 assert!(delete.filter.is_some());
1980 }
1981
1982 #[test]
1983 fn bind_select_limit_param() {
1984 let q = parse("SELECT * FROM users LIMIT $1");
1985 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1986 let QueryExpr::Table(t) = bound else {
1987 panic!("expected Table");
1988 };
1989 assert_eq!(t.limit, Some(7));
1990 assert_eq!(t.limit_param, None, "limit_param must be cleared post-bind");
1991 assert_eq!(t.offset, None);
1992 assert_eq!(t.offset_param, None);
1993 }
1994
1995 #[test]
1996 fn bind_select_offset_param() {
1997 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
1998 let bound = bind(&q, &[Value::Integer(20)]).unwrap();
1999 let QueryExpr::Table(t) = bound else {
2000 panic!("expected Table");
2001 };
2002 assert_eq!(t.limit, Some(10));
2003 assert_eq!(t.offset, Some(20));
2004 assert_eq!(t.offset_param, None);
2005 }
2006
2007 #[test]
2008 fn bind_select_limit_and_offset_params_together() {
2009 let q = parse("SELECT * FROM users WHERE id = $1 LIMIT $2 OFFSET $3");
2010 let bound = bind(
2011 &q,
2012 &[Value::Integer(5), Value::Integer(10), Value::Integer(20)],
2013 )
2014 .unwrap();
2015 let QueryExpr::Table(t) = bound else {
2016 panic!("expected Table");
2017 };
2018 assert_eq!(t.limit, Some(10));
2019 assert_eq!(t.offset, Some(20));
2020 assert_eq!(t.limit_param, None);
2021 assert_eq!(t.offset_param, None);
2022 assert!(t.where_expr.is_some());
2024 }
2025
2026 #[test]
2027 fn bind_select_offset_zero_is_valid() {
2028 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2029 let bound = bind(&q, &[Value::Integer(0)]).unwrap();
2030 let QueryExpr::Table(t) = bound else {
2031 panic!("expected Table");
2032 };
2033 assert_eq!(t.offset, Some(0));
2034 }
2035
2036 #[test]
2037 fn bind_select_limit_rejects_zero() {
2038 let q = parse("SELECT * FROM users LIMIT $1");
2039 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
2040 assert!(
2041 matches!(
2042 err,
2043 UserParamError::TypeMismatch {
2044 slot: "SELECT LIMIT parameter (must be > 0)",
2045 ..
2046 }
2047 ),
2048 "got {err:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn bind_select_limit_rejects_non_integer() {
2054 let q = parse("SELECT * FROM users LIMIT $1");
2055 let err = bind(&q, &[Value::text("ten")]).unwrap_err();
2056 assert!(
2057 matches!(
2058 err,
2059 UserParamError::TypeMismatch {
2060 slot: "SELECT LIMIT parameter",
2061 got: "text"
2062 }
2063 ),
2064 "got {err:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn bind_select_offset_rejects_negative() {
2070 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
2071 let err = bind(&q, &[Value::Integer(-1)]).unwrap_err();
2072 assert!(
2073 matches!(
2074 err,
2075 UserParamError::TypeMismatch {
2076 slot: "SELECT OFFSET parameter (must be >= 0)",
2077 ..
2078 }
2079 ),
2080 "got {err:?}"
2081 );
2082 }
2083
2084 #[test]
2085 fn bind_no_params_is_noop() {
2086 let q = parse("SELECT * FROM users");
2087 let bound = bind(&q, &[]).unwrap();
2088 assert!(matches!(bound, QueryExpr::Table(_)));
2089 }
2090}