-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(cubesql): Support DATE_ADD SQL pushdown
#11539
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
base: master
Are you sure you want to change the base?
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 |
|---|---|---|
|
|
@@ -2853,3 +2853,148 @@ async fn test_wrapper_only_system_fields() { | |
| displayable(physical_plan.as_ref()).indent() | ||
| ); | ||
| } | ||
|
|
||
| /// A per-group aggregate in a CTE, date-filtered and counted on the outside, must be | ||
| /// pushed down whole: `DATEADD` in the outer filter is rewritten to `DATE_ADD`, so the | ||
| /// filter and the aggregate above it only push down when the data source has a | ||
| /// `functions/DATE_ADD` template. | ||
| #[tokio::test] | ||
| async fn test_wrapper_cte_aggregate_then_date_filter() { | ||
| if !Rewriter::sql_push_down_enabled() { | ||
| return; | ||
| } | ||
| init_testing_logger(); | ||
|
|
||
| let query_plan = convert_select_to_query_plan( | ||
| r#" | ||
| WITH first_orders AS ( | ||
| SELECT customer_gender, MIN(order_date) AS first_order_at | ||
| FROM KibanaSampleDataEcommerce | ||
| WHERE has_subscription = true | ||
| GROUP BY 1 | ||
| ) | ||
| SELECT COUNT(DISTINCT customer_gender) AS customers | ||
| FROM first_orders | ||
| WHERE first_order_at >= DATEADD('month', -12, CURRENT_DATE()) | ||
| AND first_order_at < CURRENT_DATE() | ||
| "# | ||
| .to_string(), | ||
| DatabaseProtocol::PostgreSQL, | ||
| ) | ||
| .await; | ||
|
|
||
| let logical_plan = query_plan.as_logical_plan(); | ||
| let sql = logical_plan.find_cube_scan_wrapped_sql().wrapped_sql.sql; | ||
| assert!( | ||
| sql.contains("COUNT(DISTINCT"), | ||
| "outer aggregate is pushed down: {}", | ||
| sql | ||
| ); | ||
| assert!( | ||
| sql.contains("DATE_ADD"), | ||
| "outer date filter is pushed down: {}", | ||
| sql | ||
| ); | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
||
| let _physical_plan = query_plan.as_physical_plan().await.unwrap(); | ||
| } | ||
|
|
||
| /// `DATEADD` is rewritten to `date_add`, and the dialect template renders it from the | ||
| /// `date_part` and `interval` variables rather than from the arguments. The rewrite maps | ||
| /// every unit onto one of three parts - sub-day units become `MILLISECOND`, `day` and | ||
| /// `week` become `DAY`, and `month`, `quarter` and `year` become `MONTH` - so each dialect | ||
| /// has to render all three. These are the real templates from the query classes; the ones | ||
| /// used elsewhere in these tests take `args_concat` and would not catch a wrong unit. | ||
| #[tokio::test] | ||
| async fn test_wrapper_date_add_dialect_templates() { | ||
| if !Rewriter::sql_push_down_enabled() { | ||
| return; | ||
| } | ||
| init_testing_logger(); | ||
|
|
||
| let dialects = [ | ||
| ( | ||
| // PostgresQuery, RedshiftQuery inherits it, DuckDBQuery repeats it | ||
| "({{ args[0] }} + '{{ interval }} {{ date_part }}'::interval)", | ||
| [ | ||
| "(CURRENT_DATE() + '7200000 MILLISECOND'::interval)", | ||
| "(CURRENT_DATE() + '14 DAY'::interval)", | ||
| "(CURRENT_DATE() + '24 MONTH'::interval)", | ||
| ], | ||
| ), | ||
| ( | ||
| // SnowflakeQuery, MssqlQuery, RedshiftQuery | ||
| "DATEADD({{ date_part }}, {{ interval }}, {{ args[0] }})", | ||
| [ | ||
| "DATEADD(MILLISECOND, 7200000, CURRENT_DATE())", | ||
| "DATEADD(DAY, 14, CURRENT_DATE())", | ||
| "DATEADD(MONTH, 24, CURRENT_DATE())", | ||
| ], | ||
| ), | ||
| ( | ||
| // MysqlQuery: MySQL has no MILLISECOND unit, so those become microseconds | ||
| "DATE_ADD({{ args[0] }}, INTERVAL {% if date_part == \"MILLISECOND\" %}\ | ||
| {{ interval }}000 MICROSECOND{% else %}{{ interval }} {{ date_part }}{% endif %})", | ||
| [ | ||
| "DATE_ADD(CURRENT_DATE(), INTERVAL 7200000000 MICROSECOND)", | ||
| "DATE_ADD(CURRENT_DATE(), INTERVAL 14 DAY)", | ||
| "DATE_ADD(CURRENT_DATE(), INTERVAL 24 MONTH)", | ||
| ], | ||
| ), | ||
| ( | ||
| // ClickHouseQuery, DatabricksQuery | ||
| "({{ args[0] }} + INTERVAL {{ interval }} {{ date_part }})", | ||
| [ | ||
| "(CURRENT_DATE() + INTERVAL 7200000 MILLISECOND)", | ||
| "(CURRENT_DATE() + INTERVAL 14 DAY)", | ||
| "(CURRENT_DATE() + INTERVAL 24 MONTH)", | ||
| ], | ||
| ), | ||
| ( | ||
| // PrestodbQuery, TrinoQuery and AthenaQuery inherit it | ||
| "DATE_ADD('{{ date_part }}', {{ interval }}, {{ args[0] }})", | ||
| [ | ||
| "DATE_ADD('MILLISECOND', 7200000, CURRENT_DATE())", | ||
| "DATE_ADD('DAY', 14, CURRENT_DATE())", | ||
| "DATE_ADD('MONTH', 24, CURRENT_DATE())", | ||
| ], | ||
| ), | ||
| ]; | ||
|
|
||
| for (template, expected) in dialects { | ||
| for (unit, expected) in ["hour", "week", "year"].iter().zip(expected) { | ||
|
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. This matrix is a big improvement over the
Neither is introduced by this PR — the same literal is what DataFusion evaluates in the post-processing path today, so the value is equally wrong without pushdown — but this test is the natural place to catch it, and negative offsets ( The underlying fix is a one-liner at the construction site — build the two halves separately instead of multiplying: Some("hour") => {
let ms = i32::try_from(3_600_000i64 * i64::from(interval_int)).ok()?; // bail if it overflows
ScalarValue::IntervalDayTime(Some(i64::from(ms) & 0xFFFF_FFFF))
}(and the same shape for |
||
| // A filter over a per-group aggregate cannot become a Cube filter, so the | ||
| // whole expression has to be rendered by the template | ||
| let sql = convert_select_to_query_plan_customized( | ||
| format!( | ||
| r#" | ||
| WITH first_orders AS ( | ||
| SELECT customer_gender, MIN(order_date) AS first_order_at | ||
| FROM KibanaSampleDataEcommerce | ||
| GROUP BY 1 | ||
| ) | ||
| SELECT COUNT(DISTINCT customer_gender) AS customers | ||
| FROM first_orders | ||
| WHERE first_order_at > DATEADD('{unit}', 2, CURRENT_DATE()) | ||
| "# | ||
| ), | ||
| DatabaseProtocol::PostgreSQL, | ||
| vec![("functions/DATE_ADD".to_string(), template.to_string())], | ||
| ) | ||
| .await | ||
| .as_logical_plan() | ||
| .find_cube_scan_wrapped_sql() | ||
| .wrapped_sql | ||
| .sql; | ||
|
|
||
| assert!( | ||
| sql.contains(expected), | ||
| "`{}` renders as `{}` with template `{}`, got: {}", | ||
| unit, | ||
| expected, | ||
| template, | ||
| sql | ||
| ); | ||
| } | ||
| } | ||
| } | ||
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.
NANOSECONDis a unit the generator can emit (theIntervalMonthDayNanobranch inwrapper.rs:3168-3181), and Postgres has nonanosecondinterval unit —'7200000000000 NANOSECOND'::intervalerrors out. Same for DuckDB (microsecond granularity) and Trino.Worth checking whether that branch is reachable for the interval literals DataFusion actually produces here; if it is, converting nanos to microseconds/seconds on the Rust side (before it reaches any dialect template) would fix all nine dialects at once rather than each template guarding independently. Note this also inherits into
CrateQuery/FireboltQuery/QuestQuery, which extendPostgresQuery.