fix(charts): render time comparison without a time grain (#42054)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-07-16 21:50:46 -07:00
committed by GitHub
parent f467d36a24
commit 157ef61fd8
6 changed files with 1020 additions and 54 deletions

View File

@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime, timedelta
from typing import Any
from unittest.mock import MagicMock, patch
@@ -26,6 +27,7 @@ from superset.common.chart_data import ChartDataResultFormat, ChartDataResultTyp
from superset.common.db_query_status import QueryStatus
from superset.common.query_context_processor import QueryContextProcessor
from superset.utils.core import GenericDataType
from superset.utils.date_parser import get_past_or_future
@pytest.fixture
@@ -389,8 +391,45 @@ def test_get_offset_custom_or_inherit_with_inherit(processor):
"inherit", from_dttm, to_dttm
)
# Should return the difference in days
assert result == "9 days ago"
# Should shift back by the length of the range: 9 days, in seconds
assert result == "777600 seconds ago"
@pytest.mark.parametrize(
"hours",
[
# Sub-day ranges truncated to "0 days ago" and compared the range
# against itself.
1,
12,
# Ranges that are not a whole number of days truncated downwards and
# overlapped the range they were compared against.
36,
# Whole-day ranges were already correct and must stay so.
24,
48,
],
)
def test_get_offset_custom_or_inherit_shifts_by_full_range(
processor, hours: int
) -> None:
"""
'inherit' compares a range against the period immediately preceding it,
for any range length. The offset must not be truncated to whole days: it
has to reproduce the range's exact duration, so that the comparison window
neither overlaps the range nor leaves a gap before it.
"""
from_dttm = datetime(2024, 3, 10, 6, 0)
to_dttm = from_dttm + timedelta(hours=hours)
offset = processor._qc_datasource.get_offset_custom_or_inherit(
"inherit", from_dttm, to_dttm
)
shifted_from = get_past_or_future(offset, from_dttm)
shifted_to = get_past_or_future(offset, to_dttm)
assert shifted_to == from_dttm
assert shifted_to - shifted_from == to_dttm - from_dttm
def test_get_offset_custom_or_inherit_with_date(processor):
@@ -1024,6 +1063,261 @@ def test_processing_time_offsets_updates_temporal_filter_with_adhoc_x_axis(proce
assert "2025-06-01" in val, f"Expected shifted-to-dttm in val, got: {val!r}"
def test_processing_time_offsets_quarter_offset_shifts_query_window(
processor: QueryContextProcessor,
) -> None:
"""A quarter offset must shift the offset query's window, not just the
join keys. parsedatetime does not understand "1 quarter ago", so without
rewriting quarters to months the offset subquery silently runs against
the current period and the comparison series joins to nulls. The fake
query below derives its rows from the requested window, so the join only
yields the expected values when the window was actually shifted.
"""
from superset.common.query_object import QueryObject
from superset.models.helpers import ExploreMixin
# The fixture's datasource is a MagicMock, not a real Explorable
datasource: Any = processor._qc_datasource
for method in (
"processing_time_offsets",
"_align_offset_without_time_grain",
"_coalesce_offset_index",
):
setattr(
datasource,
method,
getattr(ExploreMixin, method).__get__(datasource),
)
df = pd.DataFrame(
{
"__timestamp": pd.to_datetime(["2024-04-01", "2024-05-01", "2024-06-01"]),
"sum__num": [100, 200, 300],
}
)
query_object = QueryObject(
datasource=MagicMock(),
granularity="ds",
columns=[],
metrics=["sum__num"],
is_timeseries=True,
time_offsets=["1 quarter ago"],
filters=[
{
"col": "ds",
"op": "TEMPORAL_RANGE",
"val": "2024-04-01 : 2024-07-01",
}
],
)
captured: list[dict[str, Any]] = []
def fake_query(dct: dict[str, Any]) -> MagicMock:
captured.append(dct)
result = MagicMock()
result.df = pd.DataFrame(
{
"__timestamp": pd.date_range(
start=dct["from_dttm"], periods=3, freq="MS"
),
"sum__num": [1.0, 2.0, 3.0],
}
)
result.query = "SELECT 1"
return result
datasource.query = fake_query
datasource.normalize_df = MagicMock(
side_effect=lambda offset_df, _query_object: offset_df
)
with (
patch(
"superset.models.helpers.get_since_until_from_query_object",
return_value=(pd.Timestamp("2024-04-01"), pd.Timestamp("2024-07-01")),
),
patch(
"superset.common.utils.query_cache_manager.QueryCacheManager"
) as mock_cache_manager,
patch.object(
datasource,
"get_time_grain",
return_value=None,
),
):
mock_cache = MagicMock()
mock_cache.is_loaded = False
mock_cache_manager.get.return_value = mock_cache
result = datasource.processing_time_offsets(df, query_object, None, None, False)
assert len(captured) == 1
assert captured[0]["from_dttm"] == pd.Timestamp("2024-01-01")
assert captured[0]["to_dttm"] == pd.Timestamp("2024-04-01")
assert result["df"]["sum__num__1 quarter ago"].tolist() == [1.0, 2.0, 3.0]
def test_processing_time_offsets_accepts_zero_shift_offset(
processor: QueryContextProcessor,
) -> None:
"""An offset that legitimately parses to no shift (e.g. "0 days ago")
must render a self-comparison instead of being rejected as
uninterpretable: a zero delta is not a parse failure.
"""
from superset.common.query_object import QueryObject
from superset.models.helpers import ExploreMixin
# The fixture's datasource is a MagicMock, not a real Explorable
datasource: Any = processor._qc_datasource
for method in (
"processing_time_offsets",
"_align_offset_without_time_grain",
"_coalesce_offset_index",
):
setattr(
datasource,
method,
getattr(ExploreMixin, method).__get__(datasource),
)
df = pd.DataFrame(
{
"__timestamp": pd.to_datetime(["2024-04-01", "2024-05-01", "2024-06-01"]),
"sum__num": [100, 200, 300],
}
)
query_object = QueryObject(
datasource=MagicMock(),
granularity="ds",
columns=[],
metrics=["sum__num"],
is_timeseries=True,
time_offsets=["0 days ago"],
filters=[
{
"col": "ds",
"op": "TEMPORAL_RANGE",
"val": "2024-04-01 : 2024-07-01",
}
],
)
captured: list[dict[str, Any]] = []
def fake_query(dct: dict[str, Any]) -> MagicMock:
captured.append(dct)
result = MagicMock()
result.df = pd.DataFrame(
{
"__timestamp": pd.date_range(
start=dct["from_dttm"], periods=3, freq="MS"
),
"sum__num": [1.0, 2.0, 3.0],
}
)
result.query = "SELECT 1"
return result
datasource.query = fake_query
datasource.normalize_df = MagicMock(
side_effect=lambda offset_df, _query_object: offset_df
)
with (
patch(
"superset.models.helpers.get_since_until_from_query_object",
return_value=(pd.Timestamp("2024-04-01"), pd.Timestamp("2024-07-01")),
),
patch(
"superset.common.utils.query_cache_manager.QueryCacheManager"
) as mock_cache_manager,
patch.object(
datasource,
"get_time_grain",
return_value=None,
),
):
mock_cache = MagicMock()
mock_cache.is_loaded = False
mock_cache_manager.get.return_value = mock_cache
result = datasource.processing_time_offsets(df, query_object, None, None, False)
# The offset query runs against the unshifted window and its rows join
# back onto the main series one-to-one
assert len(captured) == 1
assert captured[0]["from_dttm"] == pd.Timestamp("2024-04-01")
assert captured[0]["to_dttm"] == pd.Timestamp("2024-07-01")
assert result["df"]["sum__num__0 days ago"].tolist() == [1.0, 2.0, 3.0]
def test_processing_time_offsets_rejects_unparseable_offset(
processor: QueryContextProcessor,
) -> None:
"""An offset no parser understands must fail with a validation error
instead of querying an unshifted window and presenting the current
period's rows as the comparison series.
"""
from superset.common.query_object import QueryObject
from superset.exceptions import QueryObjectValidationError
from superset.models.helpers import ExploreMixin
# The fixture's datasource is a MagicMock, not a real Explorable
datasource: Any = processor._qc_datasource
datasource.processing_time_offsets = ExploreMixin.processing_time_offsets.__get__(
datasource
)
df = pd.DataFrame(
{
"__timestamp": pd.to_datetime(["2024-04-01"]),
"sum__num": [100],
}
)
query_object = QueryObject(
datasource=MagicMock(),
granularity="ds",
columns=[],
metrics=["sum__num"],
is_timeseries=True,
time_offsets=["not a real offset"],
filters=[
{
"col": "ds",
"op": "TEMPORAL_RANGE",
"val": "2024-04-01 : 2024-07-01",
}
],
)
datasource.query = MagicMock()
with (
patch(
"superset.models.helpers.get_since_until_from_query_object",
return_value=(pd.Timestamp("2024-04-01"), pd.Timestamp("2024-07-01")),
),
patch.object(
datasource,
"get_time_grain",
return_value=None,
),
):
with pytest.raises(
QueryObjectValidationError, match="Unable to interpret the time offset"
):
datasource.processing_time_offsets(df, query_object, None, None, False)
datasource.query.assert_not_called()
def test_ensure_totals_available_updates_cache_values():
"""
Test that ensure_totals_available() updates the query objects AND