1#[doc(hidden)]
45pub mod rules;
46
47use crate::context::ExecutionContext;
48use radixdb_core::{DataType, Schema, Value};
49use radixdb_sql::ast::{self as ast};
50use radixdb_storage::expression::Expression as StorageExpr;
51
52pub use rules::*;
53
54#[derive(Debug)]
56pub enum PushdownResult {
57 Converted(Box<dyn StorageExpr>),
59 Partial {
61 storage_expr: Box<dyn StorageExpr>,
62 residual: ast::Expression,
63 },
64 NotApplicable,
66 CannotPush,
68}
69
70#[derive(Debug)]
71pub struct PushdownPlan {
72 pub storage_expr: Option<Box<dyn StorageExpr>>,
73 pub residual: Option<ast::Expression>,
74}
75
76impl PushdownPlan {
77 pub fn needs_memory_filter(&self) -> bool {
78 self.residual.is_some()
79 }
80}
81
82pub struct PushdownContext<'a> {
84 pub schema: &'a Schema,
86 pub exec_ctx: Option<&'a ExecutionContext>,
88}
89
90impl<'a> PushdownContext<'a> {
91 pub fn new(schema: &'a Schema, exec_ctx: Option<&'a ExecutionContext>) -> Self {
92 Self { schema, exec_ctx }
93 }
94
95 pub fn column_type(&self, name: &str) -> Option<radixdb_core::DataType> {
97 self.schema
98 .column_index_map()
99 .get(name)
100 .and_then(|&idx| self.schema.columns.get(idx).map(|c| c.data_type))
101 }
102
103 pub fn has_column(&self, name: &str) -> bool {
105 self.schema.has_column(name)
106 }
107
108 pub fn coerce_to_column_type(&self, column: &str, value: Value) -> Option<Value> {
110 if let Some(col_type) = self.column_type(column) {
111 let preserve_numeric_variant = matches!(col_type, DataType::Integer | DataType::Float)
120 && (matches!(value, Value::Integer(_) | Value::Float(_))
121 || value.as_decimal_parts().is_some());
122 if preserve_numeric_variant {
123 return Some(value);
124 }
125 let source_was_null = value.is_null();
126 let coerced = value.into_coerce_to_type(col_type);
127 if !source_was_null && coerced.is_null() {
128 None
129 } else {
130 Some(coerced)
131 }
132 } else {
133 Some(value)
134 }
135 }
136}
137
138pub trait PushdownRule: Send + Sync {
144 fn name(&self) -> &'static str;
146
147 fn try_convert(&self, expr: &ast::Expression, ctx: &PushdownContext<'_>) -> PushdownResult;
154}
155
156pub struct PushdownRegistry {
161 rules: Vec<Box<dyn PushdownRule>>,
162}
163
164impl Default for PushdownRegistry {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170impl PushdownRegistry {
171 pub fn new() -> Self {
173 let mut registry = Self { rules: vec![] };
174
175 registry.register(Box::new(LogicalAndRule));
178 registry.register(Box::new(LogicalOrRule));
179 registry.register(Box::new(LogicalNotRule));
180 registry.register(Box::new(LogicalXorRule));
181
182 registry.register(Box::new(BetweenRule));
184 registry.register(Box::new(InListRule));
185 registry.register(Box::new(LikeRule));
186 registry.register(Box::new(NullCheckRule));
187 registry.register(Box::new(BooleanCheckRule));
188
189 registry.register(Box::new(ComparisonRule));
191
192 registry.register(Box::new(FunctionRule));
194
195 registry.register(Box::new(BooleanLiteralRule));
197
198 registry
199 }
200
201 pub fn register(&mut self, rule: Box<dyn PushdownRule>) {
203 self.rules.push(rule);
204 }
205
206 pub fn try_pushdown(
210 &self,
211 expr: &ast::Expression,
212 schema: &Schema,
213 exec_ctx: Option<&ExecutionContext>,
214 ) -> (Option<Box<dyn StorageExpr>>, bool) {
215 let ctx = PushdownContext::new(schema, exec_ctx);
216 self.try_pushdown_with_ctx(expr, &ctx)
217 }
218
219 pub(crate) fn try_pushdown_with_ctx(
221 &self,
222 expr: &ast::Expression,
223 ctx: &PushdownContext<'_>,
224 ) -> (Option<Box<dyn StorageExpr>>, bool) {
225 let plan = self.try_pushdown_plan_with_ctx(expr, ctx);
226 let needs_memory_filter = plan.needs_memory_filter();
227 (plan.storage_expr, needs_memory_filter)
228 }
229
230 pub(crate) fn try_pushdown_plan_with_ctx(
231 &self,
232 expr: &ast::Expression,
233 ctx: &PushdownContext<'_>,
234 ) -> PushdownPlan {
235 for rule in &self.rules {
236 match rule.try_convert(expr, ctx) {
237 PushdownResult::Converted(storage_expr) => {
238 return PushdownPlan {
239 storage_expr: Some(storage_expr),
240 residual: None,
241 };
242 }
243 PushdownResult::Partial {
244 storage_expr,
245 residual,
246 } => {
247 return PushdownPlan {
248 storage_expr: Some(storage_expr),
249 residual: Some(residual),
250 };
251 }
252 PushdownResult::CannotPush => {
253 return PushdownPlan {
254 storage_expr: None,
255 residual: Some(expr.clone()),
256 };
257 }
258 PushdownResult::NotApplicable => {
259 continue;
261 }
262 }
263 }
264
265 PushdownPlan {
267 storage_expr: None,
268 residual: Some(expr.clone()),
269 }
270 }
271
272 pub(crate) fn convert_expr(
275 &self,
276 expr: &ast::Expression,
277 ctx: &PushdownContext<'_>,
278 ) -> Option<Box<dyn StorageExpr>> {
279 let plan = self.try_pushdown_plan_with_ctx(expr, ctx);
280 if plan.needs_memory_filter() {
281 None
282 } else {
283 plan.storage_expr
284 }
285 }
286}
287
288use std::sync::OnceLock;
290
291static REGISTRY: OnceLock<PushdownRegistry> = OnceLock::new();
292
293pub fn registry() -> &'static PushdownRegistry {
295 REGISTRY.get_or_init(PushdownRegistry::new)
296}
297
298pub fn try_pushdown(
300 expr: &ast::Expression,
301 schema: &Schema,
302 exec_ctx: Option<&ExecutionContext>,
303) -> (Option<Box<dyn StorageExpr>>, bool) {
304 registry().try_pushdown(expr, schema, exec_ctx)
305}
306
307pub fn try_pushdown_plan(
308 expr: &ast::Expression,
309 schema: &Schema,
310 exec_ctx: Option<&ExecutionContext>,
311) -> PushdownPlan {
312 let ctx = PushdownContext::new(schema, exec_ctx);
313 registry().try_pushdown_plan_with_ctx(expr, &ctx)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use radixdb_core::{DataType, Row, SchemaBuilder};
320 use radixdb_sql::token::{Position, Token, TokenType};
321
322 fn test_schema() -> Schema {
323 SchemaBuilder::new("test")
324 .add_primary_key("id", DataType::Integer)
325 .add("name", DataType::Text)
326 .add("age", DataType::Integer)
327 .add_nullable("email", DataType::Text)
328 .add("active", DataType::Boolean)
329 .add("price", DataType::Float)
330 .build()
331 }
332
333 fn test_row() -> Row {
334 Row::from_values(vec![
335 Value::integer(1),
336 Value::text("Alice"),
337 Value::integer(30),
338 Value::text("alice@example.com"),
339 Value::Boolean(true),
340 Value::Float(99.99),
341 ])
342 }
343
344 #[test]
345 fn test_pushdown_preserves_valid_numeric_literal_identity() {
346 let schema = SchemaBuilder::new("numeric_pushdown")
347 .add("integer_value", DataType::Integer)
348 .add("float_value", DataType::Float)
349 .add("decimal_value", DataType::Decimal)
350 .build();
351 let ctx = PushdownContext::new(&schema, None);
352
353 assert_eq!(
354 ctx.coerce_to_column_type("integer_value", Value::Float(0.5)),
355 Some(Value::Float(0.5))
356 );
357 let decimal_fraction = Value::decimal(15, 2, 1);
358 assert_eq!(
359 ctx.coerce_to_column_type("integer_value", decimal_fraction.clone()),
360 Some(decimal_fraction)
361 );
362 assert_eq!(
363 ctx.coerce_to_column_type("float_value", Value::Integer((1_i64 << 53) + 1)),
364 Some(Value::Integer((1_i64 << 53) + 1))
365 );
366 assert_eq!(
367 ctx.coerce_to_column_type("decimal_value", Value::Float(0.5)),
368 Some(Value::decimal(5, 1, 1))
369 );
370 }
371
372 fn dummy_token() -> Token {
373 Token::new(TokenType::Error, "", Position::new(0, 1, 1))
374 }
375
376 fn make_ident(name: &str) -> ast::Expression {
377 ast::Expression::Identifier(ast::Identifier::new(dummy_token(), name.to_string()))
378 }
379
380 fn make_int(value: i64) -> ast::Expression {
381 ast::Expression::IntegerLiteral(ast::IntegerLiteral {
382 token: dummy_token(),
383 value,
384 })
385 }
386
387 fn make_str(value: &str) -> ast::Expression {
388 ast::Expression::StringLiteral(ast::StringLiteral {
389 token: dummy_token(),
390 value: value.into(),
391 type_hint: None,
392 })
393 }
394
395 fn make_infix(left: ast::Expression, op: &str, right: ast::Expression) -> ast::Expression {
396 ast::Expression::Infix(ast::InfixExpression::new(
397 dummy_token(),
398 Box::new(left),
399 op,
400 Box::new(right),
401 ))
402 }
403
404 fn make_function(name: &str, arguments: Vec<ast::Expression>) -> ast::Expression {
405 ast::Expression::FunctionCall(Box::new(ast::FunctionCall {
406 token: dummy_token(),
407 function: name.into(),
408 arguments,
409 is_distinct: false,
410 order_by: vec![],
411 filter: None,
412 }))
413 }
414
415 #[test]
416 fn test_simple_equality() {
417 let schema = test_schema();
418 let expr = make_infix(make_ident("id"), "=", make_int(1));
419
420 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
421 assert!(storage_expr.is_some());
422 assert!(!needs_mem);
423
424 let mut expr = storage_expr.unwrap();
425 expr.prepare_for_schema(&schema);
426 assert!(expr.evaluate(&test_row()).unwrap());
427 }
428
429 #[test]
430 fn test_and_expression() {
431 let schema = test_schema();
432 let left = make_infix(make_ident("id"), "=", make_int(1));
433 let right = make_infix(make_ident("age"), ">", make_int(20));
434 let expr = make_infix(left, "AND", right);
435
436 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
437 assert!(storage_expr.is_some());
438 assert!(!needs_mem);
439
440 let mut expr = storage_expr.unwrap();
441 expr.prepare_for_schema(&schema);
442 assert!(expr.evaluate(&test_row()).unwrap());
443 }
444
445 #[test]
446 fn test_function_pushable() {
447 let schema = test_schema();
448 let func = ast::Expression::FunctionCall(Box::new(ast::FunctionCall {
449 token: dummy_token(),
450 function: "LENGTH".into(),
451 arguments: vec![make_ident("name")],
452 is_distinct: false,
453 order_by: vec![],
454 filter: None,
455 }));
456 let expr = make_infix(func, ">", make_int(5));
457
458 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
459 assert!(storage_expr.is_some());
461 assert!(!needs_mem);
462 }
463
464 #[test]
465 fn test_full_pushdown_with_function() {
466 let schema = test_schema();
467 let pushable = make_infix(make_ident("id"), "=", make_int(1));
469 let func = ast::Expression::FunctionCall(Box::new(ast::FunctionCall {
470 token: dummy_token(),
471 function: "LENGTH".into(),
472 arguments: vec![make_ident("name")],
473 is_distinct: false,
474 order_by: vec![],
475 filter: None,
476 }));
477 let also_pushable = make_infix(func, ">=", make_int(5)); let expr = make_infix(pushable, "AND", also_pushable);
479
480 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
481 assert!(storage_expr.is_some());
483 assert!(!needs_mem);
485
486 let mut expr = storage_expr.unwrap();
487 expr.prepare_for_schema(&schema);
488 assert!(expr.evaluate(&test_row()).unwrap());
490 }
491
492 #[test]
493 fn test_between() {
494 let schema = test_schema();
495 let expr = ast::Expression::Between(ast::BetweenExpression {
496 token: dummy_token(),
497 expr: Box::new(make_ident("age")),
498 lower: Box::new(make_int(25)),
499 upper: Box::new(make_int(35)),
500 not: false,
501 });
502
503 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
504 assert!(storage_expr.is_some());
505 assert!(!needs_mem);
506
507 let mut expr = storage_expr.unwrap();
508 expr.prepare_for_schema(&schema);
509 assert!(expr.evaluate(&test_row()).unwrap()); }
511
512 #[test]
513 fn test_in_list() {
514 let schema = test_schema();
515 let expr = ast::Expression::In(ast::InExpression {
516 token: dummy_token(),
517 left: Box::new(make_ident("id")),
518 right: Box::new(ast::Expression::ExpressionList(Box::new(
519 ast::ExpressionList {
520 token: dummy_token(),
521 expressions: vec![make_int(1), make_int(2), make_int(3)],
522 },
523 ))),
524 not: false,
525 });
526
527 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
528 assert!(storage_expr.is_some());
529 assert!(!needs_mem);
530
531 let mut expr = storage_expr.unwrap();
532 expr.prepare_for_schema(&schema);
533 assert!(expr.evaluate(&test_row()).unwrap()); }
535
536 #[test]
537 fn test_like() {
538 let schema = test_schema();
539 let expr = ast::Expression::Like(ast::LikeExpression {
540 token: dummy_token(),
541 left: Box::new(make_ident("name")),
542 operator: "LIKE".into(),
543 pattern: Box::new(make_str("Ali%")),
544 escape: None,
545 });
546
547 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
548 assert!(storage_expr.is_some());
549 assert!(!needs_mem);
550
551 let mut expr = storage_expr.unwrap();
552 expr.prepare_for_schema(&schema);
553 assert!(expr.evaluate(&test_row()).unwrap()); }
555
556 #[test]
557 fn test_is_null() {
558 let schema = test_schema();
559 let expr = make_infix(
560 make_ident("email"),
561 "IS",
562 ast::Expression::NullLiteral(ast::NullLiteral {
563 token: dummy_token(),
564 }),
565 );
566
567 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
568 assert!(storage_expr.is_some());
569 assert!(!needs_mem);
570
571 let mut expr = storage_expr.unwrap();
572 expr.prepare_for_schema(&schema);
573 assert!(!expr.evaluate(&test_row()).unwrap());
575 }
576
577 #[test]
578 fn test_or_fully_pushable() {
579 let schema = test_schema();
580 let left = make_infix(make_ident("id"), "=", make_int(1));
581 let right = make_infix(make_ident("id"), "=", make_int(2));
582 let expr = make_infix(left, "OR", right);
583
584 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
585 assert!(storage_expr.is_some());
586 assert!(!needs_mem);
587 }
588
589 #[test]
590 fn test_or_with_function_pushable() {
591 let schema = test_schema();
592 let left = make_infix(make_ident("id"), "=", make_int(1));
593 let func = ast::Expression::FunctionCall(Box::new(ast::FunctionCall {
594 token: dummy_token(),
595 function: "LENGTH".into(),
596 arguments: vec![make_ident("name")],
597 is_distinct: false,
598 order_by: vec![],
599 filter: None,
600 }));
601 let right = make_infix(func, ">", make_int(5));
602 let expr = make_infix(left, "OR", right);
603
604 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
606 assert!(storage_expr.is_some());
607 assert!(!needs_mem);
608
609 let mut expr = storage_expr.unwrap();
610 expr.prepare_for_schema(&schema);
611 assert!(expr.evaluate(&test_row()).unwrap());
613 }
614
615 #[test]
616 fn r4_batch_a_compounds_never_promote_partial_children() {
617 let schema = test_schema();
618 let partial = make_infix(
619 make_infix(make_ident("name"), "=", make_str("Alice")),
620 "AND",
621 make_infix(
622 make_infix(make_ident("age"), "+", make_int(1)),
623 "=",
624 make_int(31),
625 ),
626 );
627
628 let cases = [
629 (
630 "OR",
631 make_infix(
632 make_infix(make_ident("id"), "=", make_int(2)),
633 "OR",
634 partial.clone(),
635 ),
636 ),
637 (
638 "NOT",
639 ast::Expression::Prefix(ast::PrefixExpression::new(
640 dummy_token(),
641 "NOT",
642 Box::new(partial.clone()),
643 )),
644 ),
645 (
646 "XOR",
647 make_infix(
648 make_infix(make_ident("id"), "=", make_int(2)),
649 "XOR",
650 partial,
651 ),
652 ),
653 ];
654
655 for (name, expr) in cases {
656 let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
657 assert!(needs_mem, "{name} must retain the complete residual");
658 assert!(
659 storage_expr.is_none(),
660 "{name} cannot safely publish a partial child as a complete predicate"
661 );
662 }
663 }
664
665 #[test]
666 fn r4_batch_a_not_preserves_composed_sql_unknown() {
667 let schema = test_schema();
668 let inner = make_infix(
669 make_infix(make_ident("email"), "=", make_str("nobody@example.com")),
670 "OR",
671 make_infix(make_ident("id"), "=", make_int(999)),
672 );
673 let predicate = ast::Expression::Prefix(ast::PrefixExpression::new(
674 dummy_token(),
675 "NOT",
676 Box::new(inner),
677 ));
678 let (storage_expr, needs_mem) = try_pushdown(&predicate, &schema, None);
679 assert!(!needs_mem);
680 let mut storage_expr = storage_expr.expect("predicate should be fully pushable");
681 storage_expr.prepare_for_schema(&schema);
682
683 let row = Row::from_values(vec![
684 Value::integer(1),
685 Value::text("Alice"),
686 Value::integer(30),
687 Value::null_unknown(),
688 Value::Boolean(true),
689 Value::Float(99.99),
690 ]);
691 assert!(
692 !storage_expr.evaluate(&row).unwrap(),
693 "NOT (UNKNOWN OR FALSE) must remain UNKNOWN and fail WHERE admission"
694 );
695 }
696
697 #[test]
698 fn r4_batch_a_unsupported_typed_domain_falls_back() {
699 let schema = SchemaBuilder::new("typed_pushdown")
700 .add_primary_key("id", DataType::Integer)
701 .add("payload", DataType::Bytes)
702 .build();
703 let predicate = make_infix(make_ident("payload"), "=", make_str("abc"));
704 let (storage_expr, needs_mem) = try_pushdown(&predicate, &schema, None);
705 assert!(needs_mem);
706 assert!(storage_expr.is_none());
707 }
708
709 #[test]
710 fn r4_batch_a_nested_function_columns_are_recursively_bound() {
711 let schema = test_schema();
712 let lower = make_function("LOWER", vec![make_ident("name")]);
713 let predicate = make_infix(make_function("LENGTH", vec![lower]), ">", make_int(3));
714 let (storage_expr, needs_mem) = try_pushdown(&predicate, &schema, None);
715 assert!(!needs_mem);
716 let mut storage_expr = storage_expr.expect("nested function should be pushable");
717 storage_expr.prepare_for_schema(&schema);
718 assert!(storage_expr.evaluate(&test_row()).unwrap());
719 }
720
721 #[test]
722 fn r4_batch_b_partial_plan_carries_only_unpushed_residual() {
723 let schema = test_schema();
724 let predicate = make_infix(
725 make_infix(make_ident("id"), "=", make_int(1)),
726 "AND",
727 make_infix(
728 make_infix(make_ident("age"), "+", make_int(1)),
729 ">",
730 make_int(30),
731 ),
732 );
733 let plan = try_pushdown_plan(&predicate, &schema, None);
734 assert!(plan.storage_expr.is_some());
735 let residual = plan.residual.expect("partial plan needs a residual");
736 assert!(residual.to_string().contains('+'));
737 assert!(!residual.to_string().contains("id = 1"));
738 }
739
740 #[test]
741 fn r4_batch_b_invalid_typed_literal_is_not_published_as_null() {
742 let schema = SchemaBuilder::new("typed_pushdown")
743 .add_primary_key("id", DataType::Integer)
744 .add("external_id", DataType::Uuid)
745 .build();
746 let predicate = make_infix(make_ident("external_id"), "=", make_str("not-a-uuid"));
747 let plan = try_pushdown_plan(&predicate, &schema, None);
748 assert!(plan.storage_expr.is_none());
749 assert!(plan.residual.is_some());
750 }
751
752 #[test]
753 fn r4_batch_b_case_function_like_is_not_rewritten_to_ilike() {
754 let schema = test_schema();
755 let predicate = ast::Expression::Like(ast::LikeExpression {
756 token: dummy_token(),
757 left: Box::new(make_function("LOWER", vec![make_ident("name")])),
758 pattern: Box::new(make_str("A%")),
759 operator: "LIKE".into(),
760 escape: None,
761 });
762 let plan = try_pushdown_plan(&predicate, &schema, None);
763 assert!(plan.storage_expr.is_none());
764 assert!(plan.residual.is_some());
765 }
766}