Skip to content

Commit 17a5b7a

Browse files
feat(duckdb): Addressed review comments, calculation logic updated to adhere to ISO day numbering conventions
1 parent e8cebc2 commit 17a5b7a

File tree

3 files changed

+66
-54
lines changed

3 files changed

+66
-54
lines changed

sqlglot/dialects/bigquery.py

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
date_add_interval_sql,
1818
datestrtodate_sql,
1919
build_formatted_time,
20+
extract_week_unit_info,
2021
filter_array_using_unnest,
2122
if_sql,
2223
inline_array_unless_query,
@@ -209,45 +210,37 @@ def _ts_or_ds_diff_sql(self: BigQuery.Generator, expression: exp.TsOrDsDiff) ->
209210
return self.func("DATE_DIFF", expression.this, expression.expression, unit)
210211

211212

212-
def _serialize_bq_datetime_diff_unit(self: BigQuery.Generator, expression: exp.Expression) -> str:
213+
def _generate_bq_datetime_diff_unit(self: BigQuery.Generator, expression: exp.Expression) -> str:
213214
"""
214-
Serialize unit for *_DIFF functions, converting Week expressions to BigQuery syntax.
215+
Generate unit for *_DIFF functions, converting Week expressions to BigQuery syntax.
215216
216217
Canonical form -> BigQuery syntax:
217218
- Week(SUNDAY) -> WEEK (BigQuery's default)
218219
- Week(MONDAY) -> WEEK(MONDAY) (preserved during round-trip)
219220
- Var(ISOWEEK) -> ISOWEEK (preserved as-is)
220-
- WeekStart -> preserved as-is
221-
- Week(other day) -> WEEK(day)
222-
- Other units -> use unit_to_var
223221
224222
"""
225-
from sqlglot.dialects.dialect import extract_week_unit_info
226-
227223
unit = expression.args.get("unit")
228224

229-
# Preserve ISOWEEK/WEEKISO as-is (don't convert to WEEK(MONDAY))
225+
# Preserve ISOWEEK/WEEKISO as-is.
230226
if isinstance(unit, exp.Var):
231-
unit_name = unit.this.upper() if isinstance(unit.this, str) else str(unit.this)
232-
if unit_name in ("ISOWEEK", "WEEKISO"):
227+
if unit.name.upper() in ("ISOWEEK", "WEEKISO"):
233228
return self.sql(unit)
234229

235230
week_info = extract_week_unit_info(unit)
236231

237232
if week_info:
238-
day_name, _ = week_info # Extract day name, ignore DOW number
233+
day_name, _ = week_info
239234
if day_name == "SUNDAY":
240-
return self.sql(exp.var("WEEK"))
241-
elif day_name == "MONDAY":
242-
if isinstance(unit, exp.WeekStart):
243-
return self.sql(unit)
244-
else:
245-
return self.sql(exp.Week(this=exp.var(day_name)))
235+
return "WEEK"
236+
elif isinstance(unit, exp.WeekStart):
237+
# Preserve WeekStart expressions as-is
238+
return self.sql(unit)
246239
else:
240+
# Convert to WEEK(day) for all other cases
247241
return self.sql(exp.Week(this=exp.var(day_name)))
248242

249-
unit_expr = unit_to_var(expression)
250-
return self.sql(unit_expr) if unit_expr else "DAY"
243+
return self.sql(unit_to_var(expression))
251244

252245

253246
def _unix_to_time_sql(self: BigQuery.Generator, expression: exp.UnixToTime) -> str:
@@ -286,18 +279,15 @@ def _build_datetime(args: t.List) -> exp.Func:
286279
def _normalize_week_unit(unit: t.Optional[exp.Expression]) -> t.Optional[exp.Expression]:
287280
"""
288281
In BigQuery, plain WEEK defaults to Sunday-start weeks.
289-
Normalize plain WEEK to WEEK(SUNDAY) to preserve the semantic in the AST for correct cross-dialect transpilation.
282+
Normalize plain WEEK to WEEK(SUNDAY) to preserve the semantic in the AST for correct cross-dialect transpilation.
290283
"""
291-
unit_name = None
292-
293-
if isinstance(unit, exp.Var):
294-
unit_name = str(unit.this)
295-
elif isinstance(unit, exp.Column) and isinstance(unit.this, exp.Identifier):
296-
unit_name = str(unit.this.this)
297-
298-
if unit_name and unit_name.upper() == "WEEK":
284+
if isinstance(unit, exp.Var) and unit.name.upper() == "WEEK":
299285
return exp.Week(this=exp.var("SUNDAY"))
300286

