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