mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Joe Li <joe@preset.io>
205 lines
7.9 KiB
Python
Executable File
205 lines
7.9 KiB
Python
Executable File
# 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.
|
|
"""Regression test for SC-111332.
|
|
|
|
When a chart has a Time Comparison (time offset, e.g. ``1 year ago``) and the
|
|
Time Column driving the temporal x-axis is overridden on a dashboard to a
|
|
non-default column, the chart used to collapse into a single data point.
|
|
|
|
Root cause: overriding the Time Column funnels through
|
|
``QueryContextFactory._apply_granularity`` (this is the single convergence point
|
|
for *both* dashboard entry points — the native "Time Column" filter and the
|
|
Display Controls "Time Column" dropdown; both emit ``granularity_sqla`` via
|
|
``extra_form_data``). That method used to rewrite the x-axis BASE_AXIS column's
|
|
``label`` to the overridden column name. The result dataframe was then keyed
|
|
under the *overridden* column name, but the offset join, the post-processing
|
|
pivot ``index`` and the frontend all look the x-axis up by the *original* saved
|
|
label. With a Time Comparison offset in play that desynchronization collapses
|
|
the series into a single point.
|
|
|
|
The fix keeps the x-axis column's original label and only swaps the underlying
|
|
expression, so every consumer keeps referencing the same label.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from typing import Any, cast
|
|
|
|
import pandas as pd
|
|
|
|
from superset.common.query_context_factory import QueryContextFactory
|
|
from superset.common.query_object import QueryObject
|
|
from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
|
|
from superset.models.core import Database
|
|
from superset.superset_typing import Column
|
|
|
|
X_AXIS_LABEL = "wedding_date"
|
|
OVERRIDE_COLUMN = "purchase_date"
|
|
OFFSET_METRIC = "sum_revenue__1 year ago"
|
|
|
|
|
|
def _make_dataset(db_path: str) -> SqlaTable:
|
|
"""Build the reporter's dataset (monthly wedding/purchase dates)."""
|
|
uri = f"sqlite:///{db_path}"
|
|
|
|
rows = []
|
|
d = pd.Timestamp("2024-01-01")
|
|
while d <= pd.Timestamp("2026-04-01"):
|
|
rows.append(
|
|
{
|
|
"wedding_date": d.date().isoformat(),
|
|
# aligned but distinct from wedding_date
|
|
"purchase_date": (d + pd.Timedelta(days=14)).date().isoformat(),
|
|
"revenue": 100 + d.month * 10,
|
|
}
|
|
)
|
|
d = d + pd.DateOffset(months=1)
|
|
|
|
con = sqlite3.connect(db_path)
|
|
pd.DataFrame(rows).to_sql("weddings", con, index=False, if_exists="replace")
|
|
con.commit()
|
|
con.close()
|
|
|
|
database = Database(database_name="repro_db", sqlalchemy_uri=uri)
|
|
table = SqlaTable(table_name="weddings", database=database)
|
|
table.columns = [
|
|
TableColumn(column_name="wedding_date", is_dttm=True, type="DATETIME"),
|
|
TableColumn(column_name="purchase_date", is_dttm=True, type="DATETIME"),
|
|
TableColumn(column_name="revenue", type="INTEGER"),
|
|
]
|
|
table.metrics = [
|
|
SqlMetric(metric_name="sum_revenue", expression="SUM(revenue)"),
|
|
]
|
|
return table
|
|
|
|
|
|
def _x_axis(col: str) -> dict[str, Any]:
|
|
return {
|
|
"label": col,
|
|
"sqlExpression": col,
|
|
"expressionType": "SQL",
|
|
"columnType": "BASE_AXIS",
|
|
"timeGrain": "P1M",
|
|
"isColumnReference": True,
|
|
}
|
|
|
|
|
|
def _pivot_post_processing() -> list[dict[str, Any]]:
|
|
"""The pivot/flatten the frontend emits for a time-comparison line chart.
|
|
|
|
The pivot ``index`` is keyed on the *saved* x-axis label, mirroring
|
|
``timeComparePivotOperator``.
|
|
"""
|
|
return [
|
|
{
|
|
"operation": "pivot",
|
|
"options": {
|
|
"index": [X_AXIS_LABEL],
|
|
"columns": [],
|
|
"drop_missing_columns": False,
|
|
"aggregates": {
|
|
"sum_revenue": {"operator": "mean"},
|
|
OFFSET_METRIC: {"operator": "mean"},
|
|
},
|
|
},
|
|
},
|
|
{"operation": "flatten"},
|
|
]
|
|
|
|
|
|
def _build_query_object(
|
|
*, db_path: str, time_column_override: str | None
|
|
) -> QueryObject:
|
|
"""Build the query object for a saved chart, optionally overriding the
|
|
Time Column the way a dashboard's ``extra_form_data`` (granularity_sqla)
|
|
does."""
|
|
table = _make_dataset(db_path)
|
|
query_object = QueryObject(
|
|
datasource=table,
|
|
columns=cast("list[Column]", [_x_axis(X_AXIS_LABEL)]),
|
|
metrics=["sum_revenue"],
|
|
# A dashboard Time Column override sets granularity_sqla -> granularity.
|
|
granularity=time_column_override or X_AXIS_LABEL,
|
|
is_timeseries=False,
|
|
row_limit=10000,
|
|
time_offsets=["1 year ago"],
|
|
time_range="2025-01-01 : 2026-01-01",
|
|
from_dttm=pd.Timestamp("2025-01-01").to_pydatetime(),
|
|
to_dttm=pd.Timestamp("2026-01-01").to_pydatetime(),
|
|
filters=[
|
|
{
|
|
"col": X_AXIS_LABEL,
|
|
"op": "TEMPORAL_RANGE",
|
|
"val": "2025-01-01 : 2026-01-01",
|
|
}
|
|
],
|
|
post_processing=cast("list[dict[str, Any] | None]", _pivot_post_processing()),
|
|
extras={"time_grain_sqla": "P1M"},
|
|
)
|
|
if time_column_override:
|
|
# Both the native "Time Column" filter and the Display Controls dropdown
|
|
# converge here: extra_form_data.granularity_sqla -> granularity, then
|
|
# _apply_granularity re-points the x-axis at the overridden column.
|
|
QueryContextFactory()._apply_granularity(
|
|
query_object, {"x_axis": X_AXIS_LABEL}, table
|
|
)
|
|
return query_object
|
|
|
|
|
|
def _run(query_object: QueryObject) -> pd.DataFrame:
|
|
# ``get_query_result`` already runs the offset join and post-processing
|
|
# (pivot/flatten), returning the production-shaped dataframe the chart
|
|
# consumes.
|
|
table = cast(SqlaTable, query_object.datasource)
|
|
return table.get_query_result(query_object).df
|
|
|
|
|
|
def test_default_time_column_plots_across_range(app_context, tmp_path):
|
|
"""Sanity check: without an override the chart plots across the range."""
|
|
db_path = str(tmp_path / "weddings.db")
|
|
df = _run(_build_query_object(db_path=db_path, time_column_override=None))
|
|
assert X_AXIS_LABEL in df.columns
|
|
assert df[X_AXIS_LABEL].nunique() > 1
|
|
assert OFFSET_METRIC in df.columns
|
|
|
|
|
|
def test_overridden_time_column_does_not_collapse(app_context, tmp_path):
|
|
"""The reported bug: overriding the Time Column collapsed the chart into a
|
|
single point. After the fix the temporal x-axis is still returned under the
|
|
original label and plots across the full range with a populated offset."""
|
|
db_path = str(tmp_path / "weddings.db")
|
|
df = _run(
|
|
_build_query_object(db_path=db_path, time_column_override=OVERRIDE_COLUMN)
|
|
)
|
|
|
|
# The frontend (and the offset join / pivot) look the x-axis up by the
|
|
# *original* saved label; it must not be renamed to the overridden column.
|
|
assert X_AXIS_LABEL in df.columns, (
|
|
f"x-axis must stay under its original label; got {list(df.columns)}"
|
|
)
|
|
assert OVERRIDE_COLUMN not in df.columns
|
|
|
|
# Multiple points across the range instead of a single collapsed point.
|
|
assert df[X_AXIS_LABEL].nunique() > 1, "chart collapsed into a single point"
|
|
assert len(df) == 12
|
|
|
|
# The overridden column's data is what actually drives the series, and the
|
|
# time-comparison offset is populated (not all-NaN / single value).
|
|
assert OFFSET_METRIC in df.columns
|
|
assert df[OFFSET_METRIC].notna().sum() > 1
|