diff --git a/sqlglot/dialects/duckdb.py b/sqlglot/dialects/duckdb.py index 2c9827a9a3..655ec45e05 100644 --- a/sqlglot/dialects/duckdb.py +++ b/sqlglot/dialects/duckdb.py @@ -628,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 @@ -1480,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, diff --git a/tests/dialects/test_snowflake.py b/tests/dialects/test_snowflake.py index 982fc03072..2e7534e146 100644 --- a/tests/dialects/test_snowflake.py +++ b/tests/dialects/test_snowflake.py @@ -2901,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)")