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
222#[allow(clippy::collapsible_match)]
230fn collect_non_expr_indices(expr: &QueryExpr, out: &mut Vec<usize>) {
231 match expr {
232 QueryExpr::SearchCommand(SearchCommand::Similar {
233 vector_param,
234 limit_param,
235 min_score_param,
236 text_param,
237 ..
238 }) => {
239 if let Some(idx) = vector_param {
240 out.push(*idx);
241 }
242 if let Some(idx) = limit_param {
243 out.push(*idx);
244 }
245 if let Some(idx) = min_score_param {
246 out.push(*idx);
247 }
248 if let Some(idx) = text_param {
249 out.push(*idx);
250 }
251 }
252 QueryExpr::SearchCommand(SearchCommand::Hybrid { limit_param, .. }) => {
253 if let Some(idx) = limit_param {
254 out.push(*idx);
255 }
256 }
257 QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k_param, .. }) => {
258 if let Some(idx) = k_param {
259 out.push(*idx);
260 }
261 }
262 QueryExpr::SearchCommand(SearchCommand::SpatialRadius { limit_param, .. }) => {
263 if let Some(idx) = limit_param {
264 out.push(*idx);
265 }
266 }
267 QueryExpr::SearchCommand(SearchCommand::SpatialBbox { limit_param, .. }) => {
268 if let Some(idx) = limit_param {
269 out.push(*idx);
270 }
271 }
272 QueryExpr::SearchCommand(SearchCommand::Text { limit_param, .. }) => {
273 if let Some(idx) = limit_param {
274 out.push(*idx);
275 }
276 }
277 QueryExpr::SearchCommand(SearchCommand::Multimodal { limit_param, .. }) => {
278 if let Some(idx) = limit_param {
279 out.push(*idx);
280 }
281 }
282 QueryExpr::SearchCommand(SearchCommand::Index { limit_param, .. }) => {
283 if let Some(idx) = limit_param {
284 out.push(*idx);
285 }
286 }
287 QueryExpr::SearchCommand(SearchCommand::Context { limit_param, .. }) => {
288 if let Some(idx) = limit_param {
289 out.push(*idx);
290 }
291 }
292 QueryExpr::Table(q) => {
293 if let Some(idx) = q.limit_param {
294 out.push(idx);
295 }
296 if let Some(idx) = q.offset_param {
297 out.push(idx);
298 }
299 }
300 _ => {}
301 }
302}
303
304pub fn validate(indices: &[usize], param_count: usize) -> Result<(), UserParamError> {
307 let max_used = indices.iter().copied().max();
308
309 let expected = match max_used {
310 Some(m) => m + 1,
311 None => 0,
312 };
313
314 if expected != param_count {
315 return Err(UserParamError::Arity {
316 expected,
317 got: param_count,
318 });
319 }
320
321 if let Some(max) = max_used {
322 let mut seen = vec![false; max + 1];
323 for &i in indices {
324 seen[i] = true;
325 }
326 for (i, used) in seen.iter().enumerate() {
327 if !used {
328 return Err(UserParamError::Gap {
329 missing: i + 1,
330 max: max + 1,
331 });
332 }
333 }
334 }
335
336 Ok(())
337}
338
339pub fn bind(expr: &QueryExpr, params: &[Value]) -> Result<QueryExpr, UserParamError> {
341 let indices = collect_indices(expr);
342 validate(&indices, params.len())?;
343
344 if indices.is_empty() {
345 return Ok(expr.clone());
346 }
347
348 if let QueryExpr::SearchCommand(SearchCommand::Similar {
352 vector,
353 text,
354 provider,
355 collection,
356 limit,
357 min_score,
358 vector_param,
359 limit_param,
360 min_score_param,
361 text_param,
362 }) = expr
363 {
364 let mut bound_vector = vector.clone();
365 if let Some(idx) = vector_param {
366 let value = params.get(*idx).ok_or(UserParamError::Arity {
367 expected: idx + 1,
368 got: params.len(),
369 })?;
370 bound_vector = match value {
371 Value::Vector(v) => v.clone(),
372 other => {
373 return Err(UserParamError::TypeMismatch {
374 slot: "SEARCH SIMILAR vector parameter",
375 got: value_variant_name(other),
376 });
377 }
378 };
379 }
380 let bound_limit = if let Some(idx) = limit_param {
381 let value = params.get(*idx).ok_or(UserParamError::Arity {
382 expected: idx + 1,
383 got: params.len(),
384 })?;
385 match value {
386 Value::Integer(n) if *n > 0 => *n as usize,
387 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
388 Value::BigInt(n) if *n > 0 => *n as usize,
389 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
390 return Err(UserParamError::TypeMismatch {
391 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
392 got: value_variant_name(value),
393 });
394 }
395 other => {
396 return Err(UserParamError::TypeMismatch {
397 slot: "SEARCH SIMILAR LIMIT parameter",
398 got: value_variant_name(other),
399 });
400 }
401 }
402 } else {
403 *limit
404 };
405 let bound_min_score = if let Some(idx) = min_score_param {
406 let value = params.get(*idx).ok_or(UserParamError::Arity {
407 expected: idx + 1,
408 got: params.len(),
409 })?;
410 match value {
411 Value::Float(f) => *f as f32,
412 Value::Integer(n) => *n as f32,
413 Value::UnsignedInteger(n) => *n as f32,
414 Value::BigInt(n) => *n as f32,
415 other => {
416 return Err(UserParamError::TypeMismatch {
417 slot: "SEARCH SIMILAR MIN_SCORE parameter",
418 got: value_variant_name(other),
419 });
420 }
421 }
422 } else {
423 *min_score
424 };
425 let bound_text = if let Some(idx) = text_param {
426 let value = params.get(*idx).ok_or(UserParamError::Arity {
427 expected: idx + 1,
428 got: params.len(),
429 })?;
430 match value {
431 Value::Text(s) => Some(s.to_string()),
432 other => {
433 return Err(UserParamError::TypeMismatch {
434 slot: "SEARCH SIMILAR TEXT parameter",
435 got: value_variant_name(other),
436 });
437 }
438 }
439 } else {
440 text.clone()
441 };
442 return Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
443 vector: bound_vector,
444 text: bound_text,
445 provider: provider.clone(),
446 collection: collection.clone(),
447 limit: bound_limit,
448 min_score: bound_min_score,
449 vector_param: None,
450 limit_param: None,
451 min_score_param: None,
452 text_param: None,
453 }));
454 }
455
456 if let QueryExpr::SearchCommand(SearchCommand::Hybrid {
457 vector,
458 query,
459 collection,
460 limit,
461 limit_param,
462 }) = expr
463 {
464 let bound_limit = if let Some(idx) = limit_param {
465 let value = params.get(*idx).ok_or(UserParamError::Arity {
466 expected: idx + 1,
467 got: params.len(),
468 })?;
469 match value {
470 Value::Integer(n) if *n > 0 => *n as usize,
471 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
472 Value::BigInt(n) if *n > 0 => *n as usize,
473 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
474 return Err(UserParamError::TypeMismatch {
475 slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
476 got: value_variant_name(value),
477 });
478 }
479 other => {
480 return Err(UserParamError::TypeMismatch {
481 slot: "SEARCH HYBRID LIMIT parameter",
482 got: value_variant_name(other),
483 });
484 }
485 }
486 } else {
487 *limit
488 };
489 return Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
490 vector: vector.clone(),
491 query: query.clone(),
492 collection: collection.clone(),
493 limit: bound_limit,
494 limit_param: None,
495 }));
496 }
497
498 if let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
499 lat,
500 lon,
501 k,
502 collection,
503 column,
504 k_param,
505 }) = expr
506 {
507 let bound_k = if let Some(idx) = k_param {
508 let value = params.get(*idx).ok_or(UserParamError::Arity {
509 expected: idx + 1,
510 got: params.len(),
511 })?;
512 match value {
513 Value::Integer(n) if *n > 0 => *n as usize,
514 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
515 Value::BigInt(n) if *n > 0 => *n as usize,
516 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
517 return Err(UserParamError::TypeMismatch {
518 slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
519 got: value_variant_name(value),
520 });
521 }
522 other => {
523 return Err(UserParamError::TypeMismatch {
524 slot: "SEARCH SPATIAL NEAREST K parameter",
525 got: value_variant_name(other),
526 });
527 }
528 }
529 } else {
530 *k
531 };
532 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
533 lat: *lat,
534 lon: *lon,
535 k: bound_k,
536 collection: collection.clone(),
537 column: column.clone(),
538 k_param: None,
539 }));
540 }
541
542 if let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
543 center_lat,
544 center_lon,
545 radius_km,
546 collection,
547 column,
548 limit,
549 limit_param,
550 }) = expr
551 {
552 let bound_limit = if let Some(idx) = limit_param {
553 let value = params.get(*idx).ok_or(UserParamError::Arity {
554 expected: idx + 1,
555 got: params.len(),
556 })?;
557 match value {
558 Value::Integer(n) if *n > 0 => *n as usize,
559 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
560 Value::BigInt(n) if *n > 0 => *n as usize,
561 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
562 return Err(UserParamError::TypeMismatch {
563 slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
564 got: value_variant_name(value),
565 });
566 }
567 other => {
568 return Err(UserParamError::TypeMismatch {
569 slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
570 got: value_variant_name(other),
571 });
572 }
573 }
574 } else {
575 *limit
576 };
577 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
578 center_lat: *center_lat,
579 center_lon: *center_lon,
580 radius_km: *radius_km,
581 collection: collection.clone(),
582 column: column.clone(),
583 limit: bound_limit,
584 limit_param: None,
585 }));
586 }
587
588 if let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
589 min_lat,
590 min_lon,
591 max_lat,
592 max_lon,
593 collection,
594 column,
595 limit,
596 limit_param,
597 }) = expr
598 {
599 let bound_limit = if let Some(idx) = limit_param {
600 let value = params.get(*idx).ok_or(UserParamError::Arity {
601 expected: idx + 1,
602 got: params.len(),
603 })?;
604 match value {
605 Value::Integer(n) if *n > 0 => *n as usize,
606 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
607 Value::BigInt(n) if *n > 0 => *n as usize,
608 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
609 return Err(UserParamError::TypeMismatch {
610 slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
611 got: value_variant_name(value),
612 });
613 }
614 other => {
615 return Err(UserParamError::TypeMismatch {
616 slot: "SEARCH SPATIAL BBOX LIMIT parameter",
617 got: value_variant_name(other),
618 });
619 }
620 }
621 } else {
622 *limit
623 };
624 return Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
625 min_lat: *min_lat,
626 min_lon: *min_lon,
627 max_lat: *max_lat,
628 max_lon: *max_lon,
629 collection: collection.clone(),
630 column: column.clone(),
631 limit: bound_limit,
632 limit_param: None,
633 }));
634 }
635
636 if let QueryExpr::SearchCommand(SearchCommand::Text {
637 query,
638 collection,
639 limit,
640 fuzzy,
641 limit_param,
642 }) = expr
643 {
644 let bound_limit = if let Some(idx) = limit_param {
645 let value = params.get(*idx).ok_or(UserParamError::Arity {
646 expected: idx + 1,
647 got: params.len(),
648 })?;
649 match value {
650 Value::Integer(n) if *n > 0 => *n as usize,
651 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
652 Value::BigInt(n) if *n > 0 => *n as usize,
653 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
654 return Err(UserParamError::TypeMismatch {
655 slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
656 got: value_variant_name(value),
657 });
658 }
659 other => {
660 return Err(UserParamError::TypeMismatch {
661 slot: "SEARCH TEXT LIMIT parameter",
662 got: value_variant_name(other),
663 });
664 }
665 }
666 } else {
667 *limit
668 };
669 return Ok(QueryExpr::SearchCommand(SearchCommand::Text {
670 query: query.clone(),
671 collection: collection.clone(),
672 limit: bound_limit,
673 fuzzy: *fuzzy,
674 limit_param: None,
675 }));
676 }
677
678 if let QueryExpr::SearchCommand(SearchCommand::Multimodal {
679 query,
680 collection,
681 limit,
682 limit_param,
683 }) = expr
684 {
685 let bound_limit = if let Some(idx) = limit_param {
686 let value = params.get(*idx).ok_or(UserParamError::Arity {
687 expected: idx + 1,
688 got: params.len(),
689 })?;
690 match value {
691 Value::Integer(n) if *n > 0 => *n as usize,
692 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
693 Value::BigInt(n) if *n > 0 => *n as usize,
694 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
695 return Err(UserParamError::TypeMismatch {
696 slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
697 got: value_variant_name(value),
698 });
699 }
700 other => {
701 return Err(UserParamError::TypeMismatch {
702 slot: "SEARCH MULTIMODAL LIMIT parameter",
703 got: value_variant_name(other),
704 });
705 }
706 }
707 } else {
708 *limit
709 };
710 return Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
711 query: query.clone(),
712 collection: collection.clone(),
713 limit: bound_limit,
714 limit_param: None,
715 }));
716 }
717
718 if let QueryExpr::SearchCommand(SearchCommand::Index {
719 index,
720 value,
721 collection,
722 limit,
723 exact,
724 limit_param,
725 }) = expr
726 {
727 let bound_limit = if let Some(idx) = limit_param {
728 let value = params.get(*idx).ok_or(UserParamError::Arity {
729 expected: idx + 1,
730 got: params.len(),
731 })?;
732 match value {
733 Value::Integer(n) if *n > 0 => *n as usize,
734 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
735 Value::BigInt(n) if *n > 0 => *n as usize,
736 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
737 return Err(UserParamError::TypeMismatch {
738 slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
739 got: value_variant_name(value),
740 });
741 }
742 other => {
743 return Err(UserParamError::TypeMismatch {
744 slot: "SEARCH INDEX LIMIT parameter",
745 got: value_variant_name(other),
746 });
747 }
748 }
749 } else {
750 *limit
751 };
752 return Ok(QueryExpr::SearchCommand(SearchCommand::Index {
753 index: index.clone(),
754 value: value.clone(),
755 collection: collection.clone(),
756 limit: bound_limit,
757 exact: *exact,
758 limit_param: None,
759 }));
760 }
761
762 if let QueryExpr::SearchCommand(SearchCommand::Context {
763 query,
764 field,
765 collection,
766 limit,
767 depth,
768 limit_param,
769 }) = expr
770 {
771 let bound_limit = if let Some(idx) = limit_param {
772 let value = params.get(*idx).ok_or(UserParamError::Arity {
773 expected: idx + 1,
774 got: params.len(),
775 })?;
776 match value {
777 Value::Integer(n) if *n > 0 => *n as usize,
778 Value::UnsignedInteger(n) if *n > 0 => *n as usize,
779 Value::BigInt(n) if *n > 0 => *n as usize,
780 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
781 return Err(UserParamError::TypeMismatch {
782 slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
783 got: value_variant_name(value),
784 });
785 }
786 other => {
787 return Err(UserParamError::TypeMismatch {
788 slot: "SEARCH CONTEXT LIMIT parameter",
789 got: value_variant_name(other),
790 });
791 }
792 }
793 } else {
794 *limit
795 };
796 return Ok(QueryExpr::SearchCommand(SearchCommand::Context {
797 query: query.clone(),
798 field: field.clone(),
799 collection: collection.clone(),
800 limit: bound_limit,
801 depth: *depth,
802 limit_param: None,
803 }));
804 }
805
806 if let QueryExpr::Insert(insert) = expr {
807 let mut bound = insert.clone();
808 let mut new_values: Vec<Vec<Value>> = Vec::with_capacity(bound.value_exprs.len());
809 let new_exprs = bound
810 .value_exprs
811 .into_iter()
812 .map(|row| {
813 row.into_iter()
814 .map(|e| substitute_params_in_expr(e, params))
815 .collect::<Result<Vec<_>, _>>()
816 })
817 .collect::<Result<Vec<_>, _>>()?;
818 for row in &new_exprs {
819 let folded = row
820 .iter()
821 .cloned()
822 .map(fold_expr_to_value)
823 .collect::<Result<Vec<_>, _>>()
824 .map_err(|_| UserParamError::UnsupportedShape)?;
825 new_values.push(folded);
826 }
827 bound.value_exprs = new_exprs;
828 bound.values = new_values;
829 return Ok(QueryExpr::Insert(bound));
830 }
831
832 if let QueryExpr::Table(table) = expr {
837 if table.limit_param.is_some() || table.offset_param.is_some() {
838 let bound_inner =
839 bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)?;
840 let mut bound_table = match bound_inner {
841 QueryExpr::Table(t) => t,
842 _ => return Err(UserParamError::UnsupportedShape),
843 };
844 if let Some(idx) = table.limit_param {
845 let value = params.get(idx).ok_or(UserParamError::Arity {
846 expected: idx + 1,
847 got: params.len(),
848 })?;
849 let n = match value {
850 Value::Integer(n) if *n > 0 => *n as u64,
851 Value::UnsignedInteger(n) if *n > 0 => *n,
852 Value::BigInt(n) if *n > 0 => *n as u64,
853 Value::Integer(_) | Value::UnsignedInteger(_) | Value::BigInt(_) => {
854 return Err(UserParamError::TypeMismatch {
855 slot: "SELECT LIMIT parameter (must be > 0)",
856 got: value_variant_name(value),
857 });
858 }
859 other => {
860 return Err(UserParamError::TypeMismatch {
861 slot: "SELECT LIMIT parameter",
862 got: value_variant_name(other),
863 });
864 }
865 };
866 bound_table.limit = Some(n);
867 bound_table.limit_param = None;
868 }
869 if let Some(idx) = table.offset_param {
870 let value = params.get(idx).ok_or(UserParamError::Arity {
871 expected: idx + 1,
872 got: params.len(),
873 })?;
874 let n = match value {
875 Value::Integer(n) if *n >= 0 => *n as u64,
876 Value::UnsignedInteger(n) => *n,
877 Value::BigInt(n) if *n >= 0 => *n as u64,
878 Value::Integer(_) | Value::BigInt(_) => {
879 return Err(UserParamError::TypeMismatch {
880 slot: "SELECT OFFSET parameter (must be >= 0)",
881 got: value_variant_name(value),
882 });
883 }
884 other => {
885 return Err(UserParamError::TypeMismatch {
886 slot: "SELECT OFFSET parameter",
887 got: value_variant_name(other),
888 });
889 }
890 };
891 bound_table.offset = Some(n);
892 bound_table.offset_param = None;
893 }
894 return Ok(QueryExpr::Table(bound_table));
895 }
896 }
897
898 bind_user_param_query(expr, params).ok_or(UserParamError::UnsupportedShape)
899}
900
901fn value_variant_name(value: &Value) -> &'static str {
902 match value {
903 Value::Null => "null",
904 Value::Integer(_) => "integer",
905 Value::UnsignedInteger(_) => "unsigned integer",
906 Value::BigInt(_) => "bigint",
907 Value::Float(_) => "float",
908 Value::Text(_) => "text",
909 Value::Boolean(_) => "boolean",
910 Value::Vector(_) => "vector",
911 Value::Json(_) => "json",
912 Value::Blob(_) => "bytes",
913 _ => "other",
914 }
915}
916
917fn visit_query_expr<F: FnMut(&Expr)>(expr: &QueryExpr, visit: &mut F) {
918 match expr {
919 QueryExpr::Table(q) => {
920 for item in &q.select_items {
921 if let crate::storage::query::ast::SelectItem::Expr { expr, .. } = item {
922 visit_expr(expr, visit);
923 }
924 }
925 if let Some(e) = &q.where_expr {
926 visit_expr(e, visit);
927 }
928 for e in &q.group_by_exprs {
929 visit_expr(e, visit);
930 }
931 if let Some(e) = &q.having_expr {
932 visit_expr(e, visit);
933 }
934 for clause in &q.order_by {
935 if let Some(e) = &clause.expr {
936 visit_expr(e, visit);
937 }
938 }
939 if let Some(crate::storage::query::ast::TableSource::Subquery(inner)) = &q.source {
940 visit_query_expr(inner, visit);
941 }
942 }
943 QueryExpr::Join(q) => {
944 visit_query_expr(&q.left, visit);
945 visit_query_expr(&q.right, visit);
946 }
947 QueryExpr::Hybrid(q) => {
948 visit_query_expr(&q.structured, visit);
949 }
950 QueryExpr::Insert(q) => {
951 for row in &q.value_exprs {
952 for e in row {
953 visit_expr(e, visit);
954 }
955 }
956 }
957 _ => {}
959 }
960}
961
962fn visit_expr<F: FnMut(&Expr)>(expr: &Expr, visit: &mut F) {
963 visit(expr);
964 match expr {
965 Expr::Literal { .. } | Expr::Column { .. } | Expr::Parameter { .. } => {}
966 Expr::BinaryOp { lhs, rhs, .. } => {
967 visit_expr(lhs, visit);
968 visit_expr(rhs, visit);
969 }
970 Expr::UnaryOp { operand, .. } => visit_expr(operand, visit),
971 Expr::Cast { inner, .. } => visit_expr(inner, visit),
972 Expr::FunctionCall { args, .. } => {
973 for a in args {
974 visit_expr(a, visit);
975 }
976 }
977 Expr::Case {
978 branches, else_, ..
979 } => {
980 for (c, v) in branches {
981 visit_expr(c, visit);
982 visit_expr(v, visit);
983 }
984 if let Some(e) = else_ {
985 visit_expr(e, visit);
986 }
987 }
988 Expr::IsNull { operand, .. } => visit_expr(operand, visit),
989 Expr::InList { target, values, .. } => {
990 visit_expr(target, visit);
991 for v in values {
992 visit_expr(v, visit);
993 }
994 }
995 Expr::Between {
996 target, low, high, ..
997 } => {
998 visit_expr(target, visit);
999 visit_expr(low, visit);
1000 visit_expr(high, visit);
1001 }
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::storage::query::modes::parse_multi;
1009
1010 fn parse(sql: &str) -> QueryExpr {
1011 parse_multi(sql).expect("parse")
1012 }
1013
1014 #[test]
1015 fn collect_indices_select_where() {
1016 let q = parse("SELECT * FROM users WHERE id = $1 AND name = $2");
1017 let mut ix = collect_indices(&q);
1018 ix.sort();
1019 assert_eq!(ix, vec![0, 1]);
1020 }
1021
1022 #[test]
1023 fn validate_ok() {
1024 assert!(validate(&[0, 1], 2).is_ok());
1025 assert!(validate(&[0, 1, 0], 2).is_ok());
1026 assert!(validate(&[], 0).is_ok());
1027 }
1028
1029 #[test]
1030 fn validate_arity_too_few() {
1031 let err = validate(&[0, 1], 1).unwrap_err();
1032 assert!(matches!(
1033 err,
1034 UserParamError::Arity {
1035 expected: 2,
1036 got: 1
1037 }
1038 ));
1039 }
1040
1041 #[test]
1042 fn validate_arity_too_many() {
1043 let err = validate(&[0], 3).unwrap_err();
1044 assert!(matches!(
1045 err,
1046 UserParamError::Arity {
1047 expected: 1,
1048 got: 3
1049 }
1050 ));
1051 }
1052
1053 #[test]
1054 fn validate_gap() {
1055 let err = validate(&[0, 2], 3).unwrap_err();
1057 assert!(matches!(err, UserParamError::Gap { missing: 2, .. }));
1058 }
1059
1060 #[test]
1061 fn bind_substitutes_int_param() {
1062 let q = parse("SELECT * FROM users WHERE id = $1");
1063 let bound = bind(&q, &[Value::Integer(42)]).unwrap();
1064 let QueryExpr::Table(t) = bound else {
1065 panic!("expected Table");
1066 };
1067 let Expr::BinaryOp { rhs, .. } = t.where_expr.unwrap() else {
1068 panic!("expected BinaryOp");
1069 };
1070 assert!(matches!(
1071 *rhs,
1072 Expr::Literal {
1073 value: Value::Integer(42),
1074 ..
1075 }
1076 ));
1077 }
1078
1079 #[test]
1080 fn bind_substitutes_text_and_null() {
1081 let q = parse("SELECT * FROM users WHERE name = $1 AND deleted = $2");
1082 let bound = bind(&q, &[Value::text("Alice"), Value::Null]).unwrap();
1083 let QueryExpr::Table(t) = bound else {
1084 panic!("expected Table");
1085 };
1086 let mut literals: Vec<Value> = Vec::new();
1087 visit_expr(&t.where_expr.unwrap(), &mut |e| {
1088 if let Expr::Literal { value, .. } = e {
1089 literals.push(value.clone());
1090 }
1091 });
1092 assert!(literals
1093 .iter()
1094 .any(|v| matches!(v, Value::Text(s) if s.as_ref() == "Alice")));
1095 assert!(literals.iter().any(|v| matches!(v, Value::Null)));
1096 }
1097
1098 #[test]
1099 fn bind_search_similar_vector_param() {
1100 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT 5");
1103 let bound = bind(&q, &[Value::Vector(vec![0.1, 0.2, 0.3])]).unwrap();
1104 let QueryExpr::SearchCommand(SearchCommand::Similar {
1105 vector,
1106 vector_param,
1107 collection,
1108 limit,
1109 ..
1110 }) = bound
1111 else {
1112 panic!("expected SearchCommand::Similar");
1113 };
1114 assert_eq!(vector, vec![0.1f32, 0.2, 0.3]);
1115 assert_eq!(vector_param, None, "vector_param must be cleared post-bind");
1116 assert_eq!(collection, "embeddings");
1117 assert_eq!(limit, 5);
1118 }
1119
1120 #[test]
1121 fn bind_search_similar_limit_param() {
1122 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings LIMIT $1");
1124 let bound = bind(&q, &[Value::Integer(25)]).unwrap();
1125 let QueryExpr::SearchCommand(SearchCommand::Similar {
1126 limit,
1127 limit_param,
1128 min_score_param,
1129 ..
1130 }) = bound
1131 else {
1132 panic!("expected SearchCommand::Similar");
1133 };
1134 assert_eq!(limit, 25);
1135 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1136 assert_eq!(min_score_param, None);
1137 }
1138
1139 #[test]
1140 fn bind_search_similar_min_score_param() {
1141 let q = parse("SEARCH SIMILAR [0.1, 0.2] COLLECTION embeddings MIN_SCORE $1");
1142 let bound = bind(&q, &[Value::Float(0.42)]).unwrap();
1143 let QueryExpr::SearchCommand(SearchCommand::Similar {
1144 min_score,
1145 min_score_param,
1146 ..
1147 }) = bound
1148 else {
1149 panic!("expected SearchCommand::Similar");
1150 };
1151 assert!((min_score - 0.42_f32).abs() < 1e-6);
1152 assert_eq!(min_score_param, None);
1153 }
1154
1155 #[test]
1156 fn bind_search_similar_limit_and_min_score_together() {
1157 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings LIMIT $2 MIN_SCORE $3");
1158 let bound = bind(
1159 &q,
1160 &[
1161 Value::Vector(vec![0.1, 0.2]),
1162 Value::Integer(7),
1163 Value::Float(0.9),
1164 ],
1165 )
1166 .unwrap();
1167 let QueryExpr::SearchCommand(SearchCommand::Similar {
1168 limit,
1169 min_score,
1170 vector,
1171 vector_param,
1172 limit_param,
1173 min_score_param,
1174 ..
1175 }) = bound
1176 else {
1177 panic!("expected SearchCommand::Similar");
1178 };
1179 assert_eq!(vector, vec![0.1_f32, 0.2]);
1180 assert_eq!(limit, 7);
1181 assert!((min_score - 0.9_f32).abs() < 1e-6);
1182 assert_eq!(vector_param, None);
1183 assert_eq!(limit_param, None);
1184 assert_eq!(min_score_param, None);
1185 }
1186
1187 #[test]
1188 fn bind_search_similar_limit_rejects_non_integer() {
1189 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1190 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1191 assert!(
1192 matches!(
1193 err,
1194 UserParamError::TypeMismatch {
1195 slot: "SEARCH SIMILAR LIMIT parameter",
1196 got: "text"
1197 }
1198 ),
1199 "got {err:?}"
1200 );
1201 }
1202
1203 #[test]
1204 fn bind_search_similar_limit_rejects_zero_or_negative() {
1205 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e LIMIT $1");
1206 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1207 assert!(matches!(
1208 err,
1209 UserParamError::TypeMismatch {
1210 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1211 ..
1212 }
1213 ));
1214 let err = bind(&q, &[Value::Integer(-3)]).unwrap_err();
1215 assert!(matches!(
1216 err,
1217 UserParamError::TypeMismatch {
1218 slot: "SEARCH SIMILAR LIMIT parameter (must be > 0)",
1219 ..
1220 }
1221 ));
1222 }
1223
1224 #[test]
1225 fn bind_search_similar_min_score_rejects_non_numeric() {
1226 let q = parse("SEARCH SIMILAR [0.1] COLLECTION e MIN_SCORE $1");
1227 let err = bind(&q, &[Value::Vector(vec![1.0])]).unwrap_err();
1228 assert!(matches!(
1229 err,
1230 UserParamError::TypeMismatch {
1231 slot: "SEARCH SIMILAR MIN_SCORE parameter",
1232 got: "vector"
1233 }
1234 ));
1235 }
1236
1237 #[test]
1244 fn bind_search_similar_rejects_non_vector_param() {
1245 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1246 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1247 assert!(
1248 matches!(
1249 err,
1250 UserParamError::TypeMismatch {
1251 slot: "SEARCH SIMILAR vector parameter",
1252 got: "integer"
1253 }
1254 ),
1255 "got {err:?}"
1256 );
1257 }
1258
1259 #[test]
1260 fn bind_search_similar_empty_vector_param() {
1261 let q = parse("SEARCH SIMILAR $1 COLLECTION embeddings");
1262 let bound = bind(&q, &[Value::Vector(vec![])]).unwrap();
1263 let QueryExpr::SearchCommand(SearchCommand::Similar { vector, .. }) = bound else {
1264 panic!("expected SearchCommand::Similar");
1265 };
1266 assert!(vector.is_empty());
1267 }
1268
1269 #[test]
1270 fn bind_search_hybrid_limit_param() {
1271 let q = parse("SEARCH HYBRID SIMILAR [0.1, 0.2] TEXT 'q' COLLECTION svc LIMIT $1");
1273 let bound = bind(&q, &[Value::Integer(30)]).unwrap();
1274 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1275 limit, limit_param, ..
1276 }) = bound
1277 else {
1278 panic!("expected SearchCommand::Hybrid");
1279 };
1280 assert_eq!(limit, 30);
1281 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1282 }
1283
1284 #[test]
1285 fn bind_search_hybrid_k_param() {
1286 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc K $1");
1288 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1289 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
1290 limit, limit_param, ..
1291 }) = bound
1292 else {
1293 panic!("expected SearchCommand::Hybrid");
1294 };
1295 assert_eq!(limit, 7);
1296 assert_eq!(limit_param, None);
1297 }
1298
1299 #[test]
1300 fn bind_search_hybrid_limit_rejects_non_integer() {
1301 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1302 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1303 assert!(
1304 matches!(
1305 err,
1306 UserParamError::TypeMismatch {
1307 slot: "SEARCH HYBRID LIMIT parameter",
1308 got: "text"
1309 }
1310 ),
1311 "got {err:?}"
1312 );
1313 }
1314
1315 #[test]
1316 fn bind_search_hybrid_limit_rejects_zero() {
1317 let q = parse("SEARCH HYBRID TEXT 'q' COLLECTION svc LIMIT $1");
1318 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1319 assert!(matches!(
1320 err,
1321 UserParamError::TypeMismatch {
1322 slot: "SEARCH HYBRID LIMIT parameter (must be > 0)",
1323 ..
1324 }
1325 ));
1326 }
1327
1328 #[test]
1329 fn bind_search_spatial_nearest_k_param() {
1330 let q =
1332 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1333 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1334 let QueryExpr::SearchCommand(SearchCommand::SpatialNearest { k, k_param, .. }) = bound
1335 else {
1336 panic!("expected SpatialNearest");
1337 };
1338 assert_eq!(k, 7);
1339 assert_eq!(k_param, None, "k_param must be cleared post-bind");
1340 }
1341
1342 #[test]
1343 fn bind_search_spatial_nearest_k_rejects_zero() {
1344 let q =
1345 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1346 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1347 assert!(matches!(
1348 err,
1349 UserParamError::TypeMismatch {
1350 slot: "SEARCH SPATIAL NEAREST K parameter (must be > 0)",
1351 ..
1352 }
1353 ));
1354 }
1355
1356 #[test]
1357 fn bind_search_spatial_nearest_k_rejects_non_integer() {
1358 let q =
1359 parse("SEARCH SPATIAL NEAREST 40.7128 74.0060 K $1 COLLECTION sites COLUMN location");
1360 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1361 assert!(matches!(
1362 err,
1363 UserParamError::TypeMismatch {
1364 slot: "SEARCH SPATIAL NEAREST K parameter",
1365 got: "text"
1366 }
1367 ));
1368 }
1369
1370 #[test]
1371 fn bind_search_text_limit_param() {
1372 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1374 let bound = bind(&q, &[Value::Integer(15)]).unwrap();
1375 let QueryExpr::SearchCommand(SearchCommand::Text {
1376 limit, limit_param, ..
1377 }) = bound
1378 else {
1379 panic!("expected SearchCommand::Text");
1380 };
1381 assert_eq!(limit, 15);
1382 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1383 }
1384
1385 #[test]
1386 fn bind_search_text_limit_rejects_zero() {
1387 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1388 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1389 assert!(matches!(
1390 err,
1391 UserParamError::TypeMismatch {
1392 slot: "SEARCH TEXT LIMIT parameter (must be > 0)",
1393 ..
1394 }
1395 ));
1396 }
1397
1398 #[test]
1399 fn bind_search_text_limit_rejects_non_integer() {
1400 let q = parse("SEARCH TEXT 'hello' COLLECTION docs LIMIT $1");
1401 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1402 assert!(matches!(
1403 err,
1404 UserParamError::TypeMismatch {
1405 slot: "SEARCH TEXT LIMIT parameter",
1406 got: "text"
1407 }
1408 ));
1409 }
1410
1411 #[test]
1412 fn bind_search_multimodal_limit_param() {
1413 let q = parse("SEARCH MULTIMODAL 'user:123' COLLECTION people LIMIT $1");
1415 let bound = bind(&q, &[Value::Integer(40)]).unwrap();
1416 let QueryExpr::SearchCommand(SearchCommand::Multimodal {
1417 limit, limit_param, ..
1418 }) = bound
1419 else {
1420 panic!("expected SearchCommand::Multimodal");
1421 };
1422 assert_eq!(limit, 40);
1423 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1424 }
1425
1426 #[test]
1427 fn bind_search_multimodal_limit_rejects_zero() {
1428 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1429 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1430 assert!(matches!(
1431 err,
1432 UserParamError::TypeMismatch {
1433 slot: "SEARCH MULTIMODAL LIMIT parameter (must be > 0)",
1434 ..
1435 }
1436 ));
1437 }
1438
1439 #[test]
1440 fn bind_search_multimodal_limit_rejects_non_integer() {
1441 let q = parse("SEARCH MULTIMODAL 'k' COLLECTION people LIMIT $1");
1442 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1443 assert!(matches!(
1444 err,
1445 UserParamError::TypeMismatch {
1446 slot: "SEARCH MULTIMODAL LIMIT parameter",
1447 got: "text"
1448 }
1449 ));
1450 }
1451
1452 #[test]
1453 fn bind_search_index_limit_param() {
1454 let q = parse("SEARCH INDEX cpf VALUE '000.000.000-00' COLLECTION people LIMIT $1");
1456 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1457 let QueryExpr::SearchCommand(SearchCommand::Index {
1458 limit, limit_param, ..
1459 }) = bound
1460 else {
1461 panic!("expected SearchCommand::Index");
1462 };
1463 assert_eq!(limit, 50);
1464 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1465 }
1466
1467 #[test]
1468 fn bind_search_index_limit_rejects_zero() {
1469 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1470 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1471 assert!(matches!(
1472 err,
1473 UserParamError::TypeMismatch {
1474 slot: "SEARCH INDEX LIMIT parameter (must be > 0)",
1475 ..
1476 }
1477 ));
1478 }
1479
1480 #[test]
1481 fn bind_search_index_limit_rejects_non_integer() {
1482 let q = parse("SEARCH INDEX cpf VALUE 'x' COLLECTION people LIMIT $1");
1483 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1484 assert!(matches!(
1485 err,
1486 UserParamError::TypeMismatch {
1487 slot: "SEARCH INDEX LIMIT parameter",
1488 got: "text"
1489 }
1490 ));
1491 }
1492
1493 #[test]
1494 fn bind_search_context_limit_param() {
1495 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1497 let bound = bind(&q, &[Value::Integer(60)]).unwrap();
1498 let QueryExpr::SearchCommand(SearchCommand::Context {
1499 limit, limit_param, ..
1500 }) = bound
1501 else {
1502 panic!("expected SearchCommand::Context");
1503 };
1504 assert_eq!(limit, 60);
1505 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1506 }
1507
1508 #[test]
1509 fn bind_search_context_limit_rejects_zero() {
1510 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1511 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1512 assert!(matches!(
1513 err,
1514 UserParamError::TypeMismatch {
1515 slot: "SEARCH CONTEXT LIMIT parameter (must be > 0)",
1516 ..
1517 }
1518 ));
1519 }
1520
1521 #[test]
1522 fn bind_search_context_limit_rejects_non_integer() {
1523 let q = parse("SEARCH CONTEXT 'hello' COLLECTION docs LIMIT $1");
1524 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1525 assert!(matches!(
1526 err,
1527 UserParamError::TypeMismatch {
1528 slot: "SEARCH CONTEXT LIMIT parameter",
1529 got: "text"
1530 }
1531 ));
1532 }
1533
1534 #[test]
1535 fn bind_search_spatial_radius_limit_param() {
1536 let q = parse(
1538 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1539 );
1540 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1541 let QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
1542 limit, limit_param, ..
1543 }) = bound
1544 else {
1545 panic!("expected SearchCommand::SpatialRadius");
1546 };
1547 assert_eq!(limit, 50);
1548 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1549 }
1550
1551 #[test]
1552 fn bind_search_spatial_radius_limit_rejects_zero() {
1553 let q = parse(
1554 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1555 );
1556 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1557 assert!(matches!(
1558 err,
1559 UserParamError::TypeMismatch {
1560 slot: "SEARCH SPATIAL RADIUS LIMIT parameter (must be > 0)",
1561 ..
1562 }
1563 ));
1564 }
1565
1566 #[test]
1567 fn bind_search_spatial_radius_limit_rejects_non_integer() {
1568 let q = parse(
1569 "SEARCH SPATIAL RADIUS 48.8566 2.3522 10.0 COLLECTION sites COLUMN location LIMIT $1",
1570 );
1571 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1572 assert!(matches!(
1573 err,
1574 UserParamError::TypeMismatch {
1575 slot: "SEARCH SPATIAL RADIUS LIMIT parameter",
1576 got: "text"
1577 }
1578 ));
1579 }
1580
1581 #[test]
1582 fn bind_search_spatial_bbox_limit_param() {
1583 let q =
1585 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1586 let bound = bind(&q, &[Value::Integer(50)]).unwrap();
1587 let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
1588 limit, limit_param, ..
1589 }) = bound
1590 else {
1591 panic!("expected SearchCommand::SpatialBbox");
1592 };
1593 assert_eq!(limit, 50);
1594 assert_eq!(limit_param, None, "limit_param must be cleared post-bind");
1595 }
1596
1597 #[test]
1598 fn bind_search_spatial_bbox_limit_rejects_zero() {
1599 let q =
1600 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1601 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1602 assert!(matches!(
1603 err,
1604 UserParamError::TypeMismatch {
1605 slot: "SEARCH SPATIAL BBOX LIMIT parameter (must be > 0)",
1606 ..
1607 }
1608 ));
1609 }
1610
1611 #[test]
1612 fn bind_search_spatial_bbox_limit_rejects_non_integer() {
1613 let q =
1614 parse("SEARCH SPATIAL BBOX 0.0 0.0 1.0 1.0 COLLECTION sites COLUMN location LIMIT $1");
1615 let err = bind(&q, &[Value::text("five")]).unwrap_err();
1616 assert!(matches!(
1617 err,
1618 UserParamError::TypeMismatch {
1619 slot: "SEARCH SPATIAL BBOX LIMIT parameter",
1620 got: "text"
1621 }
1622 ));
1623 }
1624
1625 #[test]
1626 fn bind_search_similar_text_param() {
1627 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT 5 USING openai");
1630 let bound = bind(&q, &[Value::text("find vulnerabilities")]).unwrap();
1631 let QueryExpr::SearchCommand(SearchCommand::Similar {
1632 vector,
1633 text,
1634 text_param,
1635 collection,
1636 limit,
1637 provider,
1638 ..
1639 }) = bound
1640 else {
1641 panic!("expected SearchCommand::Similar");
1642 };
1643 assert!(vector.is_empty());
1644 assert_eq!(text.as_deref(), Some("find vulnerabilities"));
1645 assert_eq!(text_param, None, "text_param must be cleared post-bind");
1646 assert_eq!(collection, "docs");
1647 assert_eq!(limit, 5);
1648 assert_eq!(provider.as_deref(), Some("openai"));
1649 }
1650
1651 #[test]
1652 fn bind_search_similar_text_rejects_non_text() {
1653 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs");
1654 let err = bind(&q, &[Value::Integer(42)]).unwrap_err();
1655 assert!(
1656 matches!(
1657 err,
1658 UserParamError::TypeMismatch {
1659 slot: "SEARCH SIMILAR TEXT parameter",
1660 got: "integer"
1661 }
1662 ),
1663 "got {err:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn bind_search_similar_text_with_limit_param() {
1669 let q = parse("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2");
1672 let bound = bind(&q, &[Value::text("hello"), Value::Integer(11)]).unwrap();
1673 let QueryExpr::SearchCommand(SearchCommand::Similar {
1674 text,
1675 text_param,
1676 limit,
1677 limit_param,
1678 ..
1679 }) = bound
1680 else {
1681 panic!("expected SearchCommand::Similar");
1682 };
1683 assert_eq!(text.as_deref(), Some("hello"));
1684 assert_eq!(text_param, None);
1685 assert_eq!(limit, 11);
1686 assert_eq!(limit_param, None);
1687 }
1688
1689 #[test]
1690 fn bind_insert_values_with_vector_param() {
1691 let q = parse("INSERT INTO embeddings (dense, content) VALUES ($1, $2)");
1694 let vec = Value::Vector(vec![0.1, 0.2, 0.3]);
1695 let bound = bind(&q, &[vec.clone(), Value::text("doc text")]).unwrap();
1696 let QueryExpr::Insert(insert) = bound else {
1697 panic!("expected Insert");
1698 };
1699 assert_eq!(insert.values.len(), 1);
1700 assert_eq!(insert.values[0].len(), 2);
1701 assert!(
1702 matches!(insert.values[0][0], Value::Vector(ref v) if v == &vec![0.1f32, 0.2, 0.3])
1703 );
1704 assert!(matches!(insert.values[0][1], Value::Text(ref s) if s.as_ref() == "doc text"));
1705 let row0 = &insert.value_exprs[0];
1707 assert!(matches!(
1708 &row0[0],
1709 Expr::Literal {
1710 value: Value::Vector(_),
1711 ..
1712 }
1713 ));
1714 }
1715
1716 #[test]
1717 fn bind_insert_arity_mismatch() {
1718 let q = parse("INSERT INTO t (a, b) VALUES ($1, $2)");
1719 let err = bind(&q, &[Value::Integer(1)]).unwrap_err();
1720 assert!(matches!(
1721 err,
1722 UserParamError::Arity {
1723 expected: 2,
1724 got: 1
1725 }
1726 ));
1727 }
1728
1729 #[test]
1730 fn bind_select_limit_param() {
1731 let q = parse("SELECT * FROM users LIMIT $1");
1732 let bound = bind(&q, &[Value::Integer(7)]).unwrap();
1733 let QueryExpr::Table(t) = bound else {
1734 panic!("expected Table");
1735 };
1736 assert_eq!(t.limit, Some(7));
1737 assert_eq!(t.limit_param, None, "limit_param must be cleared post-bind");
1738 assert_eq!(t.offset, None);
1739 assert_eq!(t.offset_param, None);
1740 }
1741
1742 #[test]
1743 fn bind_select_offset_param() {
1744 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
1745 let bound = bind(&q, &[Value::Integer(20)]).unwrap();
1746 let QueryExpr::Table(t) = bound else {
1747 panic!("expected Table");
1748 };
1749 assert_eq!(t.limit, Some(10));
1750 assert_eq!(t.offset, Some(20));
1751 assert_eq!(t.offset_param, None);
1752 }
1753
1754 #[test]
1755 fn bind_select_limit_and_offset_params_together() {
1756 let q = parse("SELECT * FROM users WHERE id = $1 LIMIT $2 OFFSET $3");
1757 let bound = bind(
1758 &q,
1759 &[Value::Integer(5), Value::Integer(10), Value::Integer(20)],
1760 )
1761 .unwrap();
1762 let QueryExpr::Table(t) = bound else {
1763 panic!("expected Table");
1764 };
1765 assert_eq!(t.limit, Some(10));
1766 assert_eq!(t.offset, Some(20));
1767 assert_eq!(t.limit_param, None);
1768 assert_eq!(t.offset_param, None);
1769 assert!(t.where_expr.is_some());
1771 }
1772
1773 #[test]
1774 fn bind_select_offset_zero_is_valid() {
1775 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
1776 let bound = bind(&q, &[Value::Integer(0)]).unwrap();
1777 let QueryExpr::Table(t) = bound else {
1778 panic!("expected Table");
1779 };
1780 assert_eq!(t.offset, Some(0));
1781 }
1782
1783 #[test]
1784 fn bind_select_limit_rejects_zero() {
1785 let q = parse("SELECT * FROM users LIMIT $1");
1786 let err = bind(&q, &[Value::Integer(0)]).unwrap_err();
1787 assert!(
1788 matches!(
1789 err,
1790 UserParamError::TypeMismatch {
1791 slot: "SELECT LIMIT parameter (must be > 0)",
1792 ..
1793 }
1794 ),
1795 "got {err:?}"
1796 );
1797 }
1798
1799 #[test]
1800 fn bind_select_limit_rejects_non_integer() {
1801 let q = parse("SELECT * FROM users LIMIT $1");
1802 let err = bind(&q, &[Value::text("ten")]).unwrap_err();
1803 assert!(
1804 matches!(
1805 err,
1806 UserParamError::TypeMismatch {
1807 slot: "SELECT LIMIT parameter",
1808 got: "text"
1809 }
1810 ),
1811 "got {err:?}"
1812 );
1813 }
1814
1815 #[test]
1816 fn bind_select_offset_rejects_negative() {
1817 let q = parse("SELECT * FROM users LIMIT 10 OFFSET $1");
1818 let err = bind(&q, &[Value::Integer(-1)]).unwrap_err();
1819 assert!(
1820 matches!(
1821 err,
1822 UserParamError::TypeMismatch {
1823 slot: "SELECT OFFSET parameter (must be >= 0)",
1824 ..
1825 }
1826 ),
1827 "got {err:?}"
1828 );
1829 }
1830
1831 #[test]
1832 fn bind_no_params_is_noop() {
1833 let q = parse("SELECT * FROM users");
1834 let bound = bind(&q, &[]).unwrap();
1835 assert!(matches!(bound, QueryExpr::Table(_)));
1836 }
1837}