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