aminghadersohi commented on code in PR #45671:
URL: https://github.com/apache/superset/pull/45671#discussion_r4416305277
##########
superset/common/query_object.py:
##########
@@ -782,11 +784,54 @@ def exec_post_processing(self, df: DataFrame) ->
DataFrame:
func = extra_ops[operation]
options = post_process.get("options", {})
if operation == "resample":
- options = self._resolve_resample_options(options)
+ options = self._resolve_resample_options(options, df)
df = func(df, **options)
return df
- def _resolve_resample_options(self, options: dict[str, Any]) -> dict[str,
Any]:
+ def _resample_display_bound(
+ self, bound: datetime | None, df: DataFrame
+ ) -> datetime | None:
+ """Align explicit instants with a datasource's normalized display
frame."""
+ if (
+ bound is None
+ or bound.utcoffset() is None
+ or self.datasource is None
+ or getattr(df.index, "tz", None) is not None
+ ):
+ return bound
+ column_type = next(
+ (
+ col.type
+ for col in getattr(self.datasource, "columns", [])
+ if col.column_name == self.granularity
Review Comment:
The DATE check reads only `self.granularity`, which is `None` for x-axis
charts: a DATE x-axis with `fill_time_range` over `2024-05-01T00:00:00+08:00 :
2024-06-01T00:00:00+08:00` pads an extra `2024-04-30` row outside the SQL
filter.
##########
tests/unit_tests/models/test_time_bound_consumers.py:
##########
@@ -0,0 +1,308 @@
+# 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.
+"""Offset-aware quarter bounds through their real downstream consumers."""
+
+import sqlite3
+from datetime import datetime, timedelta, timezone
+
+import pandas as pd
+import pytest
+from flask import Flask
+
+from superset.common.query_object import QueryObject
+from superset.connectors.sqla.models import SqlaTable, TableColumn
+from superset.jinja_context import ExtraCache
+from superset.mcp_service.dashboard_scope import (
+ _rewrite_dataset_request,
+ DashboardConstraints,
+ intersect_time_ranges,
+)
+from superset.models.core import Database
+from superset.utils.core import DateColumn, normalize_dttm_col
+from superset.utils.date_parser import (
+ datetime_eval,
+ get_past_or_future,
+ get_since_until,
+)
+
+QUARTER_RANGE = (
+ "DATETRUNC(DATETIME('2024-05-31T12:00:00+08:00'), QUARTER) : "
+ "DATETIME('2024-07-01T00:00:00+08:00')"
+)
+
+
+def _dataset(
+ extra: str | None = None,
+ offset: int = 0,
+ column_type: str = "TIMESTAMP",
+ uri: str = "sqlite://",
+) -> tuple[SqlaTable, TableColumn]:
+ """Build a real datasource with temporal metadata without connecting to a
DB."""
+ col = TableColumn(column_name="ts", type=column_type, is_dttm=True)
+ table = SqlaTable(
+ table_name="events",
+ columns=[col],
+ main_dttm_col="ts",
+ database=Database(database_name="test", sqlalchemy_uri=uri),
+ extra=extra,
+ offset=offset,
+ )
+ return table, col
+
+
[email protected]("unit", ["", ", DAY"])
+def test_review_datediff_mixed_quarter_bounds(unit: str) -> None:
+ """DATEDIFF and nesting it in DATEADD retain the legacy mixed-bound
duration."""
+ diff = (
+ "DATEDIFF(DATETRUNC(DATETIME('2024-05-31T12:00:00+08:00'), QUARTER), "
+ f"DATETIME('2024-06-01'){unit})"
+ )
+ assert datetime_eval(diff) == 61
+ assert datetime_eval(f"DATEADD(DATETIME('2024-01-01'), {diff}, DAY)") ==
datetime(
+ 2024, 3, 2
+ )
+
+
[email protected]("phrase", ["1 year ago", "1 month ago", "1 quarter
ago"])
+def test_review_comparison_shift_keeps_offset(app: Flask, phrase: str) -> None:
+ """Comparison bounds keep their input offset through the real shift and
SQL."""
+ table, col = _dataset('{"timezone": "Europe/Berlin"}')
+ start, end = get_since_until(QUARTER_RANGE)
+ assert start is not None
+ assert end is not None
+ shifted_start = get_past_or_future(phrase, start)
+ shifted_end = get_past_or_future(phrase, end)
+ assert shifted_start.utcoffset() == timedelta(hours=8)
+ assert shifted_end.utcoffset() == timedelta(hours=8)
+ with app.app_context():
+ sql = str(table.get_time_filter(col, shifted_start, shifted_end))
+ assert shifted_start.astimezone(timezone.utc).strftime("%Y-%m-%d
%H:%M:%S") in sql
+
+
[email protected]("uri", ["sqlite://", "postgresql://"])
+def test_review_date_columns_preserve_calendar_dates(app: Flask, uri: str) ->
None:
+ """DATE literals retain the input calendar date rather than the prior UTC
date."""
+ table, col = _dataset(column_type="DATE", uri=uri)
+ start, end = get_since_until(
+ "2024-05-01T00:00:00+08:00 : 2024-06-01T00:00:00+08:00"
+ )
+ with app.app_context():
+ adjusted = table.adjust_time_bounds(start, end, col)
+ sql = str(table.get_time_filter(col, start, end))
+ assert adjusted == (datetime(2024, 5, 1), datetime(2024, 6, 1))
+ assert "2024-05-01" in sql
+ assert "2024-06-01" in sql
+ assert "2024-04-30" not in sql
+
+
[email protected]("extra", [None, '{"timezone": "Invalid/Zone"}'])
+def test_review_hour_offset_applies_only_to_naive_bounds(
+ app: Flask, extra: str | None
+) -> None:
+ """An aware start is converted once; the naive end uses the dataset Hour
Offset."""
+ table, col = _dataset(extra, offset=8)
+ start, end = get_since_until(
+ "DATETRUNC(DATETIME('2024-05-31T12:00:00+08:00'), QUARTER) :
2024-07-01"
+ )
+ with app.app_context():
+ assert table.adjust_time_bounds(start, end, col) == (
+ datetime(2024, 3, 31, 16),
+ datetime(2024, 6, 30, 16),
+ )
+
+
[email protected]("extra", [None, '{"timezone": "Europe/Berlin"}'])
+def test_review_jinja_filter_matches_chart_and_sqlite_rows(
+ app: Flask, extra: str | None
+) -> None:
+ """The Jinja macro and chart clause select the same actual SQLite rows."""
+ table, col = _dataset(extra, offset=8)
+ start, end = get_since_until(QUARTER_RANGE)
+ with app.test_request_context(json={"queries": [{"time_range":
QUARTER_RANGE}]}):
+ macro = ExtraCache(database=table.database,
table=table).get_time_filter(
+ "ts", target_type="TIMESTAMP"
+ )
+ chart = table.get_time_filter(col, start, end)
+ with sqlite3.connect(":memory:") as connection:
+ connection.execute("CREATE TABLE events (ts TEXT)")
+ connection.executemany(
+ "INSERT INTO events VALUES (?)",
+ [
+ ("2024-03-31 15:59:59",),
+ ("2024-03-31 20:00:00",),
+ ("2024-06-30 16:00:00",),
+ ],
+ )
+ macro_rows = connection.execute(
+ f"SELECT ts FROM events WHERE ts >= {macro.from_expr} " # noqa:
S608
+ f"AND ts < {macro.to_expr}"
+ ).fetchall()
+ chart_rows = connection.execute(
+ f"SELECT ts FROM events WHERE {chart}" # noqa: S608
+ ).fetchall()
+ assert macro_rows == chart_rows == [("2024-03-31 20:00:00",)]
+
+
[email protected]("index_tz", [None, "UTC", "Europe/Berlin"])
+def test_review_fill_time_range_matches_queried_instants(index_tz: str | None)
-> None:
+ """Hourly fill covers the UTC query period, including its exclusive end."""
+ start, end = get_since_until(QUARTER_RANGE)
+ query = QueryObject(
+ row_limit=1,
+ from_dttm=start,
+ to_dttm=end,
+ post_processing=[
+ {
+ "operation": "resample",
+ "options": {
+ "rule": "1h",
+ "method": "asfreq",
+ "fill_value": 0,
+ "fill_time_range": True,
+ },
+ }
+ ],
+ )
+ frame = pd.DataFrame(
+ {"value": [1.0]}, index=pd.DatetimeIndex(["2024-05-01"], tz=index_tz)
+ )
+ result = query.exec_post_processing(frame)
+ index = result.index if index_tz is None else result.index.tz_convert(None)
+ assert index[0] == pd.Timestamp("2024-03-31 16:00:00")
+ assert index[-1] == pd.Timestamp("2024-06-30 15:00:00")
+ assert len(index) == 2184
+
+
[email protected](
+ "extra,offset,display_start,display_end",
+ [
+ ('{"timezone": "Europe/Berlin"}', 0, "2024-03-31 18:00", "2024-06-30
17:00"),
+ (None, 8, "2024-04-01 00:00", "2024-06-30 23:00"),
+ ],
+)
+def test_review_fill_time_range_uses_normalized_display_frame(
+ extra: str | None, offset: int, display_start: str, display_end: str
+) -> None:
+ """Padding uses the same display frame as the datasource-normalized
data."""
+ table, _ = _dataset(extra, offset)
+ start, end = get_since_until(QUARTER_RANGE)
+ query = QueryObject(
+ datasource=table,
+ row_limit=1,
+ from_dttm=start,
+ to_dttm=end,
+ post_processing=[
+ {
+ "operation": "resample",
+ "options": {
+ "rule": "1h",
+ "method": "asfreq",
+ "fill_value": 0,
+ "fill_time_range": True,
+ },
+ }
+ ],
+ )
+ frame = pd.DataFrame({"ts": pd.to_datetime(["2024-05-01"]), "value":
[1.0]})
+ normalize_dttm_col(
+ frame,
+ (
+ DateColumn(
+ col_label="ts", offset=offset,
timezone=table.get_dataset_timezone()
+ ),
+ ),
+ )
+ result = query.exec_post_processing(frame.set_index("ts"))
+ assert result.index[0] == pd.Timestamp(display_start)
+ assert result.index[-1] == pd.Timestamp(display_end)
+
+
+def test_review_mcp_mixed_result_survives_reparse() -> None:
+ """A valid instant range stays valid when the selected endpoints are
mixed."""
+ table, col = _dataset()
+ result = intersect_time_ranges(
+ "2024-04-01T00:00:00+08:00 : 2024-04-02T00:00:00+08:00",
+ "2024-03-31T20:00:00 : 2024-04-03",
+ )
+ assert table.adjust_time_bounds(*get_since_until(result), col) == (
+ datetime(2024, 3, 31, 20),
+ datetime(2024, 4, 1, 16),
+ )
+
+
+def test_review_fill_time_range_retains_date_column_calendar() -> None:
+ """DATE padding retains the same calendar boundaries as the SQL filter."""
+ table, _ = _dataset(column_type="DATE")
+ start, end = get_since_until(
+ "2024-05-01T00:00:00+08:00 : 2024-06-01T00:00:00+08:00"
+ )
+ query = QueryObject(
+ datasource=table,
+ granularity="ts",
+ row_limit=1,
+ from_dttm=start,
+ to_dttm=end,
+ post_processing=[
+ {
+ "operation": "resample",
+ "options": {
+ "rule": "1h",
+ "method": "asfreq",
+ "fill_value": 0,
+ "fill_time_range": True,
+ },
+ }
+ ],
+ )
+ frame = pd.DataFrame({"value": [1.0]},
index=pd.to_datetime(["2024-05-15"]))
+ result = query.exec_post_processing(frame)
+ assert result.index[0] == pd.Timestamp("2024-05-01")
+ assert result.index[-1] == pd.Timestamp("2024-05-31 23:00:00")
+
+
+def test_review_mcp_intersection_cannot_widen_offset_scope() -> None:
+ """03:00 UTC cannot replace a dashboard scope beginning at 05:00 UTC."""
+ result = intersect_time_ranges(
+ "DATETRUNC(DATETIME('2024-05-31T12:00:00-05:00'), QUARTER) :
2024-07-01",
+ "2024-04-01T03:00:00 : 2024-06-01",
+ )
+ table, col = _dataset()
+ bounds = get_since_until(result)
+ assert table.adjust_time_bounds(*bounds, col)[0] == datetime(2024, 4, 1, 5)
+
+
[email protected](
+ "extra,offset", [(None, 8), ('{"timezone": "Europe/Berlin"}', 0)]
+)
+def test_review_mcp_rewrite_uses_dataset_bound_semantics(
+ extra: str | None, offset: int
+) -> None:
+ """The dataset request rewrite intersects using the same adjustments as
SQL."""
+ table, col = _dataset(extra, offset)
+ scope_range = "2024-04-01T00:00:00-05:00 : 2024-07-01"
Review Comment:
Nit: This passes with `normalize_bound=normalize_bound` dropped from
`_rewrite_dataset_request`: the scope start wins under either rule here. An
`America/Los_Angeles` dataset (model `03:00` is `10:00Z`) would pin it.
##########
superset/models/helpers.py:
##########
@@ -5881,48 +5894,51 @@ def adjust_time_bounds(
against the engine -- see exactly the instants the clause compares
against, rather than a copy of this logic that can drift.
"""
- dataset_timezone = self.get_dataset_timezone()
-
- if dataset_timezone and (start_dttm or end_dttm):
+ tz = None
+ if dataset_timezone := self.get_dataset_timezone():
try:
tz = ZoneInfo(dataset_timezone)
-
- # The datetimes from the UI are naive (no timezone info). We
- # interpret them as being in the dataset's configured timezone
and
- # convert them to UTC for comparison with UTC-stored data.
- if start_dttm is not None:
- start_dttm = (
- start_dttm.replace(tzinfo=tz)
- .astimezone(timezone.utc)
- .replace(tzinfo=None)
- )
- if end_dttm is not None:
- end_dttm = (
- end_dttm.replace(tzinfo=tz)
- .astimezone(timezone.utc)
- .replace(tzinfo=None)
- )
except (ZoneInfoNotFoundError, TypeError):
logger.warning(
"Invalid timezone %r in dataset extra; falling back to
Hour "
"Offset if configured",
dataset_timezone,
)
- dataset_timezone = None
-
- if not dataset_timezone and (offset_hours := getattr(self, "offset",
0) or 0):
+ column_type = target_type or (time_col.type if time_col is not None
else None)
+ sqla_type = (
+ self.db_engine_spec.get_sqla_column_type(column_type)
+ if column_type
+ else None
+ )
+ is_date = isinstance(sqla_type, sa.Date) and not isinstance(
Review Comment:
Date-only string columns still lose a day: a VARCHAR column with
`python_date_format` `%Y-%m-%d` and range `2024-04-01T00:00:00+08:00 :
2024-07-01T00:00:00+08:00` renders Postgres `ts >= '2024-03-31' AND ts <
'2024-06-30'`; master kept `2024-04-01`/`2024-07-01`.
##########
superset/jinja_context.py:
##########
@@ -727,6 +728,37 @@ def _get_filters_from_query_context(
return filters
# pylint: disable=too-many-arguments
+ def _normalize_time_filter_bounds(
+ self,
+ start: datetime | None,
+ end: datetime | None,
+ column: str | None,
+ target_type: str | None,
+ ) -> tuple[datetime | None, datetime | None]:
+ """Use the same bound interpretation as the datasource's chart
filter."""
+ if self.table:
+ time_col = next(
+ (
+ col
+ for col in self.table.columns
+ if col.column_name == (column or self.table.main_dttm_col)
+ ),
+ None,
+ )
+ return self.table.adjust_time_bounds(
+ start, end, time_col, target_type=target_type
+ )
Review Comment:
This also shifts naive ranges: with dataset timezone `Asia/Shanghai` or Hour
Offset 8, `get_time_filter('ds', strftime='%Y-%m-%d')` over `2024-05-01 :
2024-06-01` returns `2024-04-30`/`2024-05-31` where master returned
`2024-05-01`/`2024-06-01`.
```suggestion
adjusted = self.table.adjust_time_bounds(
start, end, time_col, target_type=target_type
)
# Naive bounds keep the macro's unshifted wall-clock values.
return (
adjusted[0] if start and start.utcoffset() is not None else
start,
adjusted[1] if end and end.utcoffset() is not None else end,
)
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]