This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 2220f53d9db fix(chart): accept quarter and day in end-of time ranges
(#43204)
2220f53d9db is described below
commit 2220f53d9db0352dc1dbebcc391a1c5725cdb0f3
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Sat Oct 3 10:16:07 2026 +0530
fix(chart): accept quarter and day in end-of time ranges (#43204)
---
superset/utils/date_parser.py | 25 ++++++++++-
tests/unit_tests/utils/date_parser_tests.py | 64 +++++++++++++++++++++++++++++
2 files changed, 88 insertions(+), 1 deletion(-)
diff --git a/superset/utils/date_parser.py b/superset/utils/date_parser.py
index dcb85e75e65..bca761295eb 100644
--- a/superset/utils/date_parser.py
+++ b/superset/utils/date_parser.py
@@ -867,6 +867,19 @@ class EvalLastDayFunc: # pylint:
disable=too-few-public-methods
return dttm.replace(
month=12, day=31, hour=0, minute=0, second=0, microsecond=0
)
+ if unit == "quarter":
+ # Both arguments are passed to a single `replace` so the day is
+ # never briefly out of range for the new month (e.g. the 31st
+ # moving into a 30-day quarter-end month).
+ last_month = 3 * ((dttm.month - 1) // 3) + 3
+ return dttm.replace(
+ month=last_month,
+ day=calendar.monthrange(dttm.year, last_month)[1],
+ hour=0,
+ minute=0,
+ second=0,
+ microsecond=0,
+ )
if unit == "month":
return dttm.replace(
day=calendar.monthrange(dttm.year, dttm.month)[1],
@@ -875,6 +888,11 @@ class EvalLastDayFunc: # pylint:
disable=too-few-public-methods
second=0,
microsecond=0,
)
+ if unit == "day":
+ # The last day of a day is that same day. Truncating to midnight
+ # keeps the result consistent with every other unit here, which
+ # all return the final day at midnight rather than at its end.
+ return dttm.replace(hour=0, minute=0, second=0, microsecond=0)
# unit == "week":
mon = dttm - relativedelta(days=dttm.weekday())
mon = mon.replace(hour=0, minute=0, second=0, microsecond=0)
@@ -975,7 +993,12 @@ def datetime_parser() -> ParseResults: # pylint:
disable=too-many-locals
lastday_func <<= (
LASTDAY
+ lparen
- + Group(date_expr + comma + (YEAR | MONTH | WEEK) + ppOptional(comma))
+ + Group(
+ date_expr
+ + comma
+ + (YEAR | QUARTER | MONTH | WEEK | DAY)
+ + ppOptional(comma)
+ )
+ rparen
).setParseAction(EvalLastDayFunc)
holiday_func <<= (
diff --git a/tests/unit_tests/utils/date_parser_tests.py
b/tests/unit_tests/utils/date_parser_tests.py
index f07ab945c84..10c6c2bf850 100644
--- a/tests/unit_tests/utils/date_parser_tests.py
+++ b/tests/unit_tests/utils/date_parser_tests.py
@@ -979,3 +979,67 @@ def
test_datetime_eval_does_not_emit_parsedatetime_debug_logs(
"flood production logs. Records: "
+ repr([(r.levelname, r.getMessage()) for r in parsedatetime_records])
)
+
+
+def test_lastday_supports_quarter_and_day() -> None:
+ """`handle_end_of` emits LASTDAY for quarter and day, so the grammar must
+ accept them.
+
+ The `lastday` rule only matched `year | month | week`, so every "end of ...
+ quarter" and "end of ... day" range raised
+ `ValueError: Expected {'year' | 'month' | 'week'}` instead of resolving.
+ """
+ assert datetime_eval(
+ "LASTDAY(datetime('2026-08-12T15:30:45'), quarter)"
+ ) == datetime(2026, 9, 30)
+ assert datetime_eval("LASTDAY(datetime('2026-08-12T15:30:45'), day)") ==
datetime(
+ 2026, 8, 12
+ )
+
+
+def test_lastday_quarter_boundaries() -> None:
+ """Every month resolves to the last day of the quarter containing it."""
+ expected = {
+ 1: datetime(2026, 3, 31),
+ 2: datetime(2026, 3, 31),
+ 3: datetime(2026, 3, 31),
+ 4: datetime(2026, 6, 30),
+ 5: datetime(2026, 6, 30),
+ 6: datetime(2026, 6, 30),
+ 7: datetime(2026, 9, 30),
+ 8: datetime(2026, 9, 30),
+ 9: datetime(2026, 9, 30),
+ 10: datetime(2026, 12, 31),
+ 11: datetime(2026, 12, 31),
+ 12: datetime(2026, 12, 31),
+ }
+ for month, last_day in expected.items():
+ assert (
+ datetime_eval("LASTDAY(datetime('2026-%02d-15'), quarter)" % month)
+ == last_day
+ )
+
+
+def test_lastday_quarter_from_a_longer_month() -> None:
+ """The 31st must survive a move into a 30-day quarter-end month."""
+ assert datetime_eval("LASTDAY(datetime('2026-08-31'), quarter)") ==
datetime(
+ 2026, 9, 30
+ )
+ # Q1 of a leap year, where the source month is shorter than the target.
+ assert datetime_eval("LASTDAY(datetime('2024-02-15'), quarter)") ==
datetime(
+ 2024, 3, 31
+ )
+
+
+@patch("superset.utils.date_parser.parse_human_datetime",
mock_parse_human_datetime)
+def test_get_since_until_end_of_quarter_and_day() -> None:
+ """End-to-end: the "end of ..." time ranges these units feed."""
+ for time_range, expected_until in [
+ ("2020-01-01 : end of this quarter", datetime(2016, 12, 31)),
+ ("2020-01-01 : end of last quarter", datetime(2016, 9, 30)),
+ ("2020-01-01 : end of next quarter", datetime(2017, 3, 31)),
+ ("2020-01-01 : end of this day", datetime(2016, 11, 7)),
+ ("2020-01-01 : end of prior 3 days", datetime(2016, 11, 4)),
+ ]:
+ _, until = get_since_until(time_range)
+ assert until == expected_until, time_range