-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Implement predicate pruning for not like expressions #14567
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1590,6 +1590,7 @@ fn build_statistics_expr( | |||||||||||||||||||||||||||
| )), | ||||||||||||||||||||||||||||
| )) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Operator::NotLikeMatch => build_not_like_match(expr_builder)?, | ||||||||||||||||||||||||||||
| Operator::LikeMatch => build_like_match(expr_builder).ok_or_else(|| { | ||||||||||||||||||||||||||||
| plan_datafusion_err!( | ||||||||||||||||||||||||||||
| "LIKE expression with wildcard at the beginning is not supported" | ||||||||||||||||||||||||||||
|
|
@@ -1638,6 +1639,19 @@ fn build_statistics_expr( | |||||||||||||||||||||||||||
| Ok(statistics_expr) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// returns the string literal of the scalar value if it is a string | ||||||||||||||||||||||||||||
| fn unpack_string(s: &ScalarValue) -> Option<&str> { | ||||||||||||||||||||||||||||
| s.try_as_str().flatten() | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| fn extract_string_literal(expr: &Arc<dyn PhysicalExpr>) -> Option<&str> { | ||||||||||||||||||||||||||||
| if let Some(lit) = expr.as_any().downcast_ref::<phys_expr::Literal>() { | ||||||||||||||||||||||||||||
| let s = unpack_string(lit.value())?; | ||||||||||||||||||||||||||||
| return Some(s); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| None | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// Convert `column LIKE literal` where P is a constant prefix of the literal | ||||||||||||||||||||||||||||
| /// to a range check on the column: `P <= column && column < P'`, where P' is the | ||||||||||||||||||||||||||||
| /// lowest string after all P* strings. | ||||||||||||||||||||||||||||
|
|
@@ -1650,19 +1664,6 @@ fn build_like_match( | |||||||||||||||||||||||||||
| // column LIKE '%foo%' => min <= '' && '' <= max => true | ||||||||||||||||||||||||||||
| // column LIKE 'foo' => min <= 'foo' && 'foo' <= max | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// returns the string literal of the scalar value if it is a string | ||||||||||||||||||||||||||||
| fn unpack_string(s: &ScalarValue) -> Option<&str> { | ||||||||||||||||||||||||||||
| s.try_as_str().flatten() | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| fn extract_string_literal(expr: &Arc<dyn PhysicalExpr>) -> Option<&str> { | ||||||||||||||||||||||||||||
| if let Some(lit) = expr.as_any().downcast_ref::<phys_expr::Literal>() { | ||||||||||||||||||||||||||||
| let s = unpack_string(lit.value())?; | ||||||||||||||||||||||||||||
| return Some(s); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| None | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // TODO Handle ILIKE perhaps by making the min lowercase and max uppercase | ||||||||||||||||||||||||||||
| // this may involve building the physical expressions that call lower() and upper() | ||||||||||||||||||||||||||||
| let min_column_expr = expr_builder.min_column_expr().ok()?; | ||||||||||||||||||||||||||||
|
|
@@ -1710,6 +1711,80 @@ fn build_like_match( | |||||||||||||||||||||||||||
| Some(combined) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // For predicate `col NOT LIKE 'const_prefix%'`, we rewrite it as `(col_min NOT LIKE 'const_prefix%' OR col_max NOT LIKE 'const_prefix%')`. | ||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||
| // The intuition is that if both `col_min` and `col_max` begin with `const_prefix` that means | ||||||||||||||||||||||||||||
| // **all** data in this row group begins with `const_prefix` as well (and therefore the predicate | ||||||||||||||||||||||||||||
| // looking for rows that don't begin with `const_prefix` can never be true) | ||||||||||||||||||||||||||||
| fn build_not_like_match( | ||||||||||||||||||||||||||||
| expr_builder: &mut PruningExpressionBuilder<'_>, | ||||||||||||||||||||||||||||
| ) -> Result<Arc<dyn PhysicalExpr>> { | ||||||||||||||||||||||||||||
| // col NOT LIKE 'const_prefix%' -> !(col_min LIKE 'const_prefix%' && col_max LIKE 'const_prefix%') -> (col_min NOT LIKE 'const_prefix%' || col_max NOT LIKE 'const_prefix%') | ||||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is not true either the truth is (or should be):
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Why? Does it prune row groups that contain data that does not match the pattern? Or is it just inefficient? First, let's clarify one point: if a row group contains any data that does not match the pattern, then this row group must not be pruned. The expected behavior is to return all data that does not match. If the row group gets pruned, data loss will occur (see this PR). I think this mistakenly prunes row groups that contain data not matching the pattern. Consider this case: |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let min_column_expr = expr_builder.min_column_expr()?; | ||||||||||||||||||||||||||||
| let max_column_expr = expr_builder.max_column_expr()?; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let scalar_expr = expr_builder.scalar_expr(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let pattern = extract_string_literal(scalar_expr).ok_or_else(|| { | ||||||||||||||||||||||||||||
| plan_datafusion_err!("cannot extract literal from NOT LIKE expression") | ||||||||||||||||||||||||||||
| })?; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let (const_prefix, remaining) = split_constant_prefix(pattern); | ||||||||||||||||||||||||||||
| if const_prefix.is_empty() || remaining != "%" { | ||||||||||||||||||||||||||||
| // we can not handle `%` at the beginning or in the middle of the pattern | ||||||||||||||||||||||||||||
| // Example: For pattern "foo%bar", the row group might include values like | ||||||||||||||||||||||||||||
| // ["foobar", "food", "foodbar"], making it unsafe to prune. | ||||||||||||||||||||||||||||
| // Even if the min/max values in the group (e.g., "foobar" and "foodbar") | ||||||||||||||||||||||||||||
| // match the pattern, intermediate values like "food" may not | ||||||||||||||||||||||||||||
| // match the full pattern "foo%bar", making pruning unsafe. | ||||||||||||||||||||||||||||
| // (truncate foo%bar to foo% have same problem) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // we can not handle pattern containing `_` | ||||||||||||||||||||||||||||
| // Example: For pattern "foo_", row groups might contain ["fooa", "fooaa", "foob"], | ||||||||||||||||||||||||||||
| // which means not every row is guaranteed to match the pattern. | ||||||||||||||||||||||||||||
|
Comment on lines
+1735
to
+1745
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a good explanation for the code we didn't write (or we did, but it was deleted).
Suggested change
|
||||||||||||||||||||||||||||
| return Err(plan_datafusion_err!( | ||||||||||||||||||||||||||||
| "NOT LIKE expressions only support constant_prefix+wildcard`%`" | ||||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let min_col_not_like_epxr = Arc::new(phys_expr::LikeExpr::new( | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| false, | ||||||||||||||||||||||||||||
| Arc::clone(&min_column_expr), | ||||||||||||||||||||||||||||
| Arc::clone(scalar_expr), | ||||||||||||||||||||||||||||
|
Comment on lines
+1751
to
+1755
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be col_min >= 'const_prefiy' (and renamed) |
||||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let max_col_not_like_expr = Arc::new(phys_expr::LikeExpr::new( | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| false, | ||||||||||||||||||||||||||||
| Arc::clone(&max_column_expr), | ||||||||||||||||||||||||||||
| Arc::clone(scalar_expr), | ||||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||||
|
Comment on lines
+1758
to
+1763
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be col_max < 'const_prefix' |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Ok(Arc::new(phys_expr::BinaryExpr::new( | ||||||||||||||||||||||||||||
| min_col_not_like_epxr, | ||||||||||||||||||||||||||||
| Operator::Or, | ||||||||||||||||||||||||||||
| max_col_not_like_expr, | ||||||||||||||||||||||||||||
| ))) | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// Returns unescaped constant prefix of a LIKE pattern (possibly empty) and the remaining pattern (possibly empty) | ||||||||||||||||||||||||||||
| fn split_constant_prefix(pattern: &str) -> (&str, &str) { | ||||||||||||||||||||||||||||
| let char_indices = pattern.char_indices().collect::<Vec<_>>(); | ||||||||||||||||||||||||||||
| for i in 0..char_indices.len() { | ||||||||||||||||||||||||||||
| let (idx, char) = char_indices[i]; | ||||||||||||||||||||||||||||
| if char == '%' || char == '_' { | ||||||||||||||||||||||||||||
| if i != 0 && char_indices[i - 1].1 == '\\' { | ||||||||||||||||||||||||||||
| // ecsaped by `\` | ||||||||||||||||||||||||||||
| continue; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| return (&pattern[..idx], &pattern[idx..]); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| (pattern, "") | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /// Increment a UTF8 string by one, returning `None` if it can't be incremented. | ||||||||||||||||||||||||||||
| /// This makes it so that the returned string will always compare greater than the input string | ||||||||||||||||||||||||||||
| /// or any other string with the same prefix. | ||||||||||||||||||||||||||||
|
|
@@ -4061,6 +4136,132 @@ mod tests { | |||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||
| fn prune_utf8_not_like_one() { | ||||||||||||||||||||||||||||
| let (schema, statistics) = utf8_setup(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let expr = col("s1").not_like(lit("A\u{10ffff}_")); | ||||||||||||||||||||||||||||
| #[rustfmt::skip] | ||||||||||||||||||||||||||||
| let expected_ret = &[ | ||||||||||||||||||||||||||||
| // s1 ["A", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", "L"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["N", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["M", "M"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 [NULL, NULL] ==> unknown (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", NULL] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", "A"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", ""] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["AB", "A\u{10ffff}\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A\u{10ffff}\u{10ffff}", "A\u{10ffff}\u{10ffff}"] ==> no row match. (min, max) maybe truncate | ||||||||||||||||||||||||||||
| // orignal (min, max) maybe ("A\u{10ffff}\u{10ffff}\u{10ffff}", "A\u{10ffff}\u{10ffff}\u{10ffff}\u{10ffff}") | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||
| fn prune_utf8_not_like_many() { | ||||||||||||||||||||||||||||
| let (schema, statistics) = utf8_setup(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let expr = col("s1").not_like(lit("A\u{10ffff}%")); | ||||||||||||||||||||||||||||
| #[rustfmt::skip] | ||||||||||||||||||||||||||||
| let expected_ret = &[ | ||||||||||||||||||||||||||||
| // s1 ["A", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", "L"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["N", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["M", "M"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 [NULL, NULL] ==> unknown (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", NULL] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", "A"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", ""] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["AB", "A\u{10ffff}\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A\u{10ffff}\u{10ffff}", "A\u{10ffff}\u{10ffff}"] ==> no row match | ||||||||||||||||||||||||||||
| false, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let expr = col("s1").not_like(lit("A\u{10ffff}%\u{10ffff}")); | ||||||||||||||||||||||||||||
| #[rustfmt::skip] | ||||||||||||||||||||||||||||
| let expected_ret = &[ | ||||||||||||||||||||||||||||
| // s1 ["A", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", "L"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["N", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["M", "M"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 [NULL, NULL] ==> unknown (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", NULL] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", "A"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", ""] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["AB", "A\u{10ffff}\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A\u{10ffff}\u{10ffff}", "A\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let expr = col("s1").not_like(lit("A\u{10ffff}%\u{10ffff}_")); | ||||||||||||||||||||||||||||
| #[rustfmt::skip] | ||||||||||||||||||||||||||||
| let expected_ret = &[ | ||||||||||||||||||||||||||||
| // s1 ["A", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", "L"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["N", "Z"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["M", "M"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 [NULL, NULL] ==> unknown (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A", NULL] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", "A"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["", ""] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["AB", "A\u{10ffff}\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| // s1 ["A\u{10ffff}\u{10ffff}", "A\u{10ffff}\u{10ffff}"] ==> some rows could pass (must keep) | ||||||||||||||||||||||||||||
| true, | ||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let expr = col("s1").not_like(lit("A\\%%")); | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add some negative tests that verify predicates like
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add |
||||||||||||||||||||||||||||
| let statistics = TestStatistics::new().with( | ||||||||||||||||||||||||||||
| "s1", | ||||||||||||||||||||||||||||
| ContainerStats::new_utf8( | ||||||||||||||||||||||||||||
| vec![Some("A%a"), Some("A")], | ||||||||||||||||||||||||||||
| vec![Some("A%c"), Some("A")], | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| let expected_ret = &[false, true]; | ||||||||||||||||||||||||||||
| prune_with_expr(expr, &schema, &statistics, expected_ret); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||
| fn test_rewrite_expr_to_prunable() { | ||||||||||||||||||||||||||||
| let schema = Schema::new(vec![Field::new("a", DataType::Int32, true)]); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Love these fuzz tests! These 3 lines of code give me great confidence that this PR does the right thing 😄