diff --git a/DEPRECATION.md b/DEPRECATION.md index c5e3f98026f37..a896fff6e7c54 100644 --- a/DEPRECATION.md +++ b/DEPRECATION.md @@ -70,6 +70,7 @@ features: | Removed | [Elasticsearch driver](#elasticsearch-driver) | v1.6.0 | v1.7.0 | | Removed | [`context_to_roles`](#context-to-roles) | v1.6.4 | v1.7.0 | | Deprecated | [Node.js 22](#nodejs-22) | v1.7.0 | | +| Deprecated | [Hive driver](#hive-driver) | v1.7.25 | | ### Node.js 8 @@ -454,3 +455,13 @@ The `context_to_roles` configuration option has been removed. Please use `contex Node.js 22 is in maintenance mode from [October 21, 2025][link-nodejs-eol]. This means no more new features, only security updates. Please upgrade to Node.js 24 or higher. + +### Hive driver + +**Deprecated in Release: v1.7.25** + +The Hive / SparkSQL driver (`@cubejs-backend/hive-driver`) is deprecated and will be +removed in a future release. It is community-supported and is not maintained by Cube or +the database vendor. There is no drop-in replacement; `@cubejs-backend/jdbc-driver` +ships Hive/SparkSQL connection settings that can be used through a custom +[`driverFactory`](https://docs.cube.dev/reference/configuration/configuration-options#driverfactory). diff --git a/docs-mintlify/admin/connect-to-data/data-sources/hive.mdx b/docs-mintlify/admin/connect-to-data/data-sources/hive.mdx index 0c36c2ba341e0..d546e1a321343 100644 --- a/docs-mintlify/admin/connect-to-data/data-sources/hive.mdx +++ b/docs-mintlify/admin/connect-to-data/data-sources/hive.mdx @@ -1,11 +1,13 @@ --- title: Hive / SparkSQL -description: The driver for Hive / SparkSQL is community-supported and is not maintained by Cube or the database vendor. +description: The driver for Hive / SparkSQL is deprecated and community-supported, and is not maintained by Cube or the database vendor. --- -The driver for Hive / SparkSQL is community-supported and is not maintained by Cube or the database vendor. +The driver for Hive / SparkSQL is deprecated and will be removed in a future +release. It is community-supported and is not maintained by Cube or the database +vendor. diff --git a/docs-mintlify/docs/explore-analyze/workbooks/querying-data.mdx b/docs-mintlify/docs/explore-analyze/workbooks/querying-data.mdx index bc25e05b7a59c..a49462a39596f 100644 --- a/docs-mintlify/docs/explore-analyze/workbooks/querying-data.mdx +++ b/docs-mintlify/docs/explore-analyze/workbooks/querying-data.mdx @@ -34,7 +34,73 @@ ellipsis. Hover over a truncated cell to see its full value in a tooltip. ## Filtering -Dimensions and measures can be added as filters to focus on specific rows of data. +Dimensions and measures can be added as filters to focus on specific rows of +data. Pick **Filter** in a member's menu in the left pane, or in a result +column's menu, to add the first one. The filter bar then appears above the +results, with a **Filter** button for adding more — you can filter on any member +of the semantic view, whether or not the query selects it. + +Every filter is a chip showing its member, operator and value. Click the chip +to edit it, or use its options menu to remove it. A filter added without a +value yet is highlighted and left out of the query; the query runs as soon as +the value is complete. + +Filtering a dimension keeps or drops rows before aggregation, as a SQL `WHERE` +condition. Filtering a measure applies after aggregation, as a `HAVING` +condition — so `revenue greater than 1000` keeps groups whose total exceeds +1000, not individual orders. + +### Operators + +The operators offered depend on the member's data type, for measures as much as +for dimensions: + +| Data type | Operators | +| --- | --- | +| String | is, is not, contains, not contains, starts with, not starts with, ends with, not ends with, is null, is not null, is empty, is not empty | +| Number | is, is not, greater than, greater than or equal, less than, less than or equal, between, is null, is not null | +| Time | is, is not, after, after or on, before, before or on, between, in the month, not in the month, in the quarter, not in the quarter, in the year, not in the year, is null, is not null | +| Boolean | is, is not, is null, is not null | + +On a number member the comparisons appear on the chip as `=`, `≠`, `>`, `≥`, +`<` and `≤`. + +### Values + +- **is** and **is not** accept several values, matching any of them. On a string + member the value picker lists the member's own values and searches them + server-side, so you select from what the data actually contains. +- The **contains**, **starts with** and **ends with** family matches a + substring, prefix or suffix, ignoring case. +- **between** includes both bounds. +- **is null**, **is not null**, **is empty** and **is not empty** take no value. + Empty means the empty string, which is not the same as null. +- The period operators take any date inside the period — **in the year** with + `2026-03-14` matches all of 2026. +- A date value can be fixed or relative. The date editor's **Relative** tab + takes values like `today`, `7 days ago`, `this month` or `2 weeks from now`, + and resolves them every time the query runs, so the window rolls forward on + its own. The full list is under + [`default_ui_filters`][ref-default-ui-filters]. + +### Combining filters + +Filters in the bar are combined with AND: every one of them must hold. For OR, +and for anything nested, use **Add advanced filter** in the filter picker's +footer. It creates a group, shown as its own chip, whose operator sits as a +toggle between the filters inside it — click it to switch the whole group +between AND and OR. Groups can contain groups, which is how an expression like +`status is completed AND (country is US OR city contains San)` is built. + +A group holds either dimension filters or measure filters, never both, because +the two apply at different stages of the query. Its **Filter** button therefore +offers only the kind of member the group already contains. + +### Custom SQL filters + +**Add custom SQL**, also in the filter picker's footer, takes a SQL condition +and passes it into the query as written. Use it for a condition the operators +above cannot express. ### Default filters diff --git a/docs-mintlify/reference/data-modeling/view.mdx b/docs-mintlify/reference/data-modeling/view.mdx index 75600a3470507..37307e0aa66a1 100644 --- a/docs-mintlify/reference/data-modeling/view.mdx +++ b/docs-mintlify/reference/data-modeling/view.mdx @@ -294,7 +294,7 @@ can change their values, switch operators, or remove them entirely. Each entry has a `member` (a dimension or measure exposed by the view), an `operator`, and a list of `values` — or a single `value`, which is natural for -single-value operators. Null-check operators take no value. +single-value operators. The null and empty checks take no value. @@ -366,13 +366,23 @@ view(`orders_view`, { ##### Operators Operators use the same labels data consumers see in the workbook filter bar — -what you see in the filter bar is what you type: `is`, `is not`, `after date`, -`after or on date`, `before date`, `before or on date`, `between`, `contains`, -`not contains`, `starts with`, `not starts with`, `ends with`, `not ends with`, -`is null`, `is not null`. +what you see in the filter bar is what you type: + +- Any member: `is`, `is not`, `is null`, `is not null`. +- Strings: `contains`, `not contains`, `starts with`, `not starts with`, + `ends with`, `not ends with`, `is empty`, `is not empty`. +- Numbers: `greater than`, `greater than or equal`, `less than`, + `less than or equal`, `between`. +- Dates and times: `after`, `after or on`, `before`, `before or on`, `between`, + and the period comparisons `in the month`, `not in the month`, + `in the quarter`, `not in the quarter`, `in the year`, `not in the year`. A + period comparison takes any date inside the period, so `in the year` with + `2026-03-14` means all of 2026. Operators are case-insensitive and whitespace-tolerant. Internal type names and -REST API aliases (e.g. `equals`, `gte`, `inDateRange`) are also accepted. +REST (JSON) API aliases (e.g. `equals`, `gte`, `inDateRange`) are also accepted, +as are the older `after date` / `before or on date` spellings the filter bar used +before the noun was dropped. On a date or time member, `is` with a relative value is an **equality** at that value's grain, not a range — `is yesterday` matches yesterday only (e.g. diff --git a/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts b/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts index 5d616e756eb44..2516c4a74c5ed 100644 --- a/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts +++ b/packages/cubejs-databricks-jdbc-driver/src/DatabricksQuery.ts @@ -175,6 +175,10 @@ export class DatabricksQuery extends BaseQuery { templates.functions.LTRIM = 'LTRIM({{ args|reverse|join(", ") }})'; templates.functions.RTRIM = 'RTRIM({{ args|reverse|join(", ") }})'; templates.functions.DATEDIFF = 'DATEDIFF({{ date_part }}, DATE_TRUNC(\'{{ date_part }}\', {{ args[1] }}), DATE_TRUNC(\'{{ date_part }}\', {{ args[2] }}))'; + // DATEADD is being rewritten to DATE_ADD. The unquoted multi-unit form is used + // because the ANSI form, INTERVAL '2' HOUR, only spans YEAR to SECOND, while sub-day + // intervals are reported in milliseconds + templates.functions.DATE_ADD = '({{ args[0] }} + INTERVAL {{ interval }} {{ date_part }})'; templates.functions.LEAST = 'LEAST({{ args_concat }})'; templates.functions.GREATEST = 'GREATEST({{ args_concat }})'; templates.functions.TRUNC = 'CASE WHEN ({{ args[0] }}) >= 0 THEN FLOOR({{ args_concat }}) ELSE CEIL({{ args_concat }}) END'; diff --git a/packages/cubejs-duckdb-driver/src/DuckDBQuery.ts b/packages/cubejs-duckdb-driver/src/DuckDBQuery.ts index 197bbc55881a9..9f764e278c49d 100644 --- a/packages/cubejs-duckdb-driver/src/DuckDBQuery.ts +++ b/packages/cubejs-duckdb-driver/src/DuckDBQuery.ts @@ -67,6 +67,8 @@ export class DuckDBQuery extends BaseQuery { templates.functions.LEAST = 'LEAST({{ args_concat }})'; templates.functions.GREATEST = 'GREATEST({{ args_concat }})'; templates.functions.STRING_AGG = 'STRING_AGG({% if distinct %}DISTINCT {% endif %}{{ args[0] }}, COALESCE({{ args[1] }}, \'\'))'; + // DATEADD is being rewritten to DATE_ADD + templates.functions.DATE_ADD = '({{ args[0] }} + \'{{ interval }} {{ date_part }}\'::interval)'; delete templates.functions.WIDTH_BUCKET; templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}'; templates.expressions.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\'{% endif %}'; diff --git a/packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts b/packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts index 9c2187bc60878..6a988b54e92f8 100644 --- a/packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/ClickHouseQuery.ts @@ -265,6 +265,9 @@ export class ClickHouseQuery extends BaseQuery { templates.functions.DATETRUNC = 'DATE_TRUNC({{ args_concat }})'; templates.functions.UTCTIMESTAMP = 'now(\'UTC\')'; templates.functions.STRING_AGG = 'arrayStringConcat(group{% if distinct %}Uniq{% endif %}Array({{ args[0] }}), {{ args[1] }})'; + // DATEADD is being rewritten to DATE_ADD. The operator form is used instead of + // addDate(), which only exists since ClickHouse 23.9 + templates.functions.DATE_ADD = '({{ args[0] }} + INTERVAL {{ interval }} {{ date_part }})'; // TODO: Introduce additional filter in jinja? or parseDateTimeBestEffort? // https://github.com/ClickHouse/ClickHouse/issues/19351 templates.expressions.timestamp_literal = 'parseDateTimeBestEffort(\'{{ value }}\')'; diff --git a/packages/cubejs-schema-compiler/src/adapter/MssqlQuery.ts b/packages/cubejs-schema-compiler/src/adapter/MssqlQuery.ts index 7538ff5e205a9..cfde724a5c916 100644 --- a/packages/cubejs-schema-compiler/src/adapter/MssqlQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/MssqlQuery.ts @@ -271,6 +271,8 @@ export class MssqlQuery extends BaseQuery { templates.functions.UTCTIMESTAMP = 'GETUTCDATE()'; // MSSQL ROUND requires 2 arguments: ROUND(number, length) templates.functions.ROUND = 'ROUND({{ args_concat }}{% if args | length < 2 %}, 0{% endif %})'; + // DATEADD is being rewritten to DATE_ADD + templates.functions.DATE_ADD = 'DATEADD({{ date_part }}, {{ interval }}, {{ args[0] }})'; // NOTE: MSSQL does not support DISTINCT clause. No workaround is available delete templates.functions.STRING_AGG; // PERCENTILE_CONT works but requires PARTITION BY diff --git a/packages/cubejs-schema-compiler/src/adapter/MysqlQuery.ts b/packages/cubejs-schema-compiler/src/adapter/MysqlQuery.ts index 243e47b7a9f83..bf0c2c01e0011 100644 --- a/packages/cubejs-schema-compiler/src/adapter/MysqlQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/MysqlQuery.ts @@ -187,6 +187,11 @@ export class MysqlQuery extends BaseQuery { const templates = super.sqlTemplates(); templates.functions.STRING_AGG = 'GROUP_CONCAT({% if distinct %}DISTINCT {% endif %}{{ args[0] }} SEPARATOR {{ args[1] }})'; templates.functions.UTCTIMESTAMP = 'UTC_TIMESTAMP()'; + // DATEADD is being rewritten to DATE_ADD, which reports sub-day intervals in + // milliseconds. MySQL has no MILLISECOND unit, so those are scaled to microseconds + templates.functions.DATE_ADD = 'DATE_ADD({{ args[0] }}, INTERVAL ' + + '{% if date_part == "MILLISECOND" %}{{ interval }}000 MICROSECOND' + + '{% else %}{{ interval }} {{ date_part }}{% endif %})'; // PERCENTILE_CONT works but requires PARTITION BY delete templates.functions.PERCENTILECONT; delete templates.functions.WIDTH_BUCKET; diff --git a/packages/cubejs-schema-compiler/src/adapter/PostgresQuery.ts b/packages/cubejs-schema-compiler/src/adapter/PostgresQuery.ts index 5ebd05f05e8f1..4ebed37d312dc 100644 --- a/packages/cubejs-schema-compiler/src/adapter/PostgresQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/PostgresQuery.ts @@ -88,7 +88,7 @@ export class PostgresQuery extends BaseQuery { templates.functions.NOW = 'NOW({{ args_concat }})'; templates.functions.UTCTIMESTAMP = '(NOW() AT TIME ZONE \'UTC\')'; // DATEADD is being rewritten to DATE_ADD - // templates.functions.DATEADD = '({{ args[2] }} + \'{{ interval }} {{ date_part }}\'::interval)'; + templates.functions.DATE_ADD = '({{ args[0] }} + \'{{ interval }} {{ date_part }}\'::interval)'; // TODO: is DATEDIFF expr worth documenting? templates.functions.DATEDIFF = 'CASE WHEN LOWER(\'{{ date_part }}\') IN (\'year\', \'quarter\', \'month\') THEN (EXTRACT(YEAR FROM AGE(DATE_TRUNC(\'{{ date_part }}\', {{ args[2] }}), DATE_TRUNC(\'{{ date_part }}\', {{ args[1] }}))) * 12 + EXTRACT(MONTH FROM AGE(DATE_TRUNC(\'{{ date_part }}\', {{ args[2] }}), DATE_TRUNC(\'{{ date_part }}\', {{ args[1] }})))) / CASE LOWER(\'{{ date_part }}\') WHEN \'year\' THEN 12 WHEN \'quarter\' THEN 3 WHEN \'month\' THEN 1 END ELSE EXTRACT(EPOCH FROM DATE_TRUNC(\'{{ date_part }}\', {{ args[2] }}) - DATE_TRUNC(\'{{ date_part }}\', {{ args[1] }})) / EXTRACT(EPOCH FROM \'1 {{ date_part }}\'::interval) END::bigint'; templates.expressions.interval = 'INTERVAL \'{{ interval }}\''; diff --git a/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts b/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts index ea1ccea38c59c..64e9d0c551ec0 100644 --- a/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/PrestodbQuery.ts @@ -163,6 +163,8 @@ export class PrestodbQuery extends BaseQuery { templates.functions.DATETRUNC = 'DATE_TRUNC({{ args_concat }})'; templates.functions.DATEPART = 'DATE_PART({{ args_concat }})'; templates.functions.DATEDIFF = 'DATE_DIFF(\'{{ date_part }}\', {{ args[1] }}, {{ args[2] }})'; + // DATEADD is being rewritten to DATE_ADD + templates.functions.DATE_ADD = 'DATE_ADD(\'{{ date_part }}\', {{ interval }}, {{ args[0] }})'; templates.functions.CURRENTDATE = 'CURRENT_DATE'; templates.functions.UTCTIMESTAMP = 'CAST(NOW() AT TIME ZONE \'UTC\' AS TIMESTAMP)'; templates.functions.TRUNC = 'TRUNCATE({{ args_concat }})'; diff --git a/packages/cubejs-schema-compiler/src/adapter/RedshiftQuery.ts b/packages/cubejs-schema-compiler/src/adapter/RedshiftQuery.ts index e1063e0dd6597..2c7cb9b44f727 100644 --- a/packages/cubejs-schema-compiler/src/adapter/RedshiftQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/RedshiftQuery.ts @@ -88,6 +88,8 @@ export class RedshiftQuery extends PostgresQuery { // nodes, unlike NOW(), which is a leader node–only function. templates.functions.UTCTIMESTAMP = 'GETDATE()'; templates.functions.DATEDIFF = 'DATEDIFF({{ date_part }}, {{ args[1] }}, {{ args[2] }})'; + // DATEADD is being rewritten to DATE_ADD + templates.functions.DATE_ADD = 'DATEADD({{ date_part }}, {{ interval }}, {{ args[0] }})'; templates.functions.STRING_AGG = 'LISTAGG({% if distinct %}DISTINCT {% endif %}{{ args_concat }})'; templates.statements.time_series_select = 'SELECT dates.f::timestamp date_from, dates.t::timestamp date_to \n' + 'FROM (\n' + diff --git a/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts b/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts index fc3e2e8d3c33c..02e3f077c0227 100644 --- a/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts @@ -114,6 +114,8 @@ export class SnowflakeQuery extends BaseQuery { templates.functions.CHARACTERLENGTH = 'LENGTH({{ args[0] }})'; templates.functions.BTRIM = 'TRIM({{ args_concat }})'; templates.functions.STRING_AGG = 'LISTAGG({% if distinct %}DISTINCT {% endif %}{{ args_concat }})'; + // DATEADD is being rewritten to DATE_ADD + templates.functions.DATE_ADD = 'DATEADD({{ date_part }}, {{ interval }}, {{ args[0] }})'; templates.expressions.extract = 'EXTRACT({{ date_part }} FROM {{ expr }})'; // Snowflake `/` is decimal division even for integer operands (output scale // is dividend scale + 6), while this template must keep PostgreSQL integer diff --git a/rust/cubesql/cubesql/src/compile/rewrite/rules/dates.rs b/rust/cubesql/cubesql/src/compile/rewrite/rules/dates.rs index db7da8031ebc4..564447dd5a136 100644 --- a/rust/cubesql/cubesql/src/compile/rewrite/rules/dates.rs +++ b/rust/cubesql/cubesql/src/compile/rewrite/rules/dates.rs @@ -15,7 +15,7 @@ use crate::{ var, var_iter, }; use datafusion::{ - arrow::datatypes::{DataType, DataType as ArrowDataType, TimeUnit}, + arrow::datatypes::{DataType, DataType as ArrowDataType, IntervalDayTimeType, TimeUnit}, logical_plan::DFSchema, scalar::ScalarValue, }; @@ -525,31 +525,61 @@ impl DateRules { _ => return false, }; + // `IntervalDayTime` keeps days and milliseconds in separate halves of an + // i64, so a plain millisecond count only reads back as written while it is + // positive and fits in the lower half. Build it the way DataFusion does. + // + // Sub-day units are counted in milliseconds, which runs out around 24 days, + // so whole days are carried into the other half to keep ordinary spans like + // `DATEADD('hour', 720, ...)` expressible. Cube reads time dimensions as + // naive UTC timestamps, where a day is always 24 hours, so the two halves + // measure the same thing. + // + // What is left over is a span that is neither day aligned nor small enough + // to count in milliseconds. Producing a value that decodes to a different + // amount of time would be worse than not rewriting: without the rewrite the + // original `dateadd` remains, and it is a stub that reports + // `NotImplemented` at execution rather than answering with a wrong date. + let day_time = |days: i64, millis: i64| { + const MILLIS_PER_DAY: i64 = 24 * 60 * 60 * 1_000; + let (days, millis) = if i32::try_from(millis).is_err() { + (days + millis / MILLIS_PER_DAY, millis % MILLIS_PER_DAY) + } else { + (days, millis) + }; + // The two halves are read back separately, so only one of them can + // carry the span + if days != 0 && millis != 0 { + return None; + } + let days = i32::try_from(days).ok()?; + let millis = i32::try_from(millis).ok()?; + Some(ScalarValue::IntervalDayTime(Some( + IntervalDayTimeType::make_value(days, millis), + ))) + }; + let year_month = |months: i64| { + let months = i32::try_from(months).ok()?; + Some(ScalarValue::IntervalYearMonth(Some(months))) + }; + let interval_int = i64::from(interval_int); + for datepart in var_iter!(egraph[subst[datepart_var]], LiteralExprValue).cloned() { let interval = match utils::parse_granularity(&datepart, false).as_deref() { - Some("millisecond") => { - ScalarValue::IntervalDayTime(Some(i64::from(interval_int))) - } - Some("second") => { - ScalarValue::IntervalDayTime(Some(1000 * i64::from(interval_int))) - } - Some("minute") => { - ScalarValue::IntervalDayTime(Some(60_000 * i64::from(interval_int))) - } - Some("hour") => { - ScalarValue::IntervalDayTime(Some(3_600_000 * i64::from(interval_int))) - } - Some("day") => ScalarValue::IntervalDayTime(Some( - 4_294_967_296 * i64::from(interval_int), - )), - Some("week") => ScalarValue::IntervalDayTime(Some( - 30_064_771_072 * i64::from(interval_int), - )), - Some("month") => ScalarValue::IntervalYearMonth(Some(interval_int)), - Some("quarter") => ScalarValue::IntervalYearMonth(Some(3 * interval_int)), - Some("year") => ScalarValue::IntervalYearMonth(Some(12 * interval_int)), + Some("millisecond") => day_time(0, interval_int), + Some("second") => day_time(0, 1_000 * interval_int), + Some("minute") => day_time(0, 60_000 * interval_int), + Some("hour") => day_time(0, 3_600_000 * interval_int), + Some("day") => day_time(interval_int, 0), + Some("week") => day_time(7 * interval_int, 0), + Some("month") => year_month(interval_int), + Some("quarter") => year_month(3 * interval_int), + Some("year") => year_month(12 * interval_int), _ => continue, }; + let Some(interval) = interval else { + continue; + }; if let Some(original_expr) = original_expr_name(egraph, root) { let alias = egraph.add(LogicalPlanLanguage::AliasExprAlias( diff --git a/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs b/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs index e202347037baa..edd4aa3ff37fe 100644 --- a/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs +++ b/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs @@ -16,6 +16,7 @@ use crate::{ test::{ convert_select_to_query_plan, convert_select_to_query_plan_customized, convert_select_to_query_plan_with_config, init_testing_logger, LogicalPlanTestUtils, + TestContext, }, DatabaseProtocol, }, @@ -2853,3 +2854,233 @@ 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 + ); + + 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) { + // 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 + ); + } + } +} + +/// Sub-day units are reported as a millisecond count inside an `IntervalDayTime`, which +/// keeps days and milliseconds in separate halves of an i64 and reads them back +/// separately. A negative offset used to decode as a mixed interval and fail the query, +/// and a count past the 31 bit half used to wrap silently to a different amount of time, +/// so both signs and the range boundary are covered here. Whole days are carried into the +/// other half, which is what keeps ordinary spans past that boundary expressible. The +/// encoding is the same for every dialect, so one template stands in for all of them. +#[tokio::test] +async fn test_wrapper_date_add_negative_and_out_of_range_intervals() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let context = TestContext::with_custom_templates( + DatabaseProtocol::PostgreSQL, + vec![( + "functions/DATE_ADD".to_string(), + "DATEADD({{ date_part }}, {{ interval }}, {{ args[0] }})".to_string(), + )], + ) + .await; + + let cases = [ + ("hour", "-2", Some("DATEADD(MILLISECOND, -7200000, ")), + ("minute", "-30", Some("DATEADD(MILLISECOND, -1800000, ")), + ("second", "-90", Some("DATEADD(MILLISECOND, -90000, ")), + ("day", "-2", Some("DATEADD(DAY, -2, ")), + ("week", "-2", Some("DATEADD(DAY, -14, ")), + ("month", "-12", Some("DATEADD(MONTH, -12, ")), + ("year", "-1", Some("DATEADD(MONTH, -12, ")), + // The last span the millisecond half can hold on its own, and the first one past + // it, which is day aligned and so is carried into the other half + ("hour", "596", Some("DATEADD(MILLISECOND, 2145600000, ")), + ("hour", "720", Some("DATEADD(DAY, 30, ")), + ("hour", "-720", Some("DATEADD(DAY, -30, ")), + ("minute", "43200", Some("DATEADD(DAY, 30, ")), + // Neither day aligned nor small enough to count in milliseconds. The rewrite is + // skipped, which leaves the `dateadd` stub to report `NotImplemented` at + // execution rather than answering with a different date + ("hour", "597", None), + ("hour", "1000000", None), + ]; + + for (unit, amount, expected) in cases { + let logical_plan = context + .convert_sql_to_cube_query(&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}', {amount}, CURRENT_DATE()) + "# + )) + .await + .unwrap_or_else(|error| panic!("`{}` by {} should compile: {}", unit, amount, error)) + .as_logical_plan(); + + let Some(expected) = expected else { + assert!( + logical_plan.find_filter().is_some(), + "`{}` by {} is not rewritten: {:?}", + unit, + amount, + logical_plan + ); + continue; + }; + + let sql = logical_plan.find_cube_scan_wrapped_sql().wrapped_sql.sql; + assert!( + sql.contains(expected), + "`{}` by {} renders as `{}`, got: {}", + unit, + amount, + expected, + sql + ); + } +}