140140)
141141from 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):
172174R_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+
175187class 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 (
0 commit comments