Files
superset2/tests/unit_tests/common/test_time_shifts.py
2026-07-16 21:50:46 -07:00

748 lines
24 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from pandas import DataFrame, Series, Timestamp
from pandas.testing import assert_frame_equal
from pytest import fixture, mark, raises # noqa: PT013
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
from superset.common.query_context import QueryContext
from superset.common.query_context_processor import QueryContextProcessor
from superset.connectors.sqla.models import BaseDatasource
from superset.constants import TimeGrain
from superset.exceptions import QueryObjectValidationError
from superset.models.helpers import ExploreMixin
# Create processor and bind ExploreMixin methods to datasource
processor = QueryContextProcessor(
QueryContext(
datasource=BaseDatasource(),
queries=[],
result_type=ChartDataResultType.COLUMNS,
form_data={},
slice_=None,
result_format=ChartDataResultFormat.CSV,
cache_values={},
)
)
# Bind ExploreMixin methods to datasource for testing
# Type annotation needed because _qc_datasource is typed as Explorable in protocol
_datasource: BaseDatasource = processor._qc_datasource # type: ignore
_datasource.add_offset_join_column = ExploreMixin.add_offset_join_column.__get__(
_datasource
)
_datasource.join_offset_dfs = ExploreMixin.join_offset_dfs.__get__(_datasource)
_datasource.is_valid_date_range = ExploreMixin.is_valid_date_range.__get__(_datasource)
_datasource._determine_join_keys = ExploreMixin._determine_join_keys.__get__(
_datasource
)
_datasource._align_offset_without_time_grain = (
ExploreMixin._align_offset_without_time_grain.__get__(_datasource)
)
_datasource._perform_join = ExploreMixin._perform_join.__get__(_datasource)
_datasource._apply_cleanup_logic = ExploreMixin._apply_cleanup_logic.__get__(
_datasource
)
_datasource._coalesce_offset_index = ExploreMixin._coalesce_offset_index.__get__(
_datasource
)
# Static methods don't need binding - assign directly
_datasource.generate_join_column = ExploreMixin.generate_join_column
_datasource.is_valid_date_range_static = ExploreMixin.is_valid_date_range_static
# Convenience reference for backward compatibility in tests
query_context_processor = _datasource
@fixture
def make_join_column_producer():
def join_column_producer(row: Series, column_index: int) -> str:
return "CUSTOM_FORMAT"
return join_column_producer
@mark.parametrize(
("time_grain", "expected"),
[
(TimeGrain.WEEK, "2020-W01"),
(TimeGrain.MONTH, "2020-01"),
(TimeGrain.QUARTER, "2020-Q1"),
(TimeGrain.YEAR, "2020"),
],
)
def test_join_column(time_grain: str, expected: str):
df = DataFrame({"ds": [Timestamp("2020-01-07")]})
column_name = "join_column"
query_context_processor.add_offset_join_column(df, column_name, time_grain)
result = DataFrame({"ds": [Timestamp("2020-01-07")], column_name: [expected]})
assert_frame_equal(df, result)
def test_join_column_producer(make_join_column_producer):
df = DataFrame({"ds": [Timestamp("2020-01-07")]})
column_name = "join_column"
query_context_processor.add_offset_join_column(
df, column_name, TimeGrain.YEAR, None, make_join_column_producer
)
result = DataFrame(
{"ds": [Timestamp("2020-01-07")], column_name: ["CUSTOM_FORMAT"]}
)
assert_frame_equal(df, result)
def test_join_offset_dfs_no_offsets():
df = DataFrame({"A": ["2021-01-01", "2021-02-01", "2021-03-01"]})
offset_dfs = {}
time_grain = "YEAR"
join_keys = ["A"]
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys
)
assert_frame_equal(df, result)
def test_join_offset_dfs_with_offsets():
df = DataFrame({"A": ["2021-01-01", "2021-02-01", "2021-03-01"]})
offset_df = DataFrame(
{"A": ["2021-02-01", "2021-03-01", "2021-04-01"], "B": [5, 6, 7]}
)
offset_dfs = {"1_YEAR": offset_df}
time_grain = "YEAR"
join_keys = ["A"]
expected = DataFrame(
{"A": ["2021-01-01", "2021-02-01", "2021-03-01"], "B": [None, 5, 6]}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_with_multiple_offsets():
df = DataFrame({"A": ["2021-01-01", "2021-02-01", "2021-03-01"]})
offset_df1 = DataFrame(
{"A": ["2021-02-01", "2021-03-01", "2021-04-01"], "B": [5, 6, 7]}
)
offset_df2 = DataFrame(
{"A": ["2021-03-01", "2021-04-01", "2021-05-01"], "C": [8, 9, 10]}
)
offset_dfs = {"1_YEAR": offset_df1, "2_YEAR": offset_df2}
time_grain = "YEAR"
join_keys = ["A"]
expected = DataFrame(
{
"A": ["2021-01-01", "2021-02-01", "2021-03-01"],
"B": [None, 5, 6],
"C": [None, None, 8],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_with_month_granularity():
df = DataFrame(
{
"A": [
"2021-01-01",
"2021-01-15",
"2021-02-01",
"2021-02-15",
"2021-03-01",
"2021-03-15",
],
"D": [1, 2, 3, 4, 5, 6],
}
)
offset_df = DataFrame(
{
"A": [
"2021-02-01",
"2021-02-15",
"2021-03-01",
"2021-03-15",
"2021-04-01",
"2021-04-15",
],
"B": [5, 6, 7, 8, 9, 10],
}
)
offset_dfs = {"1_MONTH": offset_df}
time_grain = "MONTH"
join_keys = ["A"]
expected = DataFrame(
{
"A": [
"2021-01-01",
"2021-01-15",
"2021-02-01",
"2021-02-15",
"2021-03-01",
"2021-03-15",
],
"D": [1, 2, 3, 4, 5, 6],
"B": [None, None, 5, 6, 7, 8],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_full_range_keeps_historical_tail():
"""
With full_range=True the offset (historical) series keeps its full time range
even when the main series ends earlier.
Simulates "today so far" (main, ends at 01:00) compared against "1 day ago"
(a complete prior day, runs to 02:00). The 02:00 historical point must survive
and be aligned onto today's axis, with the main metric left null there.
"""
# Main series: today, only two hours of data so far.
df = DataFrame(
{
"A": [Timestamp("2021-01-02 00:00"), Timestamp("2021-01-02 01:00")],
"V": [1.0, 2.0],
}
)
# Offset series: the full prior day (already renamed metric column "B").
offset_df = DataFrame(
{
"A": [
Timestamp("2021-01-01 00:00"),
Timestamp("2021-01-01 01:00"),
Timestamp("2021-01-01 02:00"),
],
"B": [10.0, 20.0, 30.0],
}
)
offset_dfs = {"1 day ago": offset_df}
time_grain = TimeGrain.HOUR
join_keys = ["A"]
expected = DataFrame(
{
"A": [
Timestamp("2021-01-02 00:00"),
Timestamp("2021-01-02 01:00"),
Timestamp("2021-01-02 02:00"),
],
"V": [1.0, 2.0, None],
"B": [10.0, 20.0, 30.0],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys, full_range=True
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_full_range_disabled_truncates_historical():
"""The default (full_range=False) left join drops the historical 02:00 point."""
df = DataFrame(
{
"A": [Timestamp("2021-01-02 00:00"), Timestamp("2021-01-02 01:00")],
"V": [1.0, 2.0],
}
)
offset_df = DataFrame(
{
"A": [
Timestamp("2021-01-01 00:00"),
Timestamp("2021-01-01 01:00"),
Timestamp("2021-01-01 02:00"),
],
"B": [10.0, 20.0, 30.0],
}
)
offset_dfs = {"1 day ago": offset_df}
expected = DataFrame(
{
"A": [Timestamp("2021-01-02 00:00"), Timestamp("2021-01-02 01:00")],
"V": [1.0, 2.0],
"B": [10.0, 20.0],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, TimeGrain.HOUR, ["A"], full_range=False
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_totals_query_no_dimensions():
"""
Test time offset join for totals query with no dimension columns.
This simulates a table chart totals query where:
- columns=[] (no dimensions, only metrics)
- time_offsets=["1 month ago"]
- The dataframes only contain metric columns (no datetime column)
The join should use the __temp_join_key__ fallback mechanism
to properly join the offset data.
"""
# Main totals query result - only has metric column, no datetime
df = DataFrame({"Total Cost": [54211.76]})
# Offset query result - renamed metric column
offset_df = DataFrame({"Total Cost__1 month ago": [48000.50]})
offset_dfs = {"1 month ago": offset_df}
time_grain = "P1D" # Daily grain from extras
join_keys = [] # No dimension columns for totals query
expected = DataFrame(
{"Total Cost": [54211.76], "Total Cost__1 month ago": [48000.50]}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain, join_keys
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_no_time_grain_aligns_relative_offset() -> None:
"""
Without a time grain, a relative offset joins on the exact shifted
timestamps instead of raising, so saved charts without a grain render
with a correctly aligned comparison series.
"""
df = DataFrame(
{
"ds": [Timestamp("2021-01-01"), Timestamp("2021-02-01")],
"D": [1, 2],
}
)
offset_df = DataFrame(
{
"ds": [Timestamp("2020-01-01"), Timestamp("2020-02-01")],
"B": [5, 6],
}
)
offset_dfs = {"1 year ago": offset_df}
expected = DataFrame(
{
"ds": [Timestamp("2021-01-01"), Timestamp("2021-02-01")],
"D": [1, 2],
"B": [5, 6],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_no_time_grain_unmatched_timestamps_yield_nulls() -> None:
"""
Without a time grain, offset timestamps that have no exact shifted
counterpart in the main series produce nulls instead of raising.
"""
df = DataFrame({"ds": [Timestamp("2021-01-01")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2020-06-15")], "B": [5]})
offset_dfs = {"1 year ago": offset_df}
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert "B" in result.columns
assert result["B"].isna().all()
def test_join_offset_dfs_no_time_grain_multiple_offsets() -> None:
"""
Multiple relative offsets without a time grain each align on their own
shifted timestamps, and no synthetic join columns leak into the result.
"""
df = DataFrame({"ds": [Timestamp("2021-01-29")], "D": [1]})
offset_df1 = DataFrame({"ds": [Timestamp("2021-01-01")], "B": [5]})
offset_df2 = DataFrame({"ds": [Timestamp("2020-01-29")], "C": [7]})
offset_dfs = {"28 days ago": offset_df1, "1 year ago": offset_df2}
expected = DataFrame(
{
"ds": [Timestamp("2021-01-29")],
"D": [1],
"B": [5],
"C": [7],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_no_time_grain_empty_offset_df() -> None:
"""
An empty offset series materializes its join keys as NaN floats; the
grain-less join must not crash on the dtype mismatch.
"""
df = DataFrame({"ds": [Timestamp("2021-01-01")], "D": [1]})
offset_df = DataFrame({"ds": [float("nan")], "B": [float("nan")]})
offset_dfs = {"1 year ago": offset_df}
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert "B" in result.columns
assert result["B"].isna().all()
def test_join_offset_dfs_no_time_grain_quarter_offset() -> None:
"""
Quarter offsets align without a time grain (normalize_time_delta converts
quarters to months, since pd.DateOffset has no quarters argument).
"""
df = DataFrame({"ds": [Timestamp("2021-04-01")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2021-01-01")], "B": [5]})
offset_dfs = {"1 quarter ago": offset_df}
expected = DataFrame({"ds": [Timestamp("2021-04-01")], "D": [1], "B": [5]})
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_no_time_grain_free_form_offset() -> None:
"""
Offsets outside the normalize_time_delta grammar (e.g. "one year ago")
are aligned with the same parser that shifted the offset query's range.
"""
df = DataFrame({"ds": [Timestamp("2021-01-01")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2020-01-01")], "B": [5]})
offset_dfs = {"one year ago": offset_df}
expected = DataFrame({"ds": [Timestamp("2021-01-01")], "D": [1], "B": [5]})
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert_frame_equal(expected, result)
def test_join_offset_dfs_no_time_grain_free_form_offset_tz_microseconds() -> None:
"""
Free-form offsets align timezone-aware, sub-second timestamps: each row's
shift is computed from a naive second-truncated copy and applied to the
original value, so the join keys keep their timezone and precision.
"""
df = DataFrame(
{"ds": [Timestamp("2021-03-04 05:06:07.890123", tz="UTC")], "D": [1]}
)
offset_df = DataFrame(
{"ds": [Timestamp("2020-03-04 05:06:07.890123", tz="UTC")], "B": [5]}
)
offset_dfs = {"one year ago": offset_df}
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
assert result["B"].tolist() == [5]
def test_join_offset_dfs_no_time_grain_uninterpretable_offset() -> None:
"""
An offset that no parser can interpret cannot be aligned without a time
grain, and fails with the error that points at the missing grain rather
than silently producing an unshifted or empty comparison.
"""
df = DataFrame({"ds": [Timestamp("2021-01-01")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2020-01-01")], "B": [5]})
offset_dfs = {"not a real offset": offset_df}
with raises(QueryObjectValidationError, match="Time Grain must be"):
query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
def test_join_offset_dfs_no_time_grain_uninterpretable_offset_subsecond() -> None:
"""
Sub-second timestamps must not mask an uninterpretable offset:
unparseability is detected from the parser's parse flag, not by
comparing shifted values, so value precision cannot fake a
successful parse.
"""
df = DataFrame({"ds": [Timestamp("2021-01-01 00:00:00.123456")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2020-01-01 00:00:00.123456")], "B": [5]})
offset_dfs = {"not a real offset": offset_df}
with raises(QueryObjectValidationError, match="Time Grain must be"):
query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["ds"]
)
@mark.parametrize("offset", ["yesterday", "last month"])
def test_join_offset_dfs_no_time_grain_anchor_offset(offset: str) -> None:
"""
Phrases that parsedatetime resolves to a fixed point rather than a shift
are rejected. Applied per row they would move each timestamp by a
different amount -- parsedatetime anchors both of these rows onto the same
09:00 timestamp -- collapsing distinct buckets onto one join key.
"""
df = DataFrame(
{
"ds": [Timestamp("2021-06-15 03:00"), Timestamp("2021-06-15 21:00")],
"D": [1, 2],
}
)
offset_df = DataFrame({"ds": [Timestamp("2021-06-14 03:00")], "B": [5]})
with raises(QueryObjectValidationError, match="Time Grain must be"):
query_context_processor.join_offset_dfs(
df, {offset: offset_df}, time_grain=None, join_keys=["ds"]
)
def test_join_offset_dfs_no_time_grain_free_form_delta_offset() -> None:
"""
Free-form phrasing that does denote a fixed shift still aligns: it misses
the normalize_time_delta grammar but shifts every row equally, which is
all the join needs.
"""
df = DataFrame({"ds": [Timestamp("2021-06-15 03:00")], "D": [1]})
offset_df = DataFrame({"ds": [Timestamp("2020-06-15 03:00")], "B": [5]})
result = query_context_processor.join_offset_dfs(
df, {"one year ago": offset_df}, time_grain=None, join_keys=["ds"]
)
assert result["B"].tolist() == [5]
def test_join_offset_dfs_no_time_grain_dst_nonexistent_hour() -> None:
"""
A shift landing on a local hour that DST skips must not raise: 02:30 never
occurs on 2021-03-14 in US/Eastern, so no row can carry that reading and
the comparison is simply empty. Shifting the tz-aware timestamp directly
raised NonExistentTimeError out of pandas instead.
"""
df = DataFrame({"ds": [Timestamp("2021-04-14 02:30", tz="US/Eastern")], "D": [1]})
offset_df = DataFrame(
{"ds": [Timestamp("2021-03-15 02:30", tz="US/Eastern")], "B": [5]}
)
result = query_context_processor.join_offset_dfs(
df, {"1 month ago": offset_df}, time_grain=None, join_keys=["ds"]
)
assert result["B"].isna().all()
def test_join_offset_dfs_no_time_grain_dst_ambiguous_hour() -> None:
"""
A shift landing on a local hour that DST repeats aligns on the wall clock
the offset query returned. 01:30 occurs twice on 2021-11-07 in US/Eastern;
shifting the tz-aware timestamp directly raised AmbiguousTimeError out of
pandas rather than picking either reading.
"""
df = DataFrame({"ds": [Timestamp("2021-12-07 01:30", tz="US/Eastern")], "D": [1]})
offset_df = DataFrame(
{
"ds": [
Timestamp("2021-11-07 01:30").tz_localize("US/Eastern", ambiguous=True)
],
"B": [5],
}
)
result = query_context_processor.join_offset_dfs(
df, {"1 month ago": offset_df}, time_grain=None, join_keys=["ds"]
)
assert result["B"].tolist() == [5]
@mark.parametrize(
"offset",
[
# shifts via DateOffset
"2 weeks ago",
# misses the normalize_time_delta grammar, shifts via the parser
"two weeks ago",
],
)
def test_join_offset_dfs_no_time_grain_tz_aware_preserves_wall_clock(
offset: str,
) -> None:
"""
A tz-aware shift crossing a DST boundary matches the same wall clock on
the other side, rather than drifting by the hour the UTC offset changed.
The offset query's own time range is shifted with naive arithmetic, so its
rows carry the source wall clock and only a wall-clock join finds them.
Both shift paths have to agree on that.
"""
df = DataFrame({"ds": [Timestamp("2021-03-20 12:00", tz="US/Eastern")], "D": [1]})
# Two weeks before 2021-03-20 EDT is 2021-03-06 EST: same 12:00 wall
# clock, one hour further from UTC.
offset_df = DataFrame(
{"ds": [Timestamp("2021-03-06 12:00", tz="US/Eastern")], "B": [5]}
)
result = query_context_processor.join_offset_dfs(
df, {offset: offset_df}, time_grain=None, join_keys=["ds"]
)
assert result["B"].tolist() == [5]
def test_join_offset_dfs_no_time_grain_prefers_x_axis_label() -> None:
"""
With multiple datetime join keys, the query's x-axis is the one shifted
for alignment, not whichever datetime column happens to come first.
"""
df = DataFrame(
{
"birth_date": [Timestamp("1990-05-05")],
"ds": [Timestamp("2021-02-01")],
"D": [1],
}
)
offset_df = DataFrame(
{
"birth_date": [Timestamp("1990-05-05")],
"ds": [Timestamp("2021-01-01")],
"B": [5],
}
)
offset_dfs = {"1 month ago": offset_df}
result = query_context_processor.join_offset_dfs(
df,
offset_dfs,
time_grain=None,
join_keys=["birth_date", "ds"],
x_axis_label="ds",
)
assert result["B"].tolist() == [5]
def test_join_offset_dfs_no_time_grain_preserves_column_order() -> None:
"""
A join key following the temporal key must not change the result's
column order (chart payloads and CSV exports derive order from columns).
"""
df = DataFrame(
{
"country": ["US"],
"ds": [Timestamp("2021-02-01")],
"category": ["a"],
"D": [1],
}
)
offset_df = DataFrame(
{
"country": ["US"],
"ds": [Timestamp("2021-01-01")],
"category": ["a"],
"B": [5],
}
)
offset_dfs = {"1 month ago": offset_df}
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["country", "ds", "category"]
)
assert result.columns.tolist() == ["country", "ds", "category", "D", "B"]
assert result["B"].tolist() == [5]
def test_join_offset_dfs_allows_non_temporal_join_without_time_grain():
"""Time comparison without time grain is valid when join keys are non-temporal."""
df = DataFrame({"country": ["US", "UK"], "metric": [10, 20]})
offset_df = DataFrame({"country": ["US", "UK"], "metric__1 year ago": [8, 15]})
offset_dfs = {"1 year ago": offset_df}
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["country"]
)
assert "metric__1 year ago" in result.columns
def test_join_offset_dfs_no_time_grain_temporal_key_not_first() -> None:
"""
The temporal join key is aligned by shifting even when it is not the
first join key; remaining keys still participate in the join.
"""
df = DataFrame(
{
"country": ["US", "UK"],
"ds": [Timestamp("2021-02-01"), Timestamp("2021-02-01")],
"D": [1, 2],
}
)
offset_df = DataFrame(
{
"country": ["US", "UK"],
"ds": [Timestamp("2021-01-01"), Timestamp("2021-01-01")],
"B": [5, 6],
}
)
offset_dfs = {"1 month ago": offset_df}
expected = DataFrame(
{
"country": ["US", "UK"],
"ds": [Timestamp("2021-02-01"), Timestamp("2021-02-01")],
"D": [1, 2],
"B": [5, 6],
}
)
result = query_context_processor.join_offset_dfs(
df, offset_dfs, time_grain=None, join_keys=["country", "ds"]
)
assert_frame_equal(expected, result)