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

Reply via email to