From a419eb583a6dac0344d80adc015bd125e6eb79d9 Mon Sep 17 00:00:00 2001 From: fivetran-amrutabhimsenayachit Date: Tue, 13 Jan 2026 12:45:03 -0500 Subject: [PATCH 1/2] feat(duckdb): Add transpilation support for NEXT_DAY function --- sqlglot/dialects/duckdb.py | 75 ++++++++++++++++++++++++++++++++ tests/dialects/test_snowflake.py | 48 ++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/sqlglot/dialects/duckdb.py b/sqlglot/dialects/duckdb.py index 2c9827a9a3..687334093c 100644 --- a/sqlglot/dialects/duckdb.py +++ b/sqlglot/dialects/duckdb.py @@ -144,6 +144,80 @@ def _last_day_sql(self: DuckDB.Generator, expression: exp.LastDay) -> str: return self.function_fallback_sql(expression) +def _next_day_sql(self: DuckDB.Generator, expression: exp.NextDay) -> str: + """ + Transpile Snowflake's NEXT_DAY to DuckDB using date arithmetic. + + Returns the DATE of the next occurrence of the specified weekday. + + Formula: (target_dow - current_dow + 6) % 7 + 1 + + The +6 normalizes negative differences and the final +1 prevents zero results. + + Examples: + NEXT_DAY('2024-01-01' (Monday), 'Monday') + → (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days → 2024-01-08 + + NEXT_DAY('2024-01-01' (Monday), 'Friday') + → (5 - 1 + 6) % 7 + 1 = 10 % 7 + 1 = 4 days → 2024-01-05 + + """ + date_expr = expression.this + day_name_expr = expression.expression + + # Handle NULL inputs - return CAST(NULL AS DATE) + if isinstance(date_expr, exp.Null) or isinstance(day_name_expr, exp.Null): + return self.sql(exp.cast(exp.Null(), exp.DataType.Type.DATE)) + + # Only support literal day names (not columns/expressions) + if not isinstance(day_name_expr, exp.Literal): + self.unsupported("NEXT_DAY with non-literal day name not supported in DuckDB") + return self.function_fallback_sql(expression) + + # Extract and normalize day name + day_name_str = str(day_name_expr.this).upper() + if len(day_name_str) < 2: + self.unsupported("Day name must be at least 2 characters") + return self.function_fallback_sql(expression) + + # Find matching day in WEEK_START_DAY_TO_DOW (handles both full names and abbreviations) + # e.g., "MONDAY" matches "MONDAY", "MO" matches "MONDAY", "FRI" matches "FRIDAY" + matching_day = next( + (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None + ) + if not matching_day: + self.unsupported(f"Invalid day name or abbreviation: {day_name_str}") + return self.function_fallback_sql(expression) + + target_dow = WEEK_START_DAY_TO_DOW[matching_day] + + # Build the calculation: (target - ISODOW(date) + 6) % 7 + 1 + isodow_call = exp.func("ISODOW", date_expr) + + # Step 1: target - ISODOW(date) + 6 + days_expr = exp.Add( + this=exp.Sub(this=exp.Literal.number(target_dow), expression=isodow_call), + expression=exp.Literal.number(6), + ) + + # Step 2: (...) % 7 + mod_expr = exp.Mod(this=exp.Paren(this=days_expr), expression=exp.Literal.number(7)) + + # Step 3: ... + 1 + days_to_add = exp.Add(this=mod_expr, expression=exp.Literal.number(1)) + + # Build final: CAST(date + INTERVAL (days_to_add) DAY AS DATE) + result = exp.cast( + exp.Add( + this=date_expr, + expression=exp.Interval(this=days_to_add, unit=exp.var("DAY")), + ), + exp.DataType.Type.DATE, + ) + + return self.sql(result) + + def _is_nanosecond_unit(unit: t.Optional[exp.Expression]) -> bool: return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND" @@ -1576,6 +1650,7 @@ class Generator(generator.Generator): exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), exp.DateBin: rename_func("TIME_BUCKET"), exp.LastDay: _last_day_sql, + exp.NextDay: _next_day_sql, } SUPPORTED_JSON_PATH_PARTS = { diff --git a/tests/dialects/test_snowflake.py b/tests/dialects/test_snowflake.py index 982fc03072..a5cfb184f9 100644 --- a/tests/dialects/test_snowflake.py +++ b/tests/dialects/test_snowflake.py @@ -1930,6 +1930,54 @@ def test_snowflake(self): }, ) + self.validate_all( + "NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", + write={ + "snowflake": "NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", + "duckdb": "CAST(CAST('2024-01-01' AS DATE) + INTERVAL ((1 - ISODOW(CAST('2024-01-01' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", + write={ + "snowflake": "NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", + "duckdb": "CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((5 - ISODOW(CAST('2024-01-05' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", + write={ + "snowflake": "NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", + "duckdb": "CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((3 - ISODOW(CAST('2024-01-05' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", + write={ + "snowflake": "NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", + "duckdb": "CAST(CAST('2024-01-01 10:30:45' AS TIMESTAMP) + INTERVAL ((5 - ISODOW(CAST('2024-01-01 10:30:45' AS TIMESTAMP)) + 6) % 7 + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "NEXT_DAY(NULL, 'Monday')", + write={ + "snowflake": "NEXT_DAY(NULL, 'Monday')", + "duckdb": "CAST(NULL AS DATE)", + }, + ) + + self.validate_all( + "NEXT_DAY(CAST('2024-01-01' AS DATE), NULL)", + write={ + "snowflake": "NEXT_DAY(CAST('2024-01-01' AS DATE), NULL)", + "duckdb": "CAST(NULL AS DATE)", + }, + ) + self.validate_all( "SELECT ST_DISTANCE(a, b)", write={ From 9bb544bc2429fa13eb7e7ee65aa196d22b951393 Mon Sep 17 00:00:00 2001 From: fivetran-amrutabhimsenayachit Date: Wed, 14 Jan 2026 12:29:32 -0500 Subject: [PATCH 2/2] feat(duckdb): Addressed review comments --- sqlglot/dialects/duckdb.py | 149 +++++++++++++++---------------- tests/dialects/test_snowflake.py | 122 +++++++++++++++---------- 2 files changed, 148 insertions(+), 123 deletions(-) diff --git a/sqlglot/dialects/duckdb.py b/sqlglot/dialects/duckdb.py index 687334093c..655ec45e05 100644 --- a/sqlglot/dialects/duckdb.py +++ b/sqlglot/dialects/duckdb.py @@ -144,80 +144,6 @@ def _last_day_sql(self: DuckDB.Generator, expression: exp.LastDay) -> str: return self.function_fallback_sql(expression) -def _next_day_sql(self: DuckDB.Generator, expression: exp.NextDay) -> str: - """ - Transpile Snowflake's NEXT_DAY to DuckDB using date arithmetic. - - Returns the DATE of the next occurrence of the specified weekday. - - Formula: (target_dow - current_dow + 6) % 7 + 1 - - The +6 normalizes negative differences and the final +1 prevents zero results. - - Examples: - NEXT_DAY('2024-01-01' (Monday), 'Monday') - → (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days → 2024-01-08 - - NEXT_DAY('2024-01-01' (Monday), 'Friday') - → (5 - 1 + 6) % 7 + 1 = 10 % 7 + 1 = 4 days → 2024-01-05 - - """ - date_expr = expression.this - day_name_expr = expression.expression - - # Handle NULL inputs - return CAST(NULL AS DATE) - if isinstance(date_expr, exp.Null) or isinstance(day_name_expr, exp.Null): - return self.sql(exp.cast(exp.Null(), exp.DataType.Type.DATE)) - - # Only support literal day names (not columns/expressions) - if not isinstance(day_name_expr, exp.Literal): - self.unsupported("NEXT_DAY with non-literal day name not supported in DuckDB") - return self.function_fallback_sql(expression) - - # Extract and normalize day name - day_name_str = str(day_name_expr.this).upper() - if len(day_name_str) < 2: - self.unsupported("Day name must be at least 2 characters") - return self.function_fallback_sql(expression) - - # Find matching day in WEEK_START_DAY_TO_DOW (handles both full names and abbreviations) - # e.g., "MONDAY" matches "MONDAY", "MO" matches "MONDAY", "FRI" matches "FRIDAY" - matching_day = next( - (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None - ) - if not matching_day: - self.unsupported(f"Invalid day name or abbreviation: {day_name_str}") - return self.function_fallback_sql(expression) - - target_dow = WEEK_START_DAY_TO_DOW[matching_day] - - # Build the calculation: (target - ISODOW(date) + 6) % 7 + 1 - isodow_call = exp.func("ISODOW", date_expr) - - # Step 1: target - ISODOW(date) + 6 - days_expr = exp.Add( - this=exp.Sub(this=exp.Literal.number(target_dow), expression=isodow_call), - expression=exp.Literal.number(6), - ) - - # Step 2: (...) % 7 - mod_expr = exp.Mod(this=exp.Paren(this=days_expr), expression=exp.Literal.number(7)) - - # Step 3: ... + 1 - days_to_add = exp.Add(this=mod_expr, expression=exp.Literal.number(1)) - - # Build final: CAST(date + INTERVAL (days_to_add) DAY AS DATE) - result = exp.cast( - exp.Add( - this=date_expr, - expression=exp.Interval(this=days_to_add, unit=exp.var("DAY")), - ), - exp.DataType.Type.DATE, - ) - - return self.sql(result) - - def _is_nanosecond_unit(unit: t.Optional[exp.Expression]) -> bool: return isinstance(unit, (exp.Var, exp.Literal)) and unit.name.upper() == "NANOSECOND" @@ -702,6 +628,78 @@ def _prepare_binary_bitwise_args(expression: exp.Binary) -> None: expression.set("expression", _cast_to_bit(expression.expression)) +def _day_navigation_sql( + self: DuckDB.Generator, expression: t.Union[exp.NextDay, exp.PreviousDay] +) -> str: + """ + Transpile Snowflake's NEXT_DAY / PREVIOUS_DAY to DuckDB using date arithmetic. + + Returns the DATE of the next/previous occurrence of the specified weekday. + + Formulas: + - NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 + - PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 + + Supports both literal and non-literal day names: + - Literal: Direct lookup (e.g., 'Monday' → 1) + - Non-literal: CASE statement for runtime evaluation + + Examples: + NEXT_DAY('2024-01-01' (Monday), 'Monday') + → (1 - 1 + 6) % 7 + 1 = 6 % 7 + 1 = 7 days → 2024-01-08 + + PREVIOUS_DAY('2024-01-15' (Monday), 'Friday') + → (1 - 5 + 6) % 7 + 1 = 2 % 7 + 1 = 3 days → 2024-01-12 + """ + date_expr = expression.this + day_name_expr = expression.expression + + # Build ISODOW call for current day of week + isodow_call = exp.func("ISODOW", date_expr) + + # Determine target day of week + target_dow: exp.Expression + if isinstance(day_name_expr, exp.Literal): + # Literal day name: lookup target_dow directly + day_name_str = str(day_name_expr.this).upper() + matching_day = next( + (day for day in WEEK_START_DAY_TO_DOW if day.startswith(day_name_str)), None + ) + if matching_day: + target_dow = exp.Literal.number(WEEK_START_DAY_TO_DOW[matching_day]) + else: + # Unrecognized day name, use fallback + return self.function_fallback_sql(expression) + else: + # Non-literal day name: build CASE statement for runtime mapping + upper_day_name = exp.Upper(this=day_name_expr.copy()) + target_dow = exp.Case( + ifs=[ + exp.If( + this=exp.func( + "STARTS_WITH", upper_day_name.copy(), exp.Literal.string(day[:2]) + ), + true=exp.Literal.number(dow_num), + ) + for day, dow_num in WEEK_START_DAY_TO_DOW.items() + ] + ) + + # Calculate days offset and apply interval based on direction + if isinstance(expression, exp.NextDay): + # NEXT_DAY: (target_dow - current_dow + 6) % 7 + 1 + days_offset = exp.paren(target_dow - isodow_call + 6, copy=False) % 7 + 1 + date_with_offset = date_expr + exp.Interval(this=days_offset, unit=exp.var("DAY")) + else: # exp.PreviousDay + # PREVIOUS_DAY: (current_dow - target_dow + 6) % 7 + 1 + days_offset = exp.paren(isodow_call - target_dow + 6, copy=False) % 7 + 1 + date_with_offset = date_expr - exp.Interval(this=days_offset, unit=exp.var("DAY")) + + # Build final: CAST(date_with_offset AS DATE) + result = exp.cast(date_with_offset, exp.DataType.Type.DATE) + return self.sql(result) + + def _anyvalue_sql(self: DuckDB.Generator, expression: exp.AnyValue) -> str: # Transform ANY_VALUE(expr HAVING MAX/MIN having_expr) to ARG_MAX_NULL/ARG_MIN_NULL having = expression.this @@ -1554,11 +1552,13 @@ class Generator(generator.Generator): exp.SHA1Digest: lambda self, e: self.func("UNHEX", self.func("SHA1", e.this)), exp.SHA2Digest: lambda self, e: self.func("UNHEX", sha2_digest_sql(self, e)), exp.MonthsBetween: months_between_sql, + exp.NextDay: _day_navigation_sql, exp.PercentileCont: rename_func("QUANTILE_CONT"), exp.PercentileDisc: rename_func("QUANTILE_DISC"), # DuckDB doesn't allow qualified columns inside of PIVOT expressions. # See: https://github.com/duckdb/duckdb/blob/671faf92411182f81dce42ac43de8bfb05d9909e/src/planner/binder/tableref/bind_pivot.cpp#L61-L62 exp.Pivot: transforms.preprocess([transforms.unqualify_columns]), + exp.PreviousDay: _day_navigation_sql, exp.RegexpReplace: lambda self, e: self.func( "REGEXP_REPLACE", e.this, @@ -1650,7 +1650,6 @@ class Generator(generator.Generator): exp.JSONBObjectAgg: rename_func("JSON_GROUP_OBJECT"), exp.DateBin: rename_func("TIME_BUCKET"), exp.LastDay: _last_day_sql, - exp.NextDay: _next_day_sql, } SUPPORTED_JSON_PATH_PARTS = { diff --git a/tests/dialects/test_snowflake.py b/tests/dialects/test_snowflake.py index a5cfb184f9..2e7534e146 100644 --- a/tests/dialects/test_snowflake.py +++ b/tests/dialects/test_snowflake.py @@ -1930,54 +1930,6 @@ def test_snowflake(self): }, ) - self.validate_all( - "NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", - write={ - "snowflake": "NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", - "duckdb": "CAST(CAST('2024-01-01' AS DATE) + INTERVAL ((1 - ISODOW(CAST('2024-01-01' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", - }, - ) - - self.validate_all( - "NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", - write={ - "snowflake": "NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", - "duckdb": "CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((5 - ISODOW(CAST('2024-01-05' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", - }, - ) - - self.validate_all( - "NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", - write={ - "snowflake": "NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", - "duckdb": "CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((3 - ISODOW(CAST('2024-01-05' AS DATE)) + 6) % 7 + 1) DAY AS DATE)", - }, - ) - - self.validate_all( - "NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", - write={ - "snowflake": "NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", - "duckdb": "CAST(CAST('2024-01-01 10:30:45' AS TIMESTAMP) + INTERVAL ((5 - ISODOW(CAST('2024-01-01 10:30:45' AS TIMESTAMP)) + 6) % 7 + 1) DAY AS DATE)", - }, - ) - - self.validate_all( - "NEXT_DAY(NULL, 'Monday')", - write={ - "snowflake": "NEXT_DAY(NULL, 'Monday')", - "duckdb": "CAST(NULL AS DATE)", - }, - ) - - self.validate_all( - "NEXT_DAY(CAST('2024-01-01' AS DATE), NULL)", - write={ - "snowflake": "NEXT_DAY(CAST('2024-01-01' AS DATE), NULL)", - "duckdb": "CAST(NULL AS DATE)", - }, - ) - self.validate_all( "SELECT ST_DISTANCE(a, b)", write={ @@ -2949,6 +2901,80 @@ def test_semi_structured_types(self): }, ) + def test_next_day(self): + self.validate_all( + "SELECT NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", + write={ + "snowflake": "SELECT NEXT_DAY(CAST('2024-01-01' AS DATE), 'Monday')", + "duckdb": "SELECT CAST(CAST('2024-01-01' AS DATE) + INTERVAL ((((1 - ISODOW(CAST('2024-01-01' AS DATE))) + 6) % 7) + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "SELECT NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", + write={ + "snowflake": "SELECT NEXT_DAY(CAST('2024-01-05' AS DATE), 'Friday')", + "duckdb": "SELECT CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((((5 - ISODOW(CAST('2024-01-05' AS DATE))) + 6) % 7) + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "SELECT NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", + write={ + "snowflake": "SELECT NEXT_DAY(CAST('2024-01-05' AS DATE), 'WE')", + "duckdb": "SELECT CAST(CAST('2024-01-05' AS DATE) + INTERVAL ((((3 - ISODOW(CAST('2024-01-05' AS DATE))) + 6) % 7) + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "SELECT NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", + write={ + "snowflake": "SELECT NEXT_DAY(CAST('2024-01-01 10:30:45' AS TIMESTAMP), 'Friday')", + "duckdb": "SELECT CAST(CAST('2024-01-01 10:30:45' AS TIMESTAMP) + INTERVAL ((((5 - ISODOW(CAST('2024-01-01 10:30:45' AS TIMESTAMP))) + 6) % 7) + 1) DAY AS DATE)", + }, + ) + + self.validate_all( + "SELECT NEXT_DAY(CAST('2024-01-01' AS DATE), day_column)", + write={ + "snowflake": "SELECT NEXT_DAY(CAST('2024-01-01' AS DATE), day_column)", + "duckdb": "SELECT CAST(CAST('2024-01-01' AS DATE) + INTERVAL ((((CASE WHEN STARTS_WITH(UPPER(day_column), 'MO') THEN 1 WHEN STARTS_WITH(UPPER(day_column), 'TU') THEN 2 WHEN STARTS_WITH(UPPER(day_column), 'WE') THEN 3 WHEN STARTS_WITH(UPPER(day_column), 'TH') THEN 4 WHEN STARTS_WITH(UPPER(day_column), 'FR') THEN 5 WHEN STARTS_WITH(UPPER(day_column), 'SA') THEN 6 WHEN STARTS_WITH(UPPER(day_column), 'SU') THEN 7 END - ISODOW(CAST('2024-01-01' AS DATE))) + 6) % 7) + 1) DAY AS DATE)", + }, + ) + + def test_previous_day(self): + self.validate_all( + "SELECT PREVIOUS_DAY(DATE '2024-01-15', 'Monday')", + write={ + "duckdb": "SELECT CAST(CAST('2024-01-15' AS DATE) - INTERVAL ((((ISODOW(CAST('2024-01-15' AS DATE)) - 1) + 6) % 7) + 1) DAY AS DATE)", + "snowflake": "SELECT PREVIOUS_DAY(CAST('2024-01-15' AS DATE), 'Monday')", + }, + ) + + self.validate_all( + "SELECT PREVIOUS_DAY(DATE '2024-01-15', 'Fr')", + write={ + "duckdb": "SELECT CAST(CAST('2024-01-15' AS DATE) - INTERVAL ((((ISODOW(CAST('2024-01-15' AS DATE)) - 5) + 6) % 7) + 1) DAY AS DATE)", + "snowflake": "SELECT PREVIOUS_DAY(CAST('2024-01-15' AS DATE), 'Fr')", + }, + ) + + self.validate_all( + "SELECT PREVIOUS_DAY(TIMESTAMP '2024-01-15 10:30:45', 'Monday')", + write={ + "duckdb": "SELECT CAST(CAST('2024-01-15 10:30:45' AS TIMESTAMP) - INTERVAL ((((ISODOW(CAST('2024-01-15 10:30:45' AS TIMESTAMP)) - 1) + 6) % 7) + 1) DAY AS DATE)", + "snowflake": "SELECT PREVIOUS_DAY(CAST('2024-01-15 10:30:45' AS TIMESTAMP), 'Monday')", + }, + ) + + self.validate_all( + "SELECT PREVIOUS_DAY(DATE '2024-01-15', day_column)", + write={ + "duckdb": "SELECT CAST(CAST('2024-01-15' AS DATE) - INTERVAL ((((ISODOW(CAST('2024-01-15' AS DATE)) - CASE WHEN STARTS_WITH(UPPER(day_column), 'MO') THEN 1 WHEN STARTS_WITH(UPPER(day_column), 'TU') THEN 2 WHEN STARTS_WITH(UPPER(day_column), 'WE') THEN 3 WHEN STARTS_WITH(UPPER(day_column), 'TH') THEN 4 WHEN STARTS_WITH(UPPER(day_column), 'FR') THEN 5 WHEN STARTS_WITH(UPPER(day_column), 'SA') THEN 6 WHEN STARTS_WITH(UPPER(day_column), 'SU') THEN 7 END) + 6) % 7) + 1) DAY AS DATE)", + "snowflake": "SELECT PREVIOUS_DAY(CAST('2024-01-15' AS DATE), day_column)", + }, + ) + def test_historical_data(self): self.validate_identity("SELECT * FROM my_table AT (STATEMENT => $query_id_var)") self.validate_identity("SELECT * FROM my_table AT (OFFSET => -60 * 5)")