Skip to content

Commit 157ef61

Browse files
sadpandajoeclaude
andauthored
fix(charts): render time comparison without a time grain (#42054)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f467d36 commit 157ef61

6 files changed

Lines changed: 1019 additions & 53 deletions

File tree

superset/models/helpers.py

Lines changed: 156 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,8 @@
140140
)
141141
from superset.utils.date_parser import (
142142
get_past_or_future,
143+
is_constant_human_timedelta,
144+
is_parseable_human_timedelta,
143145
normalize_time_delta,
144146
TimeDeltaAmbiguousError,
145147
)
@@ -172,6 +174,16 @@ class ValidationResultDict(TypedDict):
172174
R_SUFFIX = "__right_suffix"
173175

174176

177+
def _as_wall_clock(series: pd.Series) -> pd.Series:
178+
"""
179+
Return a datetime series as local wall-clock readings, dropping any
180+
timezone. Series of other dtypes are returned unchanged.
181+
"""
182+
if isinstance(series.dtype, pd.DatetimeTZDtype):
183+
return series.dt.tz_localize(None)
184+
return series
185+
186+
175187
class CachedTimeOffset(TypedDict):
176188
"""Result type for time offset processing"""
177189

@@ -1912,6 +1924,22 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme
19121924
outer_from_dttm,
19131925
outer_to_dttm,
19141926
)
1927+
elif not is_parseable_human_timedelta(offset):
1928+
# get_past_or_future silently returns the source time
1929+
# for offsets it cannot parse; querying with an
1930+
# unshifted window would present the current period's
1931+
# data as the comparison series. The parse flag (not
1932+
# a zero delta) is the unparseability signal, so
1933+
# legitimate zero-shift offsets like "0 days ago"
1934+
# pass through.
1935+
raise QueryObjectValidationError(
1936+
_(
1937+
"Unable to interpret the time offset: "
1938+
"%(offset)s. Use a relative time such as "
1939+
'"1 month ago".',
1940+
offset=offset,
1941+
)
1942+
)
19151943
query_object_clone.from_dttm = get_past_or_future(
19161944
offset,
19171945
outer_from_dttm,
@@ -2118,6 +2146,7 @@ def processing_time_offsets( # pylint: disable=too-many-locals,too-many-stateme
21182146
time_grain,
21192147
join_keys,
21202148
full_range=getattr(query_object, "time_compare_full_range", False),
2149+
x_axis_label=get_x_axis_label(query_object.columns),
21212150
)
21222151

21232152
return CachedTimeOffset(df=df, queries=queries, cache_keys=cache_keys)
@@ -2172,8 +2201,15 @@ def get_offset_custom_or_inherit(
21722201
:returns: The time offset.
21732202
"""
21742203
if offset == "inherit":
2175-
# return the difference in days between the from and the to dttm formatted as a string with the " days ago" suffix # noqa: E501
2176-
return f"{(outer_to_dttm - outer_from_dttm).days} days ago"
2204+
# Shift back by the full length of the range, so the comparison
2205+
# covers the period immediately preceding it. The duration is
2206+
# expressed in seconds rather than days because ``timedelta.days``
2207+
# truncates: a 12-hour range would yield "0 days ago" and compare
2208+
# the range against itself, and a 36-hour range would shift by a
2209+
# single day and overlap it. Whole-day ranges resolve to the same
2210+
# instant either way.
2211+
duration = outer_to_dttm - outer_from_dttm
2212+
return f"{int(duration.total_seconds())} seconds ago"
21772213
if self.is_valid_date(offset):
21782214
# return the offset as the difference in days between the outer from dttm and the offset date (which is a YYYY-MM-DD string) formatted as a string with the " days ago" suffix # noqa: E501
21792215
offset_date = datetime.strptime(offset, "%Y-%m-%d")
@@ -2268,7 +2304,7 @@ def _apply_cleanup_logic(
22682304
)
22692305
else:
22702306
df.drop(
2271-
list(df.filter(regex=f"{R_SUFFIX}")),
2307+
list(df.filter(regex=f"{OFFSET_JOIN_COLUMN_SUFFIX}|{R_SUFFIX}")),
22722308
axis=1,
22732309
inplace=True,
22742310
)
@@ -2284,6 +2320,7 @@ def _determine_join_keys(
22842320
join_keys: list[str],
22852321
is_date_range_offset: bool,
22862322
join_column_producer: Any,
2323+
x_axis_label: str | None = None,
22872324
) -> tuple[pd.DataFrame, list[str]]:
22882325
"""Determine appropriate join keys and modify DataFrames if needed."""
22892326
if time_grain and not is_date_range_offset:
@@ -2312,8 +2349,110 @@ def _determine_join_keys(
23122349
return self._process_date_range_offset(offset_df, join_keys)
23132350

23142351
else:
2352+
return self._align_offset_without_time_grain(
2353+
df, offset_df, offset, join_keys, x_axis_label
2354+
)
2355+
2356+
def _align_offset_without_time_grain(
2357+
self,
2358+
df: pd.DataFrame,
2359+
offset_df: pd.DataFrame,
2360+
offset: str,
2361+
join_keys: list[str],
2362+
x_axis_label: str | None = None,
2363+
) -> tuple[pd.DataFrame, list[str]]:
2364+
"""
2365+
Determine join keys for a relative offset when no time grain is set.
2366+
2367+
Without a time grain there is no truncated join column, but the two
2368+
series can still be aligned exactly: shifting the main series'
2369+
timestamps by the offset delta lands them on the offset series' raw
2370+
timestamps (normalize_time_delta returns a negative delta for "... ago"
2371+
offsets). Timestamps without an exact counterpart in the offset series
2372+
produce nulls, mirroring the grain-based join, and null timestamps in
2373+
the two series join to each other (both stringify to "NaT"). When
2374+
there is no temporal join key the original join keys are used as-is.
2375+
2376+
The shift is computed on wall-clock time, with any timezone dropped for
2377+
the duration of the alignment. The offset query's own time range was
2378+
shifted the same way -- ``get_past_or_future`` reads naive timestamps
2379+
-- so the rows it returns carry the source wall clock, and matching on
2380+
it is what aligns the two series. Re-localizing the result would only
2381+
reintroduce the DST edge cases that wall-clock arithmetic sidesteps:
2382+
shifting onto a skipped or repeated local hour raises out of pandas.
2383+
Both sides are normalized identically, so the two readings of a
2384+
repeated hour still align with each other.
2385+
2386+
Month, quarter, and year offsets shift via ``DateOffset``, which clamps
2387+
to a valid calendar day (e.g. Mar 29, 30, and 31 all shift back one
2388+
month to Feb 28), so on daily/irregular data several end-of-month rows
2389+
can align to the same offset timestamp. This mirrors the inherent
2390+
ambiguity of "the same day N months ago" without a time grain to
2391+
truncate against.
2392+
"""
2393+
# Prefer the query's temporal x-axis when it is a join key; otherwise
2394+
# use the first datetime join key.
2395+
candidate_keys = sorted(join_keys, key=lambda key: key != x_axis_label)
2396+
temporal_join_key = next(
2397+
(
2398+
key
2399+
for key in candidate_keys
2400+
if key in df.columns
2401+
and key in offset_df.columns
2402+
and pd.api.types.is_datetime64_any_dtype(df[key])
2403+
),
2404+
None,
2405+
)
2406+
if not temporal_join_key:
23152407
return offset_df, join_keys
23162408

2409+
source = _as_wall_clock(df[temporal_join_key])
2410+
2411+
try:
2412+
delta: DateOffset | None = DateOffset(**normalize_time_delta(offset))
2413+
except (ValueError, TimeDeltaAmbiguousError):
2414+
delta = None
2415+
2416+
column_name = OFFSET_JOIN_COLUMN_SUFFIX + offset
2417+
if delta is not None:
2418+
# DateOffset addition is vectorized over the datetime column; NaT
2419+
# rows shift to NaT (they join to the offset series' NaT rows).
2420+
shifted = source + delta
2421+
else:
2422+
# Free-form offsets (e.g. "one year ago") don't match the
2423+
# normalize_time_delta grammar; shift with the same parser that
2424+
# shifted the offset query's time range. parsedatetime resolves
2425+
# second resolution only, so compute each row's delta from a
2426+
# truncated copy and apply it to the original value, preserving
2427+
# sub-second precision.
2428+
if not is_constant_human_timedelta(offset):
2429+
# Anchors such as "yesterday" resolve every source time within
2430+
# a day onto one timestamp rather than shifting each by a
2431+
# fixed amount, so they cannot align two series row by row:
2432+
# distinct timestamps would collapse onto a single join key.
2433+
# A time grain gives the join a truncated column to match on
2434+
# instead of a shifted one.
2435+
raise QueryObjectValidationError(
2436+
_("Time Grain must be specified when using Time Comparison.")
2437+
)
2438+
2439+
def shift(value: pd.Timestamp) -> pd.Timestamp:
2440+
if pd.isna(value):
2441+
return value
2442+
truncated = value.floor("s").to_pydatetime()
2443+
return value + (get_past_or_future(offset, truncated) - truncated)
2444+
2445+
shifted = source.map(shift)
2446+
2447+
# Join on string values so that mismatched key dtypes (e.g. an empty
2448+
# offset series materializes its join keys as NaN floats) cannot break
2449+
# the merge.
2450+
df[column_name] = shifted.map(str)
2451+
offset_df[column_name] = _as_wall_clock(offset_df[temporal_join_key]).map(str)
2452+
2453+
remaining_keys = [key for key in join_keys if key != temporal_join_key]
2454+
return offset_df, [column_name, *remaining_keys]
2455+
23172456
def _perform_join(
23182457
self,
23192458
df: pd.DataFrame,
@@ -2357,6 +2496,7 @@ def join_offset_dfs(
23572496
time_grain: str | None,
23582497
join_keys: list[str],
23592498
full_range: bool = False,
2499+
x_axis_label: str | None = None,
23602500
) -> pd.DataFrame:
23612501
"""
23622502
Join offset DataFrames with the main DataFrame.
@@ -2369,31 +2509,13 @@ def join_offset_dfs(
23692509
time range instead of being truncated to the main series' range. This
23702510
uses an outer join so offset-only rows (e.g. the rest of a prior day when
23712511
the current day is still in progress) are preserved.
2512+
:param x_axis_label: The query's temporal x-axis label, used to pick the
2513+
temporal join key when no time grain is set.
23722514
"""
23732515
join_column_producer = app.config["TIME_GRAIN_JOIN_COLUMN_PRODUCERS"].get(
23742516
time_grain
23752517
)
2376-
2377-
if not time_grain:
2378-
has_temporal_join_key = any(
2379-
pd.api.types.is_datetime64_any_dtype(df[key])
2380-
for key in join_keys
2381-
if key in df.columns
2382-
)
2383-
if has_temporal_join_key:
2384-
has_relative_offset = any(
2385-
not (
2386-
self.is_valid_date_range(offset)
2387-
and feature_flag_manager.is_feature_enabled(
2388-
"DATE_RANGE_TIMESHIFTS_ENABLED"
2389-
)
2390-
)
2391-
for offset in offset_dfs
2392-
)
2393-
if has_relative_offset:
2394-
raise QueryObjectValidationError(
2395-
_("Time Grain must be specified when using Time Comparison.")
2396-
)
2518+
original_columns = list(df.columns)
23972519

23982520
for offset, offset_df in offset_dfs.items():
23992521
is_date_range_offset = self.is_valid_date_range(
@@ -2410,6 +2532,7 @@ def join_offset_dfs(
24102532
join_keys,
24112533
is_date_range_offset,
24122534
join_column_producer,
2535+
x_axis_label,
24132536
)
24142537

24152538
# The full-range option is only meaningful for relative offsets aligned
@@ -2432,6 +2555,15 @@ def join_offset_dfs(
24322555
df, offset, time_grain, join_keys, is_date_range_offset
24332556
)
24342557

2558+
if not time_grain and not is_date_range_offset:
2559+
# The grain-less join indexes on the synthetic key plus the
2560+
# non-temporal join keys, which reset_index moves to the front;
2561+
# restore the original column order.
2562+
df = df[
2563+
[col for col in original_columns if col in df.columns]
2564+
+ [col for col in df.columns if col not in original_columns]
2565+
]
2566+
24352567
return df
24362568

24372569
def _coalesce_offset_index(

superset/utils/date_parser.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,33 @@
6060

6161
logger = logging.getLogger(__name__)
6262

63+
# Source times used by ``is_constant_human_timedelta`` to tell a delta from an
64+
# anchor. They share a date -- mid-month and mid-year, away from any month or
65+
# year boundary a shift could clamp against -- and differ only in the hour, so
66+
# that the sole thing the comparison can detect is sensitivity to time of day.
67+
# Neither hour is parsedatetime's 09:00 default, so an anchor cannot coincide
68+
# with a probe and masquerade as a zero shift.
69+
_SHIFT_PROBE_TIMES: tuple[datetime, datetime] = (
70+
datetime(2024, 6, 15, 3, 0, 0),
71+
datetime(2024, 6, 15, 21, 0, 0),
72+
)
73+
6374
# Mapping of ordinal words to their numeric values for date expressions
6475
ORDINAL_MAP: dict[str, int] = {
6576
"first": 1,
6677
"1st": 1,
6778
}
6879

80+
# parsedatetime does not understand "N quarters" (it leaves the source time
81+
# unchanged), so such phrases are rewritten to the equivalent number of months
82+
# before parsing. The lookbehind and the bounded repetition keep matching
83+
# linear on user-provided strings (every suffix of an unbounded digit run
84+
# would be re-scanned) and keep the int() conversion small; longer digit
85+
# runs fall through to parsedatetime like any other unparseable phrase.
86+
_QUARTERS_PATTERN: re.Pattern[str] = re.compile(
87+
r"(?<![0-9])([0-9]{1,10})\s+quarters?\b", re.IGNORECASE
88+
)
89+
6990

7091
def parse_human_datetime(human_readable: str) -> datetime:
7192
"""Returns ``datetime.datetime`` from human readable strings"""
@@ -95,9 +116,12 @@ def normalize_time_delta(human_readable: str) -> dict[str, int]:
95116
if not matched:
96117
raise TimeDeltaAmbiguousError(human_readable)
97118

98-
key = matched[2] + "s"
119+
key = matched[2].lower() + "s"
99120
value = int(matched[1])
100-
value = -value if matched[3] == "ago" else value
121+
value = -value if (matched[3] or "").lower() == "ago" else value
122+
if key == "quarters":
123+
# pd.DateOffset does not accept a `quarters` argument
124+
key, value = "months", value * 3
101125
return {key: value}
102126

103127

@@ -112,6 +136,13 @@ def dttm_from_timetuple(date_: struct_time) -> datetime:
112136
)
113137

114138

139+
def _rewrite_quarters_as_months(human_readable: str | None) -> str:
140+
return _QUARTERS_PATTERN.sub(
141+
lambda match: f"{int(match[1]) * 3} months",
142+
human_readable or "",
143+
)
144+
145+
115146
def get_past_or_future(
116147
human_readable: str | None,
117148
source_time: datetime | None = None,
@@ -120,7 +151,47 @@ def get_past_or_future(
120151
source_dttm = dttm_from_timetuple(
121152
source_time.timetuple() if source_time else datetime.now().timetuple()
122153
)
123-
return dttm_from_timetuple(cal.parse(human_readable or "", source_dttm)[0])
154+
human_readable = _rewrite_quarters_as_months(human_readable)
155+
return dttm_from_timetuple(cal.parse(human_readable, source_dttm)[0])
156+
157+
158+
def is_parseable_human_timedelta(human_readable: str | None) -> bool:
159+
"""
160+
Returns whether parsedatetime understands the phrase.
161+
162+
parsedatetime echoes the source time back for phrases it cannot parse,
163+
so a zero ``parse_human_timedelta`` result cannot distinguish an
164+
uninterpretable phrase from one that legitimately parses to no shift
165+
(e.g. "0 days ago"). The parse flag makes that distinction: it is 0
166+
only when nothing in the phrase was understood.
167+
"""
168+
cal = parsedatetime.Calendar()
169+
return cal.parse(_rewrite_quarters_as_months(human_readable))[1] != 0
170+
171+
172+
def is_constant_human_timedelta(human_readable: str | None) -> bool:
173+
"""
174+
Returns whether the phrase shifts every source time by the same amount.
175+
176+
``is_parseable_human_timedelta`` accepts anchors such as "yesterday" and
177+
"last month" alongside true deltas such as "1 year ago", but the two
178+
behave differently when applied per row: an anchor resolves to a single
179+
timestamp (parsedatetime defaults to 09:00) no matter where the source
180+
time sits within the day, so it shifts each row by a different amount.
181+
182+
Probing two source times within the same day separates the two. A delta
183+
shifts both probes equally; an anchor maps both onto one timestamp, which
184+
-- the probes being distinct -- necessarily yields differing shifts. Both
185+
probes share a date, so calendar irregularities such as leap years and
186+
month lengths apply to them identically and cannot skew the comparison.
187+
"""
188+
if not is_parseable_human_timedelta(human_readable):
189+
return False
190+
deltas = {
191+
get_past_or_future(human_readable, probe) - probe
192+
for probe in _SHIFT_PROBE_TIMES
193+
}
194+
return len(deltas) == 1
124195

125196

126197
def parse_human_timedelta(

0 commit comments

Comments
 (0)