Skip to main content

radixdb_executor/pushdown/
mod.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Predicate Pushdown Framework
16//!
17//! This module provides a clean, extensible architecture for predicate pushdown.
18//! Each pushdown rule is self-contained and implements the `PushdownRule` trait.
19//!
20//! ## Adding a New Pushdown Rule
21//!
22//! 1. Create a new struct implementing `PushdownRule`
23//! 2. Register it in `PushdownRegistry::new()`
24//! 3. Done!
25//!
26//! ## Example
27//!
28//! ```ignore
29//! pub struct MyCustomRule;
30//!
31//! impl PushdownRule for MyCustomRule {
32//!     fn name(&self) -> &'static str { "my_custom_rule" }
33//!
34//!     fn try_convert(
35//!         &self,
36//!         expr: &ast::Expression,
37//!         ctx: &PushdownContext<'_>,
38//!     ) -> PushdownResult {
39//!         // Check if this rule applies and convert
40//!     }
41//! }
42//! ```
43
44#[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/// Result of a pushdown attempt
55#[derive(Debug)]
56pub enum PushdownResult {
57    /// Successfully converted to storage expression (fully pushed)
58    Converted(Box<dyn StorageExpr>),
59    /// Partially converted - some parts pushed, but still needs memory filter
60    Partial {
61        storage_expr: Box<dyn StorageExpr>,
62        residual: ast::Expression,
63    },
64    /// This rule doesn't apply to this expression (try next rule)
65    NotApplicable,
66    /// Expression cannot be pushed down (needs memory filter)
67    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
82/// Context for pushdown operations
83pub struct PushdownContext<'a> {
84    /// Schema for type coercion and column index lookup
85    pub schema: &'a Schema,
86    /// Execution context for parameter resolution
87    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    /// Get column data type by name
96    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    /// Check if a column exists in the schema
104    pub fn has_column(&self, name: &str) -> bool {
105        self.schema.has_column(name)
106    }
107
108    /// Coerce value to column type if known
109    pub fn coerce_to_column_type(&self, column: &str, value: Value) -> Option<Value> {
110        if let Some(col_type) = self.column_type(column) {
111            // Comparisons have one canonical numeric identity across INTEGER,
112            // FLOAT and valid DECIMAL values. Coercing a predicate literal to
113            // an INTEGER/FLOAT column here can truncate, saturate or round it
114            // before the storage expression sees the original value. Preserve
115            // the physical numeric variant and let Value::compare apply the
116            // exact mixed-domain contract. DECIMAL columns retain their exact
117            // target coercion because it does not lose valid INTEGER/FLOAT
118            // literal identity.
119            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
138/// Trait for pushdown rules
139///
140/// Each rule is responsible for:
141/// 1. Checking if it can handle an expression
142/// 2. Converting the expression to a storage expression
143pub trait PushdownRule: Send + Sync {
144    /// Rule name for debugging and logging
145    fn name(&self) -> &'static str;
146
147    /// Try to convert an expression to a storage expression.
148    ///
149    /// Returns:
150    /// - `Converted(expr)` if successfully converted
151    /// - `NotApplicable` if this rule doesn't handle this expression type
152    /// - `CannotPush` if the expression matches but cannot be pushed down
153    fn try_convert(&self, expr: &ast::Expression, ctx: &PushdownContext<'_>) -> PushdownResult;
154}
155
156/// Registry of all pushdown rules
157///
158/// The registry tries rules in order and returns the first successful conversion.
159/// Rules are ordered from most specific to most general.
160pub 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    /// Create a new registry with all built-in rules
172    pub fn new() -> Self {
173        let mut registry = Self { rules: vec![] };
174
175        // Register rules in priority order (most specific first)
176        // Logical operators (handle compound expressions)
177        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        // Specific expression types
183        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        // Comparison operators (most common, should be fast)
190        registry.register(Box::new(ComparisonRule));
191
192        // Function expressions (LENGTH(col) > 5, etc.)
193        registry.register(Box::new(FunctionRule));
194
195        // Boolean literals (constant expressions)
196        registry.register(Box::new(BooleanLiteralRule));
197
198        registry
199    }
200
201    /// Register a custom pushdown rule
202    pub fn register(&mut self, rule: Box<dyn PushdownRule>) {
203        self.rules.push(rule);
204    }
205
206    /// Try to push down an expression to storage layer
207    ///
208    /// Returns (`Option<StorageExpr>`, needs_memory_filter)
209    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    /// Internal pushdown with pre-built context (for recursive calls)
220    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                    // Try next rule
260                    continue;
261                }
262            }
263        }
264
265        // No rule matched - need memory filter
266        PushdownPlan {
267            storage_expr: None,
268            residual: Some(expr.clone()),
269        }
270    }
271
272    /// Try to convert an expression, returning only the storage expression
273    /// (for internal use in compound rules)
274    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
288// Global registry instance (lazily initialized)
289use std::sync::OnceLock;
290
291static REGISTRY: OnceLock<PushdownRegistry> = OnceLock::new();
292
293/// Get the global pushdown registry
294pub fn registry() -> &'static PushdownRegistry {
295    REGISTRY.get_or_init(PushdownRegistry::new)
296}
297
298/// Convenience function to try pushdown using the global registry
299pub 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        // Functions are now pushable to storage layer
460        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        // id = 1 AND LENGTH(name) > 5
468        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)); // "Alice" has length 5
478        let expr = make_infix(pushable, "AND", also_pushable);
479
480        let (storage_expr, needs_mem) = try_pushdown(&expr, &schema, None);
481        // Both parts should be pushed now (functions are pushable)
482        assert!(storage_expr.is_some());
483        // No memory filter needed
484        assert!(!needs_mem);
485
486        let mut expr = storage_expr.unwrap();
487        expr.prepare_for_schema(&schema);
488        // id=1 AND LENGTH("Alice")>=5 should be true (1=1 AND 5>=5)
489        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()); // age = 30
510    }
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()); // id = 1
534    }
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()); // name = "Alice"
554    }
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        // email is not null in test_row
574        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        // Both sides are pushable, so OR can be fully pushed
605        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        // id=1 OR LENGTH("Alice")>5 should be true (1=1, so left side is true)
612        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}