287+
if isinstance(unit, exp.Column) and not unit.table and isinstance(unit.this, exp.Identifier):
288+
if unit.name.upper() == "WEEK":
289+
return exp.Week(this=exp.var("SUNDAY"))
290+
301291
return unit
302292

303293

@@ -1165,7 +1155,7 @@ class Generator(generator.Generator):
11651155
exp.CTE: transforms.preprocess([_pushdown_cte_column_names]),
11661156
exp.DateAdd: date_add_interval_sql("DATE", "ADD"),
11671157
exp.DateDiff: lambda self, e: self.func(
1168-
"DATE_DIFF", e.this, e.expression, _serialize_bq_datetime_diff_unit(self, e)
1158+
"DATE_DIFF", e.this, e.expression, _generate_bq_datetime_diff_unit(self, e)
11691159
),
11701160
exp.DateFromParts: rename_func("DATE"),
11711161
exp.DateStrToDate: datestrtodate_sql,

sqlglot/dialects/dialect.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -828,23 +828,24 @@ class Dialect(metaclass=_Dialect):
828828
"CENTURIES": "CENTURY",
829829
}
830830

831-
# Mapping of week unit names to (start_day, dow_number) tuples
832-
# dow_number is the numeric day-of-week value (0=Sunday, 1=Monday, etc.)
831+
# Mapping of week unit names to (start_day, iso_dow) tuples
832+
# iso_dow follows ISO 8601 day-of-week numbering (Monday=1, ..., Sunday=7)
833833
WEEK_UNIT_SEMANTICS = {
834-
"WEEK": ("SUNDAY", 0),
834+
"WEEK": ("SUNDAY", 7),
835835
"ISOWEEK": ("MONDAY", 1),
836836
"WEEKISO": ("MONDAY", 1),
837837
}
838838

839-
# Days of week to DOW numbers (ISO standard: Sunday=0, Monday=1)
839+
# Days of week to ISO 8601 day-of-week numbers
840+
# ISO 8601 standard: Monday=1, Tuesday=2, Wednesday=3, Thursday=4, Friday=5, Saturday=6, Sunday=7
840841
WEEK_START_DAY_TO_DOW = {
841-
"SUNDAY": 0,
842842
"MONDAY": 1,
843843
"TUESDAY": 2,
844844
"WEDNESDAY": 3,
845845
"THURSDAY": 4,
846846
"FRIDAY": 5,
847847
"SATURDAY": 6,
848+
"SUNDAY": 7,
848849
}
849850

850851
# Specifies what types a given type can be coerced into
@@ -1721,29 +1722,32 @@ def map_date_part(part, dialect: DialectType = Dialect):
17211722

17221723

17231724
def extract_week_unit_info(
1724-
unit: t.Optional[exp.Expression], dialect: DialectType = Dialect
1725+
unit: t.Optional[exp.Expression], dialect: DialectType = None
17251726
) -> t.Optional[t.Tuple[str, int]]:
17261727
"""
17271728
Extract week unit information from AST node.
17281729
17291730
This helper provides a unified way to handle week units across dialects.
17301731
17311732
Args:
1732-
unit: The unit expression (Var, Week, or WeekStart)
1733-
dialect: Dialect class or instance for accessing WEEK_UNIT_SEMANTICS and WEEK_START_DAY_TO_DOW
1733+
unit: The unit expression (Var for ISOWEEK/WEEKISO, Week, or WeekStart)
1734+
dialect: Dialect used to access WEEK_UNIT_SEMANTICS and WEEK_START_DAY_TO_DOW mappings.
17341735
17351736
Returns:
1736-
- (day_name, dow_number) tuple (e.g., ("SUNDAY", 0) or ("MONDAY", 1))
1737-
- None if not a week unit or if day is dynamic (not a constant)
1737+
Tuple of (day_name, iso_dow) where iso_dow is ISO 8601 day number (Monday=1, Sunday=7),
1738+
or None if not a week unit or if day is dynamic (not a constant).
1739+
1740+
Examples:
1741+
Week(Var('SUNDAY')) → ('SUNDAY', 7)
1742+
Var('ISOWEEK') → ('MONDAY', 1)
1743+
Column('week') → None (dynamic, not a constant)
17381744
17391745
"""
17401746
dialect_instance = Dialect.get_or_raise(dialect)
17411747

