1use crate::storage::query::ast::{Expr, QueryExpr, SearchCommand};
10use crate::storage::query::planner::shape::bind_user_param_query;
11use crate::storage::query::sql_lowering::fold_expr_to_value;
12use crate::storage::schema::Value;
13
14pub fn expr_contains_parameter(expr: &Expr) -> bool {
18 match expr {
19 Expr::Parameter { .. } => true,
20 Expr::Literal { .. } | Expr::Column { .. } => false,
21 Expr::BinaryOp { lhs, rhs, .. } => {
22 expr_contains_parameter(lhs) || expr_contains_parameter(rhs)
23 }
24 Expr::UnaryOp { operand, .. } => expr_contains_parameter(operand),
25 Expr::Cast { inner, .. } => expr_contains_parameter(inner),
26 Expr::FunctionCall { args, .. } => args.iter().any(expr_contains_parameter),
27 Expr::Case {
28 branches, else_, ..
29 } => {
30 branches
31 .iter()
32 .any(|(c, v)| expr_contains_parameter(c) || expr_contains_parameter(v))
33 || else_.as_deref().is_some_and(expr_contains_parameter)
34 }
35 Expr::IsNull { operand, .. } => expr_contains_parameter(operand),
36 Expr::InList { target, values, .. } => {
37 expr_contains_parameter(target) || values.iter().any(expr_contains_parameter)
38 }
39 Expr::Between {
40 target, low, high, ..
41 } => {
42 expr_contains_parameter(target)
43 || expr_contains_parameter(low)
44 || expr_contains_parameter(high)
45 }
46 }
47}
48
49fn substitute_params_in_expr(expr: Expr, params: &[Value]) -> Result<Expr, UserParamError> {
53 match expr {
54 Expr::Parameter { index, span } => {
55 let value = params.get(index).ok_or(UserParamError::Arity {
56 expected: index + 1,
57 got: params.len(),
58 })?;
59 Ok(Expr::Literal {
60 value: value.clone(),
61 span,
62 })
63 }
64 Expr::Literal { .. } | Expr::Column { .. } => Ok(expr),
65 Expr::BinaryOp { op, lhs, rhs, span } => Ok(Expr::BinaryOp {
66 op,
67 lhs: Box::new(substitute_params_in_expr(*lhs, params)?),
68 rhs: Box::new(substitute_params_in_expr(*rhs, params)?),
69 span,
70 }),
71 Expr::UnaryOp { op, operand, span } => Ok(Expr::UnaryOp {
72 op,
73 operand: Box::new(substitute_params_in_expr(*operand, params)?),
74 span,
75 }),
76 Expr::Cast {
77 inner,
78 target,
79 span,
80 } => Ok(Expr::Cast {
81 inner: Box::new(substitute_params_in_expr(*inner, params)?),
82 target,
83 span,
84 }),
85 Expr::FunctionCall { name, args, span } => {
86 let new_args = args
87 .into_iter()
88 .map(|a| substitute_params_in_expr(a, params))
89 .collect::<Result<Vec<_>, _>>()?;
90 Ok(Expr::FunctionCall {
91 name,
92 args: new_args,
93 span,
94 })
95 }
96 Expr::Case {
97 branches,
98 else_,
99 span,
100 } => {
101 let new_branches = branches
102 .into_iter()
103 .map(|(c, v)| {
104 Ok::<_, UserParamError>((
105 substitute_params_in_expr(c, params)?,
106 substitute_params_in_expr(v, params)?,
107 ))
108 })
109 .collect::<Result<Vec<_>, _>>()?;
110 let new_else = match else_ {
111 Some(e) => Some(Box::new(substitute_params_in_expr(*e, params)?)),
112 None => None,
113 };
114 Ok(Expr::Case {
115 branches: new_branches,
116 else_: new_else,
117 span,
118 })
119 }
120 Expr::IsNull {
121 operand,
122 negated,
123 span,
124 } => Ok(Expr::IsNull {
125 operand: Box::new(substitute_params_in_expr(*operand, params)?),
126 negated,
127 span,
128 }),
129 Expr::InList {
130 target,
131 values,
132 negated,
133 span,
134 } => Ok(Expr::InList {
135 target: Box::new(substitute_params_in_expr(*target, params)?),
136 values: values
137 .into_iter()
138 .map(|v| substitute_params_in_expr(v, params))
139 .collect::<Result<Vec<_>, _>>()?,
140 negated,
141 span,
142 }),
143 Expr::Between {
144 target,
145 low,
146 high,
147 negated,
148 span,
149 } => Ok(Expr::Between {
150 target: Box::new(substitute_params_in_expr(*target, params)?),
151 low: Box::new(substitute_params_in_expr(*low, params)?),
152 high: Box::new(substitute_params_in_expr(*high, params)?),
153 negated,
154 span,
155 }),
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum UserParamError {
163 Arity { expected: usize, got: usize },
167 Gap { missing: usize, max: usize },
170 UnsupportedShape,
175 TypeMismatch {
179 slot: &'static str,
180 got: &'static str,
181 },
182}
183
184impl std::fmt::Display for UserParamError {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 UserParamError::Arity { expected, got } => write!(
188 f,
189 "wrong number of parameters: SQL expects {expected}, got {got}"
190 ),
191 UserParamError::Gap { missing, max } => write!(
192 f,
193 "parameter $`{missing}` is missing (max index used is ${max}) — `$N` indices must be contiguous starting at $1"
194 ),
195 UserParamError::UnsupportedShape => f.write_str(
196 "this query shape does not support `$N` parameters in the tracer-bullet slice",
197 ),
198 UserParamError::TypeMismatch { slot, got } => write!(
199 f,
200 "parameter type mismatch: {slot} (got {got})"
201 ),
202 }
203 }
204}
205
206impl std::error::Error for UserParamError {}
207
208pub fn collect_indices(expr: &QueryExpr) -> Vec<usize> {
212 let mut out = Vec::new();
213 visit_query_expr(expr, &mut |e| {
214 if let Expr::Parameter { index, .. } = e {
215 out.push(*index);
216 }
217 });
218 collect_non_expr_indices(expr, &mut out);
219 out
220}
221
222fn collect_non_expr_indices(expr: &QueryExpr, out: &mut Vec<usize>) {
225 match expr {
226 QueryExpr::SearchCommand(SearchCommand::Similar {
227 vector_param,
228 limit_param,
229 min_score_param,
230 text_param,
231 ..
232 }) => {
233 if let Some(idx) = vector_param {
234 out.push(*idx);
235 }
236 if let Some(idx) = limit_param {
237 out.push(*idx);
238 }
239 if let Some(idx) = min_score_param {
240 out.push(*idx);
241 }
242 if let Some(idx) = text_param {
243 out.push(*idx);
244 }
245 }
246 QueryExpr::SearchCommand(SearchCommand::Hybrid { limit_param, .. }) => {
247 if let Some(idx) = limit_param {
248 out.push(*idx);
249 }
250 }
251 QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k_param, .. }) => {
252 if let Some(idx) = k_param {
253 out.push(*idx);
254 }
255 }
256 QueryExpr::SearchCommand(SearchCommand::SpatialRadius { limit_param, .. }) => {
257 if let Some(idx) = limit_param {
258 out.push(*idx);
259 }
260 }
261 QueryExpr::SearchCommand(SearchCommand::SpatialBbox { limit_param, .. }) => {
262 if let Some(idx) = limit_param {
263 out.push(*idx);
264 }
265 }
266 QueryExpr::SearchCommand(SearchCommand::Text { limit_param, .. }) => {
267 if let Some(idx) = limit_param {
268 out.push(*idx);
269 }
270 }
271 QueryExpr::SearchCommand(SearchCommand::Multimodal { limit_param, .. }) => {
272 if let Some(idx) = limit_param {
273 out.push(*idx);
274 }
275 }
276 QueryExpr::SearchCommand(SearchCommand::Index { limit_param, .. }) => {
277 if let Some(idx) = limit_param {
278 out.push(*idx);
279 }
280 }
281 QueryExpr::SearchCommand(SearchCommand::Context { limit_param, .. }) => {
282 if let Some(idx) = limit_param {
283 out.push(*idx);
284 }
285 }
286 _ => {}
287 }
288}
289
290pub fn validate(indices: &[usize], param_count: usize) -> Result<(), UserParamError> {
293 let max_used = indices.iter().copied().max();
294
295 let expected = match max_used {
296 Some(m) => m + 1,
297 None => 0,
298 };
299
300 if expected != param_count {
301 return Err(UserParamError::Arity {
302 expected,
303 got: param_count,
304 });
305 }
306
307 if let Some(max) = max_used {
308 let mut seen = vec![false; max + 1];
309 for &i in indices {
310 seen[i] = true;
311 }
312 for (i, used) in seen.iter().enumerate() {
313 if !used {
314 return Err(UserParamError::Gap {
315 missing: i + 1,
316 max: max + 1,
317 });
318 }
319 }
320 }
321
322 Ok(())
323}
324
325pub fn bind(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, UserParamError> {
327 let indices = collect_indices(expr);
328 validate(&indices, params.len())?;
329
330 if indices.is_empty() {
331 return Ok(expr.clone());
332 }
333
334 if let QueryExpr::SearchCommand(SearchCommand::Similar {
338 vector,
339 text,
340 provider,
341 collection,
342 limit,
343 min_score,
344 vector_param,
345 limit_param,
346 min_score_param,
347 text_param,
348 }) = expr
349 {
350 let mut bound_vector = vector.clone();
351 if let Some(idx) = vector_param {
352 let value = params.get(*idx).ok_or(UserParamError::Arity {
353 expected: idx + 1,
354 got: params.len(),
355 })?;
356 bound_vector = match value {
357 Value::Vector(v) => v.clone(),
358 other => {
359 return Err(UserParamError::TypeMismatch {
360 slot: "SEARCH SIMILAR vector parameter",
361 got: value_variant_name(other),
362 });
363 }
364 };
365 }
366 let bound_limit = if let Some(idx) = limit_param {
367 let value = params.get(*idx).ok_or(UserParamError::Arity {
368 expected: idx + 1,
369 got: params.len(),
370 })?;
371 match value {
372 Value::Integer(n) if *n > 0 => *n as usize,
373 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
374 Value::BigInt(n) if *n > 0 => *n as usize,
375 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
376 return Err(UserParamError::TypeMismatch {
377 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
378 got: value_variant_name(value),
379 });
380 }
381 other => {
382 return Err(UserParamError::TypeMismatch {
383 slot: "SEARCH SIMILAR LIMIT parameter",
384 got: value_variant_name(other),
385 });
386 }
387 }
388 } else {
389 *limit
390 };
391 let bound_min_score = if let Some(idx) = min_score_param {
392 let value = params.get(*idx).ok_or(UserParamError::Arity {
393 expected: idx + 1,
394 got: params.len(),
395 })?;
396 match value {
397 Value::Float(f) => *f as f32,
398 Value::Integer(n) => *n as f32,
399 Value::UnsignedInteger(n) => *n as f32,
400 Value::BigInt(n) => *n as f32,
401 other => {
402 return Err(UserParamError::TypeMismatch {
403 slot: "SEARCH SIMILAR MIN_SCORE parameter",
404 got: value_variant_name(other),
405 });
406 }
407 }
408 } else {
409 *min_score
410 };
411 let bound_text = if let Some(idx) = text_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::Text(s) => Some(s.to_string()),
418 other => {
419 return Err(UserParamError::TypeMismatch {
420 slot: "SEARCH SIMILAR TEXT parameter",
421 got: value_variant_name(other),
422 });
423 }
424 }
425 } else {
426 text.clone()
427 };
428 return Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
429 vector: bound_vector,
430 text: bound_text,
431 provider: provider.clone(),
432 collection: collection.clone(),
433 limit: bound_limit,
434 min_score: bound_min_score,
435 vector_param: None,
436 limit_param: None,
437 min_score_param: None,
438 text_param: None,
439 }));
440 }
441
442 if let QueryExpr::SearchCommand(SearchCommand::Hybrid {
443 vector,
444 query,
445 collection,
446 limit,
447 limit_param,
448 }) = expr
449 {
450 let bound_limit = if let Some(idx) = limit_param {
451 let value = params.get(*idx).ok_or(UserParamError::Arity {
452 expected: idx + 1,
453 got: params.len(),
454 })?;
455 match value {
456 Value::Integer(n) if *n > 0 => *n as usize,
457 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
458 Value::BigInt(n) if *n > 0 => *n as usize,
459 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
460 return Err(UserParamError::TypeMismatch {
461 slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
462 got: value_variant_name(value),
463 });
464 }
465 other => {
466 return Err(UserParamError::TypeMismatch {
467 slot: "SEARCH HYBRID LIMIT parameter",
468 got: value_variant_name(other),
469 });
470 }
471 }
472 } else {
473 *limit
474 };
475 return Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
476 vector: vector.clone(),
477 query: query.clone(),
478 collection: collection.clone(),
479 limit: bound_limit,
480 limit_param: None,
481 }));
482 }
483
484 if let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
485 lat,
486 lon,
487 k,
488 collection,
489 column,
490 k_param,
491 }) = expr
492 {
493 let bound_k = if let Some(idx) = k_param {
494 let value = params.get(*idx).ok_or(UserParamError::Arity {
495 expected: idx + 1,
496 got: params.len(),
497 })?;
498 match value {
499 Value::Integer(n) if *n > 0 => *n as usize,
500 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
501 Value::BigInt(n) if *n > 0 => *n as usize,
502 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
503 return Err(UserParamError::TypeMismatch {
504 slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
505 got: value_variant_name(value),
506 });
507 }
508 other => {
509 return Err(UserParamError::TypeMismatch {
510 slot: "SEARCH SPATIAL NEAREST K parameter",
511 got: value_variant_name(other),
512 });
513 }
514 }
515 } else {
516 *k
517 };
518 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
519 lat: *lat,
520 lon: *lon,
521 k: bound_k,
522 collection: collection.clone(),
523 column: column.clone(),
524 k_param: None,
525 }));
526 }
527
528 if let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
529 center_lat,
530 center_lon,
531 radius_km,
532 collection,
533 column,
534 limit,
535 limit_param,
536 }) = expr
537 {
538 let bound_limit = if let Some(idx) = limit_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 RADIUS LIMIT parameter (must be > 0)",
550 got: value_variant_name(value),
551 });
552 }
553 other => {
554 return Err(UserParamError::TypeMismatch {
555 slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
556 got: value_variant_name(other),
557 });
558 }
559 }
560 } else {
561 *limit
562 };
563 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
564 center_lat: *center_lat,
565 center_lon: *center_lon,
566 radius_km: *radius_km,
567 collection: collection.clone(),
568 column: column.clone(),
569 limit: bound_limit,
570 limit_param: None,
571 }));
572 }
573
574 if let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
575 min_lat,
576 min_lon,
577 max_lat,
578 max_lon,
579 collection,
580 column,
581 limit,
582 limit_param,
583 }) = expr
584 {
585 let bound_limit = if let Some(idx) = limit_param {
586 let value = params.get(*idx).ok_or(UserParamError::Arity {
587 expected: idx + 1,
588 got: params.len(),
589 })?;
590 match value {
591 Value::Integer(n) if *n > 0 => *n as usize,
592 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
593 Value::BigInt(n) if *n > 0 => *n as usize,
594 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
595 return Err(UserParamError::TypeMismatch {
596 slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
597 got: value_variant_name(value),
598 });
599 }
600 other => {
601 return Err(UserParamError::TypeMismatch {
602 slot: "SEARCH SPATIAL BBOX LIMIT parameter",
603 got: value_variant_name(other),
604 });
605 }
606 }
607 } else {
608 *limit
609 };
610 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
611 min_lat: *min_lat,
612 min_lon: *min_lon,
613 max_lat: *max_lat,
614 max_lon: *max_lon,
615 collection: collection.clone(),
616 column: column.clone(),
617 limit: bound_limit,
618 limit_param: None,
619 }));
620 }
621
622 if let QueryExpr::SearchCommand(SearchCommand::Text {
623 query,
624 collection,
625 limit,
626 fuzzy,
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 TEXT LIMIT parameter (must be > 0)",
642 got: value_variant_name(value),
643 });
644 }
645 other => {
646 return Err(UserParamError::TypeMismatch {
647 slot: "SEARCH TEXT LIMIT parameter",
648 got: value_variant_name(other),
649 });
650 }
651 }
652 } else {
653 *limit
654 };
655 return Ok(QueryExpr::SearchCommand(SearchCommand::Text {
656 query: query.clone(),
657 collection: collection.clone(),
658 limit: bound_limit,
659 fuzzy: *fuzzy,
660 limit_param: None,
661 }));
662 }
663
664 if let QueryExpr::SearchCommand(SearchCommand::Multimodal {
665 query,
666 collection,
667 limit,
668 limit_param,
669 }) = expr
670 {
671 let bound_limit = if let Some(idx) = limit_param {
672 let value = params.get(*idx).ok_or(UserParamError::Arity {
673 expected: idx + 1,
674 got: params.len(),
675 })?;
676 match value {
677 Value::Integer(n) if *n > 0 => *n as usize,
678 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
679 Value::BigInt(n) if *n > 0 => *n as usize,
680 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
681 return Err(UserParamError::TypeMismatch {
682 slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
683 got: value_variant_name(value),
684 });
685 }
686 other => {
687 return Err(UserParamError::TypeMismatch {
688 slot: "SEARCH MULTIMODAL LIMIT parameter",
689 got: value_variant_name(other),
690 });
691 }
692 }
693 } else {
694 *limit
695 };
696 return Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
697 query: query.clone(),
698 collection: collection.clone(),
699 limit: bound_limit,
700 limit_param: None,
701 }));
702 }
703
704 if let QueryExpr::SearchCommand(SearchCommand::Index {
705 index,
706 value,
707 collection,
708 limit,
709 exact,
710 limit_param,
711 }) = expr
712 {
713 let bound_limit = if let Some(idx) = limit_param {
714 let value = params.get(*idx).ok_or(UserParamError::Arity {
715 expected: idx + 1,
716 got: params.len(),
717 })?;
718 match value {
719 Value::Integer(n) if *n > 0 => *n as usize,
720 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
721 Value::BigInt(n) if *n > 0 => *n as usize,
722 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
723 return Err(UserParamError::TypeMismatch {
724 slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
725 got: value_variant_name(value),
726 });
727 }
728 other => {
729 return Err(UserParamError::TypeMismatch {
730 slot: "SEARCH INDEX LIMIT parameter",
731 got: value_variant_name(other),
732 });
733 }
734 }
735 } else {
736 *limit
737 };
738 return Ok(QueryExpr::SearchCommand(SearchCommand::Index {
739 index: index.clone(),
740 value: value.clone(),
741 collection: collection.clone(),
742 limit: bound_limit,
743 exact: *exact,
744 limit_param: None,
745 }));
746 }
747
748 if let QueryExpr::SearchCommand(SearchCommand::Context {
749 query,
750 field,
751 collection,
752 limit,
753 depth,
754 limit_param,
755 }) = expr
756 {
757 let bound_limit = if let Some(idx) = limit_param {
758 let value = params.get(*idx).ok_or(UserParamError::Arity {
759 expected: idx + 1,
760 got: params.len(),
761 })?;
762 match value {
763 Value::Integer(n) if *n > 0 => *n as usize,
764 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
765 Value::BigInt(n) if *n > 0 => *n as usize,
766 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
767 return Err(UserParamError::TypeMismatch {
768 slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
769 got: value_variant_name(value),
770 });
771 }
772 other => {
773 return Err(UserParamError::TypeMismatch {
774 slot: "SEARCH CONTEXT LIMIT parameter",
775 got: value_variant_name(other),
776 });
777 }
778 }
779 } else {
780 *limit
781 };
782 return Ok(QueryExpr::SearchCommand(SearchCommand::Context {
783 query: query.clone(),
784 field: field.clone(),
785 collection: collection.clone(),
786 limit: bound_limit,
787 depth: *depth,
788 limit_param: None,
789 }));
790 }
791
792 if let QueryExpr::Insert(insert) = expr {
793 let mut bound = insert.clone();
794 let mut new_values: Vec<Vec<Value>> = Vec::with_capacity(bound.value_exprs.len());
795 let new_exprs = bound
796 .value_exprs
797 .into_iter()
798 .map(|row| {
799 row.into_iter()
800 .map(|e| substitute_params_in_expr(e, params))
801 .collect::<Result<Vec<_>, _>>()
802 })
803 .collect::<Result<Vec<_>, _>>()?;
804 for row in &new_exprs {
805 let folded = row
806 .iter()
807 .cloned()
808 .map(fold_expr_to_value)
809 .collect::<Result<Vec<_>, _>>()
810 .map_err(|_| UserParamError::UnsupportedShape)?;
811 new_values.push(folded);
812 }
813 bound.value_exprs = new_exprs;
814 bound.values = new_values;
815 return Ok(QueryExpr::Insert(bound));
816 }
817
818 bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
819}
820
821fn value_variant_name(value: &Value) -> &'static str {
822 match value {
823 Value::Null => "null",
824 Value::Integer(_) => "integer",
825 Value::UnsignedInteger(_) => "unsigned integer",
826 Value::BigInt(_) => "bigint",
827 Value::Float(_) => "float",
828 Value::Text(_) => "text",
829 Value::Boolean(_) => "boolean",
830 Value::Vector(_) => "vector",
831 Value::Json(_) => "json",
832 Value::Blob(_) => "bytes",
833 _ => "other",
834 }
835}
836
837fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
838 match expr {
839 QueryExpr::Table(q) => {
840 for item in &q.select_items {
841 if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
842 visit_expr(expr, visit);
843 }
844 }
845 if let Some(e) = &q.where_expr {
846 visit_expr(e, visit);
847 }
848 for e in &q.group_by_exprs {
849 visit_expr(e, visit);
850 }
851 if let Some(e) = &q.having_expr {
852 visit_expr(e, visit);
853 }
854 for clause in &q.order_by {
855 if let Some(e) = &clause.expr {
856 visit_expr(e, visit);
857 }
858 }
859 if let Some(crate::storage::query::ast::TableSource::Subquery(inner)) = &q.source {
860 visit_query_expr(inner, visit);
861 }
862 }
863 QueryExpr::Join(q) => {
864 visit_query_expr(&q.left, visit);
865 visit_query_expr(&q.right, visit);
866 }
867 QueryExpr::Hybrid(q) => {
868 visit_query_expr(&q.structured, visit);
869 }
870 QueryExpr::Insert(q) => {
871 for row in &q.value_exprs {
872 for e in row {
873 visit_expr(e, visit);
874 }
875 }
876 }
877 _ => {}
879 }
880}
881
882fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
883 visit(expr);
884 match expr {
885 Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
886 Expr::BinaryOp { lhs, rhs, .. } => {
887 visit_expr(lhs, visit);
888 visit_expr(rhs, visit);
889 }
890 Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
891 Expr::Cast { inner, .. } => visit_expr(inner, visit),
892 Expr::FunctionCall { args, .. } => {
893 for a in args {
894 visit_expr(a, visit);
895 }
896 }
897 Expr::Case {
898 branches, else_, ..
899 } => {
900 for (c, v) in branches {
901 visit_expr(c, visit);
902 visit_expr(v, visit);
903 }
904 if let Some(e) = else_ {
905 visit_expr(e, visit);
906 }
907 }
908 Expr::IsNull { operand, .. } => visit_expr(operand, visit),
909 Expr::InList { target, values, .. } => {
910 visit_expr(target, visit);
911 for v in values {
912 visit_expr(v, visit);
913 }
914 }
915 Expr::Between {
916 target, low, high, ..
917 } => {
918 visit_expr(target, visit);
919 visit_expr(low, visit);
920 visit_expr(high, visit);
921 }
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use crate::storage::query::modes::parse_multi;
929
930 fn parse(sql: &str) -> QueryExpr {
931 parse_multi(sql).expect("parse")
932 }
933
934 #[test]
935 fn collect_indices_select_where() {
936 let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
937 let mut ix = collect_indices(&q);
938 ix.sort();
939 assert_eq!(ix, vec![0, 1]);
940 }
941
942 #[test]
943 fn validate_ok() {
944 assert!(validate(&[0, 1], 2).is_ok());
945 assert!(validate(&[0, 1, 0], 2).is_ok());
946 assert!(validate(&[], 0).is_ok());
947 }
948
949 #[test]
950 fn validate_arity_too_few() {
951 let err = validate(&[0, 1], 1).unwrap_err();
952 assert!(matches!(
953 err,
954 UserParamError::Arity {
955 expected: 2,
956 got: 1
957 }
958 ));
959 }
960
961 #[test]
962 fn validate_arity_too_many() {
963 let err = validate(&[0], 3).unwrap_err();
964 assert!(matches!(
965 err,
966 UserParamError::Arity {
967 expected: 1,
968 got: 3
969 }
970 ));
971 }
972
973 #[test]
974 fn validate_gap() {
975 let err = validate(&[0, 2], 3).unwrap_err();
977 assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
978 }
979
980 #[test]
981 fn bind_substitutes_int_param() {
982 let q = parse("SELECT * FROM users WHERE id = $1");
983 let bound = bind(&q, &[Value::Integer(42)]).unwrap();
984 let QueryExpr::Table(t) = bound else {
985 panic!("expected Table");
986 };
987 let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
988 panic!("expected BinaryOp");
989 };
990 assert!(matches!(
991 *rhs,
992 Expr::Literal {
993 value: Value::Integer(42),
994 ..
995 }
996 ));
997 }
998
999 #[test]
1000 fn bind_substitutes_text_and_null() {
1001 let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1002 let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1003 let QueryExpr::Table(t) = bound else {
1004 panic!("expected Table");
1005 };
1006 let mut literals: Vec<Value> = Vec::new();
1007 visit_expr(&t.where_expr.unwrap(), &mut |e| {
1008 if let Expr::Literal { value, .. } = e {
1009 literals.push(value.clone());
1010 }
1011 });
1012 assert!(literals
1013 .iter()
1014 .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1015 assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1016 }
1017
1018 #[test]
1019 fn bind_search_similar_vector_param() {
1020 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1023 let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1024 let QueryExpr::SearchCommand(SearchCommand::Similar {
1025 vector,
1026 vector_param,
1027 collection,
1028 limit,
1029 ..
1030 }) = bound
1031 else {
1032 panic!("expected SearchCommand::Similar");
1033 };
1034 assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1035 assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1036 assert_eq!(collection, "embeddings");
1037 assert_eq!(limit, 5);
1038 }
1039
1040 #[test]
1041 fn bind_search_similar_limit_param() {
1042 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1044 let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1045 let QueryExpr::SearchCommand(SearchCommand::Similar {
1046 limit,
1047 limit_param,
1048 min_score_param,
1049 ..
1050 }) = bound
1051 else {
1052 panic!("expected SearchCommand::Similar");
1053 };
1054 assert_eq!(limit, 25);
1055 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1056 assert_eq!(min_score_param, None);
1057 }
1058
1059 #[test]
1060 fn bind_search_similar_min_score_param() {
1061 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1062 let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1063 let QueryExpr::SearchCommand(SearchCommand::Similar {
1064 min_score,
1065 min_score_param,
1066 ..
1067 }) = bound
1068 else {
1069 panic!("expected SearchCommand::Similar");
1070 };
1071 assert!((min_score - 0.42_f32).abs() < 1e-6);
1072 assert_eq!(min_score_param, None);
1073 }
1074
1075 #[test]
1076 fn bind_search_similar_limit_and_min_score_together() {
1077 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1078 let bound = bind(
1079 &q,
1080 &[
1081 Value::Vector(vec![0.1, 0.2]),
1082 Value::Integer(7),
1083 Value::Float(0.9),
1084 ],
1085 )
1086 .unwrap();
1087 let QueryExpr::SearchCommand(SearchCommand::Similar {
1088 limit,
1089 min_score,
1090 vector,
1091 vector_param,
1092 limit_param,
1093 min_score_param,
1094 ..
1095 }) = bound
1096 else {
1097 panic!("expected SearchCommand::Similar");
1098 };
1099 assert_eq!(vector, vec![0.1_f32, 0.2]);
1100 assert_eq!(limit, 7);
1101 assert!((min_score - 0.9_f32).abs() < 1e-6);
1102 assert_eq!(vector_param, None);
1103 assert_eq!(limit_param, None);
1104 assert_eq!(min_score_param, None);
1105 }
1106
1107 #[test]
1108 fn bind_search_similar_limit_rejects_non_integer() {
1109 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1110 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1111 assert!(
1112 matches!(
1113 err,
1114 UserParamError::TypeMismatch {
1115 slot: "SEARCH SIMILAR LIMIT parameter",
1116 got: "text"
1117 }
1118 ),
1119 "got {err:?}"
1120 );
1121 }
1122
1123 #[test]
1124 fn bind_search_similar_limit_rejects_zero_or_negative() {
1125 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1126 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1127 assert!(matches!(
1128 err,
1129 UserParamError::TypeMismatch {
1130 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1131 ..
1132 }
1133 ));
1134 let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1135 assert!(matches!(
1136 err,
1137 UserParamError::TypeMismatch {
1138 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1139 ..
1140 }
1141 ));
1142 }
1143
1144 #[test]
1145 fn bind_search_similar_min_score_rejects_non_numeric() {
1146 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1147 let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1148 assert!(matches!(
1149 err,
1150 UserParamError::TypeMismatch {
1151 slot: "SEARCH SIMILAR MIN_SCORE parameter",
1152 got: "vector"
1153 }
1154 ));
1155 }
1156
1157 #[test]
1164 fn bind_search_similar_rejects_non_vector_param() {
1165 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1166 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1167 assert!(
1168 matches!(
1169 err,
1170 UserParamError::TypeMismatch {
1171 slot: "SEARCH SIMILAR vector parameter",
1172 got: "integer"
1173 }
1174 ),
1175 "got {err:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn bind_search_similar_empty_vector_param() {
1181 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1182 let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1183 let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1184 panic!("expected SearchCommand::Similar");
1185 };
1186 assert!(vector.is_empty());
1187 }
1188
1189 #[test]
1190 fn bind_search_hybrid_limit_param() {
1191 let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1193 let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1194 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1195 limit, limit_param, ..
1196 }) = bound
1197 else {
1198 panic!("expected SearchCommand::Hybrid");
1199 };
1200 assert_eq!(limit, 30);
1201 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1202 }
1203
1204 #[test]
1205 fn bind_search_hybrid_k_param() {
1206 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1208 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1209 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1210 limit, limit_param, ..
1211 }) = bound
1212 else {
1213 panic!("expected SearchCommand::Hybrid");
1214 };
1215 assert_eq!(limit, 7);
1216 assert_eq!(limit_param, None);
1217 }
1218
1219 #[test]
1220 fn bind_search_hybrid_limit_rejects_non_integer() {
1221 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1222 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1223 assert!(
1224 matches!(
1225 err,
1226 UserParamError::TypeMismatch {
1227 slot: "SEARCH HYBRID LIMIT parameter",
1228 got: "text"
1229 }
1230 ),
1231 "got {err:?}"
1232 );
1233 }
1234
1235 #[test]
1236 fn bind_search_hybrid_limit_rejects_zero() {
1237 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1238 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1239 assert!(matches!(
1240 err,
1241 UserParamError::TypeMismatch {
1242 slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1243 ..
1244 }
1245 ));
1246 }
1247
1248 #[test]
1249 fn bind_search_spatial_nearest_k_param() {
1250 let q =
1252 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1253 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1254 let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1255 else {
1256 panic!("expected SpatialNearest");
1257 };
1258 assert_eq!(k, 7);
1259 assert_eq!(k_param, None, "k_param must be cleared post-bind");
1260 }
1261
1262 #[test]
1263 fn bind_search_spatial_nearest_k_rejects_zero() {
1264 let q =
1265 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1266 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1267 assert!(matches!(
1268 err,
1269 UserParamError::TypeMismatch {
1270 slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1271 ..
1272 }
1273 ));
1274 }
1275
1276 #[test]
1277 fn bind_search_spatial_nearest_k_rejects_non_integer() {
1278 let q =
1279 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1280 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1281 assert!(matches!(
1282 err,
1283 UserParamError::TypeMismatch {
1284 slot: "SEARCH SPATIAL NEAREST K parameter",
1285 got: "text"
1286 }
1287 ));
1288 }
1289
1290 #[test]
1291 fn bind_search_text_limit_param() {
1292 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1294 let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1295 let QueryExpr::SearchCommand(SearchCommand::Text {
1296 limit, limit_param, ..
1297 }) = bound
1298 else {
1299 panic!("expected SearchCommand::Text");
1300 };
1301 assert_eq!(limit, 15);
1302 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1303 }
1304
1305 #[test]
1306 fn bind_search_text_limit_rejects_zero() {
1307 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1308 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1309 assert!(matches!(
1310 err,
1311 UserParamError::TypeMismatch {
1312 slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1313 ..
1314 }
1315 ));
1316 }
1317
1318 #[test]
1319 fn bind_search_text_limit_rejects_non_integer() {
1320 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1321 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1322 assert!(matches!(
1323 err,
1324 UserParamError::TypeMismatch {
1325 slot: "SEARCH TEXT LIMIT parameter",
1326 got: "text"
1327 }
1328 ));
1329 }
1330
1331 #[test]
1332 fn bind_search_multimodal_limit_param() {
1333 let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1335 let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1336 let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1337 limit, limit_param, ..
1338 }) = bound
1339 else {
1340 panic!("expected SearchCommand::Multimodal");
1341 };
1342 assert_eq!(limit, 40);
1343 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1344 }
1345
1346 #[test]
1347 fn bind_search_multimodal_limit_rejects_zero() {
1348 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1349 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1350 assert!(matches!(
1351 err,
1352 UserParamError::TypeMismatch {
1353 slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1354 ..
1355 }
1356 ));
1357 }
1358
1359 #[test]
1360 fn bind_search_multimodal_limit_rejects_non_integer() {
1361 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1362 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1363 assert!(matches!(
1364 err,
1365 UserParamError::TypeMismatch {
1366 slot: "SEARCH MULTIMODAL LIMIT parameter",
1367 got: "text"
1368 }
1369 ));
1370 }
1371
1372 #[test]
1373 fn bind_search_index_limit_param() {
1374 let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1376 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1377 let QueryExpr::SearchCommand(SearchCommand::Index {
1378 limit, limit_param, ..
1379 }) = bound
1380 else {
1381 panic!("expected SearchCommand::Index");
1382 };
1383 assert_eq!(limit, 50);
1384 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1385 }
1386
1387 #[test]
1388 fn bind_search_index_limit_rejects_zero() {
1389 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1390 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1391 assert!(matches!(
1392 err,
1393 UserParamError::TypeMismatch {
1394 slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1395 ..
1396 }
1397 ));
1398 }
1399
1400 #[test]
1401 fn bind_search_index_limit_rejects_non_integer() {
1402 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1403 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1404 assert!(matches!(
1405 err,
1406 UserParamError::TypeMismatch {
1407 slot: "SEARCH INDEX LIMIT parameter",
1408 got: "text"
1409 }
1410 ));
1411 }
1412
1413 #[test]
1414 fn bind_search_context_limit_param() {
1415 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1417 let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1418 let QueryExpr::SearchCommand(SearchCommand::Context {
1419 limit, limit_param, ..
1420 }) = bound
1421 else {
1422 panic!("expected SearchCommand::Context");
1423 };
1424 assert_eq!(limit, 60);
1425 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1426 }
1427
1428 #[test]
1429 fn bind_search_context_limit_rejects_zero() {
1430 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1431 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1432 assert!(matches!(
1433 err,
1434 UserParamError::TypeMismatch {
1435 slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1436 ..
1437 }
1438 ));
1439 }
1440
1441 #[test]
1442 fn bind_search_context_limit_rejects_non_integer() {
1443 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1444 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1445 assert!(matches!(
1446 err,
1447 UserParamError::TypeMismatch {
1448 slot: "SEARCH CONTEXT LIMIT parameter",
1449 got: "text"
1450 }
1451 ));
1452 }
1453
1454 #[test]
1455 fn bind_search_spatial_radius_limit_param() {
1456 let q = parse(
1458 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1459 );
1460 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1461 let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1462 limit, limit_param, ..
1463 }) = bound
1464 else {
1465 panic!("expected SearchCommand::SpatialRadius");
1466 };
1467 assert_eq!(limit, 50);
1468 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1469 }
1470
1471 #[test]
1472 fn bind_search_spatial_radius_limit_rejects_zero() {
1473 let q = parse(
1474 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1475 );
1476 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1477 assert!(matches!(
1478 err,
1479 UserParamError::TypeMismatch {
1480 slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1481 ..
1482 }
1483 ));
1484 }
1485
1486 #[test]
1487 fn bind_search_spatial_radius_limit_rejects_non_integer() {
1488 let q = parse(
1489 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1490 );
1491 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1492 assert!(matches!(
1493 err,
1494 UserParamError::TypeMismatch {
1495 slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
1496 got: "text"
1497 }
1498 ));
1499 }
1500
1501 #[test]
1502 fn bind_search_spatial_bbox_limit_param() {
1503 let q =
1505 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1506 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1507 let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
1508 limit, limit_param, ..
1509 }) = bound
1510 else {
1511 panic!("expected SearchCommand::SpatialBbox");
1512 };
1513 assert_eq!(limit, 50);
1514 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1515 }
1516
1517 #[test]
1518 fn bind_search_spatial_bbox_limit_rejects_zero() {
1519 let q =
1520 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1521 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1522 assert!(matches!(
1523 err,
1524 UserParamError::TypeMismatch {
1525 slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
1526 ..
1527 }
1528 ));
1529 }
1530
1531 #[test]
1532 fn bind_search_spatial_bbox_limit_rejects_non_integer() {
1533 let q =
1534 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1535 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1536 assert!(matches!(
1537 err,
1538 UserParamError::TypeMismatch {
1539 slot: "SEARCH SPATIAL BBOX LIMIT parameter",
1540 got: "text"
1541 }
1542 ));
1543 }
1544
1545 #[test]
1546 fn bind_search_similar_text_param() {
1547 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
1550 let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
1551 let QueryExpr::SearchCommand(SearchCommand::Similar {
1552 vector,
1553 text,
1554 text_param,
1555 collection,
1556 limit,
1557 provider,
1558 ..
1559 }) = bound
1560 else {
1561 panic!("expected SearchCommand::Similar");
1562 };
1563 assert!(vector.is_empty());
1564 assert_eq!(text.as_deref(), Some("find vulnerabilities"));
1565 assert_eq!(text_param, None, "text_param must be cleared post-bind");
1566 assert_eq!(collection, "docs");
1567 assert_eq!(limit, 5);
1568 assert_eq!(provider.as_deref(), Some("openai"));
1569 }
1570
1571 #[test]
1572 fn bind_search_similar_text_rejects_non_text() {
1573 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
1574 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1575 assert!(
1576 matches!(
1577 err,
1578 UserParamError::TypeMismatch {
1579 slot: "SEARCH SIMILAR TEXT parameter",
1580 got: "integer"
1581 }
1582 ),
1583 "got {err:?}"
1584 );
1585 }
1586
1587 #[test]
1588 fn bind_search_similar_text_with_limit_param() {
1589 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
1592 let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
1593 let QueryExpr::SearchCommand(SearchCommand::Similar {
1594 text,
1595 text_param,
1596 limit,
1597 limit_param,
1598 ..
1599 }) = bound
1600 else {
1601 panic!("expected SearchCommand::Similar");
1602 };
1603 assert_eq!(text.as_deref(), Some("hello"));
1604 assert_eq!(text_param, None);
1605 assert_eq!(limit, 11);
1606 assert_eq!(limit_param, None);
1607 }
1608
1609 #[test]
1610 fn bind_insert_values_with_vector_param() {
1611 let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
1614 let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
1615 let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
1616 let QueryExpr::Insert(insert) = bound else {
1617 panic!("expected Insert");
1618 };
1619 assert_eq!(insert.values.len(), 1);
1620 assert_eq!(insert.values[0].len(), 2);
1621 assert!(
1622 matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
1623 );
1624 assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
1625 let row0 = &insert.value_exprs[0];
1627 assert!(matches!(
1628 &row0[0],
1629 Expr::Literal {
1630 value: Value::Vector(_),
1631 ..
1632 }
1633 ));
1634 }
1635
1636 #[test]
1637 fn bind_insert_arity_mismatch() {
1638 let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
1639 let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
1640 assert!(matches!(
1641 err,
1642 UserParamError::Arity {
1643 expected: 2,
1644 got: 1
1645 }
1646 ));
1647 }
1648
1649 #[test]
1650 fn bind_no_params_is_noop() {
1651 let q = parse("SELECT * FROM users");
1652 let bound = bind(&q, &[]).unwrap();
1653 assert!(matches!(bound, QueryExpr::Table(_)));
1654 }
1655}