mirror of
https://github.com/apache/superset.git
synced 2026-07-19 13:15:49 +00:00
fix(charts): render time comparison without a time grain (#42054)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user