17421748
# Handle plain Var expressions for ISOWEEK/WEEKISO only
1743-
# NOTE: Plain Var('WEEK') is NOT handled to avoid breaking other dialects
17441749
if isinstance(unit, exp.Var):
1745-
unit_name = unit.this.upper() if isinstance(unit.this, str) else str(unit.this)
1746-
# Only handle ISOWEEK/WEEKISO variants, not plain WEEK
1750+
unit_name = unit.name.upper()
17471751
if unit_name in ("ISOWEEK", "WEEKISO"):
17481752
week_info = dialect_instance.WEEK_UNIT_SEMANTICS.get(unit_name)
17491753
if week_info:
@@ -1754,7 +1758,7 @@ def extract_week_unit_info(
17541758
if isinstance(unit, (exp.Week, exp.WeekStart)):
17551759
day_var = unit.this
17561760
if isinstance(day_var, exp.Var):
1757-
day_name = day_var.this.upper() if isinstance(day_var.this, str) else str(day_var.this)
1761+
day_name = day_var.name.upper()
17581762
dow_value = dialect_instance.WEEK_START_DAY_TO_DOW.get(day_name)
17591763
return (day_name, dow_value) if dow_value is not None else None
17601764

sqlglot/dialects/duckdb.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -253,36 +253,54 @@ def _implicit_datetime_cast(
253253
def _build_week_trunc_expression(date_expr: exp.Expression, start_dow: int) -> exp.Expression:
254254
"""
255255
Build DATE_TRUNC expression for week boundaries with custom start day.
256-
Formula: shift = 1 - start_dow, then DATE_TRUNC('week', date + INTERVAL shift DAY)
256+
257+
Args:
258+
date_expr: The date expression to truncate
259+
start_dow: ISO 8601 day-of-week number (Monday=1, ..., Sunday=7)
260+
261+
DuckDB's DATE_TRUNC('WEEK', ...) aligns weeks to Monday (ISO standard).
262+
To align to a different start day, we shift the date before truncating.
263+
264+
Shift formula: Sunday (7) gets +1, others get (1 - start_dow)
265+
Examples:
266+
Monday (1): shift = 0 (no shift needed)
267+
Tuesday (2): shift = -1 (shift back 1 day) ...
268+
Sunday (7): shift = +1 (shift forward 1 day, wraps to next Monday-based week)
257269
"""
258-
if start_dow == 1:
259-
# No shift needed for Monday-based weeks (ISO standard)
260-
return exp.DateTrunc(unit=exp.var("week"), this=date_expr)
270+
# Calculate shift: Sunday is special case, others use formula
271+
shift_days = 1 if start_dow == 7 else 1 - start_dow
261272

262-
# Shift date to align week boundaries with ISO Monday
263-
shift_days = 1 - start_dow
273+
if shift_days == 0:
274+
# No shift needed for Monday-based weeks
275+
return exp.DateTrunc(unit=exp.var("WEEK"), this=date_expr)
276+
277+
# Shift date to align week boundaries with the desired start day
264278
shifted_date = exp.DateAdd(
265279
this=date_expr,
266280
expression=exp.Interval(this=exp.Literal.string(str(shift_days)), unit=exp.var("DAY")),
267281
)
268282

269-
return exp.DateTrunc(unit=exp.var("week"), this=shifted_date)
283+
return exp.DateTrunc(unit=exp.var("WEEK"), this=shifted_date)
270284

271285

272286
def _date_diff_sql(self: DuckDB.Generator, expression: exp.DateDiff) -> str:
273287
"""
274288
Generate DATE_DIFF SQL for DuckDB using DATE_TRUNC-based week alignment.
275289
276-
Note: When week start day is dynamic (from a column), week_start will be None
277-
and we fall back to standard DATE_DIFF, since compile-time offsets are required.
290+
DuckDB's DATE_DIFF doesn't support WEEK(day) units. We transpile these to
291+
DATE_TRUNC-based calculations when the day is a compile-time constant.
292+
293+
Non-literal week units (e.g., columns, placeholders) cannot be transpiled safely
294+
and are passed through as-is, following the pattern used elsewhere in sqlglot
295+
278296
"""
279297
from sqlglot.dialects.dialect import extract_week_unit_info
280298

281299
this = _implicit_datetime_cast(expression.this)
282300
expr = _implicit_datetime_cast(expression.expression)
283301
unit = expression.args.get("unit")
284302

285-
# Extract week start day; returns None if day is dynamic (column reference)
303+
# Extract week start day; returns None if day is dynamic (column/placeholder)
286304
week_start = extract_week_unit_info(unit)
287305
if week_start and this and expr:
288306
_, start_dow = week_start
@@ -293,7 +311,7 @@ def _date_diff_sql(self: DuckDB.Generator, expression: exp.DateDiff) -> str:
293311

294312
# Calculate week difference
295313
day_diff = exp.DateDiff(
296-
this=truncated_this, expression=truncated_expr, unit=exp.Literal.string("day")
314+
this=truncated_this, expression=truncated_expr, unit=exp.Literal.string("DAY")
297315
)
298316
result = exp.IntDiv(this=day_diff, expression=exp.Literal.number(7))
299317

0 commit comments

Comments
 (0)