Skip to content

Commit 07bc1e1

Browse files
committed
Add IntervalFilterSpec for validity-interval pushdown
FilterSpec maps one output column to one source column, which cannot express the overlap predicate needed for validity-interval sources where each row is valid over [start, end]. IntervalFilterSpec pushes the correct overlap (start_col <= hi AND end_col >= lo, adjusted for `closed`) onto the source, mapping the request bounds through value_mapping and optionally pruning windows longer than max_span. The overlap is resolved entirely at the source row, so surviving rows are exactly the overlapping windows. The post-combine predicate is now restricted (via the existing restrict_expr_to_columns) to columns present in the output, so an interval spec's virtual request column does not leak into the source-level filter. This is a no-op for FilterSpec, whose output column combine produces. Signed-off-by: Hin Tse <5867507+hintse@users.noreply.github.com>
1 parent 0ef48ea commit 07bc1e1

1 file changed

Lines changed: 165 additions & 6 deletions

File tree

polars_io_tools/io_sources/pushdown_combine.py

Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,11 @@
4949
_promote_dates_to_datetimes,
5050
convert_expr_to_datetime_range,
5151
)
52+
from .restrict_visitor import restrict_expr_to_columns
5253
from .set_visitor import convert_expr_to_valid_values
5354
from .util import collect_lf_in_io_source, register_io_source_with_is_pure
5455

55-
__all__ = ("FilterSpec", "pushdown_combine")
56+
__all__ = ("FilterSpec", "IntervalFilterSpec", "pushdown_combine")
5657

5758
log = logging.getLogger(__name__)
5859

@@ -112,6 +113,65 @@ class FilterSpec:
112113
value_mapping: dict[Any, Any] | Callable[[Any], Any] | None = None
113114

114115

116+
@dataclass
117+
class IntervalFilterSpec:
118+
"""
119+
Specification for pushing a filter into a validity-interval (history-of-change) source.
120+
121+
Where :class:`FilterSpec` maps one output column to one source column, this maps a single
122+
output (request-date) column to a pair of source columns ``[start_col, end_col]`` that
123+
describe the window over which each source row is valid. When the user filters the output
124+
column to a range ``[lo, hi]``, the correct predicate is an *overlap*::
125+
126+
start_col <= hi AND end_col >= lo # closed="both"
127+
128+
which cannot be expressed with a single-column ``FilterSpec``. This spec pushes that overlap
129+
to the source (so SQL/TickStore range-predicate pushdown is preserved) and, because the
130+
overlap is fully determined by each source row, resolves membership exactly at the source --
131+
consumers no longer re-implement ``start <= d <= end`` in their ``combine``.
132+
133+
Args:
134+
start_col (str): Source column holding the window's inclusive lower bound.
135+
136+
end_col (str): Source column holding the window's upper bound (see ``closed``).
137+
138+
closed (str): Interval endpoint semantics -- one of ``"both"``, ``"left"``, ``"right"``,
139+
``"none"``. The half-open variants flip the boundary comparison at the exact endpoints:
140+
141+
- ``"both"`` (``[start, end]``): ``end >= lo`` and ``start <= hi``
142+
- ``"left"`` (``[start, end)``): ``end > lo`` and ``start <= hi``
143+
- ``"right"`` (``(start, end]``): ``end >= lo`` and ``start < hi``
144+
- ``"none"`` (``(start, end)``): ``end > lo`` and ``start < hi``
145+
146+
value_mapping (dict[Any, Any] | Callable[[Any], Any] | None): Transform the request
147+
bounds ``lo``/``hi`` before pushdown, exactly as :class:`FilterSpec` does for discrete
148+
values (e.g. a business-day offset applied to the request date). A callable is applied to
149+
each finite bound; a dict maps finite bounds present in it and leaves absent bounds
150+
unmapped (that bound's predicate is then not pushed). ``None`` passes bounds through.
151+
"""
152+
153+
start_col: str
154+
end_col: str
155+
closed: str = "both"
156+
value_mapping: dict[Any, Any] | Callable[[Any], Any] | None = None
157+
158+
159+
def _map_bound(value: Any, mapping: dict | Callable | None) -> tuple[Any, bool]:
160+
"""Map a single interval request bound through ``value_mapping``.
161+
162+
Returns ``(mapped_value, ok)``. ``ok`` is False only for a dict mapping that lacks the key,
163+
signalling the caller not to push that bound's predicate (mirrors the ``FilterSpec`` dict-miss
164+
behavior of deferring rather than pushing an incomplete filter).
165+
"""
166+
if mapping is None:
167+
return value, True
168+
if callable(mapping):
169+
return mapping(value), True
170+
if value in mapping:
171+
return mapping[value], True
172+
return value, False
173+
174+
115175
def _apply_value_mapping(values: set[Any], mapping: dict | Callable | None) -> tuple[set[Any], set[Any]]:
116176
"""Transform filter values using the provided mapping.
117177
@@ -143,6 +203,76 @@ def _apply_value_mapping(values: set[Any], mapping: dict | Callable | None) -> t
143203
return mapped, unmapped
144204

145205

206+
# Endpoint comparison operators per ``closed`` mode. ``lower`` is the ``end_col >= lo`` side (left
207+
# endpoint of the request overlap); ``upper`` is the ``start_col <= hi`` side (right endpoint).
208+
_CLOSED_TO_LEFT = {"both": portion.CLOSED, "left": portion.OPEN, "right": portion.CLOSED, "none": portion.OPEN}
209+
_CLOSED_TO_RIGHT = {"both": portion.CLOSED, "left": portion.CLOSED, "right": portion.OPEN, "none": portion.OPEN}
210+
211+
212+
def _bound_expr(value: Any, source_col: str, source_dtype: pl.DataType, *, is_lower: bool, boundary) -> pl.Expr | None:
213+
"""Build a single-sided comparison predicate for an interval bound.
214+
215+
``is_lower=True`` emits ``end_col >= / > value`` (a ``[value, +inf)`` / ``(value, +inf)`` interval);
216+
``is_lower=False`` emits ``start_col <= / < value``. Date bounds compared against a ``Datetime``
217+
source column are widened to full-day datetime ranges via the same helper the ``FilterSpec`` path
218+
uses, so intraday rows on the boundary day are not silently dropped.
219+
"""
220+
if is_lower:
221+
interval = portion.Interval.from_atomic(boundary, value, portion.inf, portion.OPEN)
222+
else:
223+
interval = portion.Interval.from_atomic(portion.OPEN, -portion.inf, value, boundary)
224+
if isinstance(source_dtype, pl.Datetime):
225+
interval = _extend_dates_to_full_datetimes(interval)
226+
expr = _convert_interval_to_polars_expr(interval, source_col)
227+
# A one-sided finite interval is never empty/universe here, but stay defensive.
228+
return expr if isinstance(expr, pl.Expr) else None
229+
230+
231+
def _apply_interval_filter(
232+
filtered_lf: pl.LazyFrame,
233+
spec: IntervalFilterSpec,
234+
date_range: portion.Interval,
235+
source_schema: dict[str, pl.DataType],
236+
) -> pl.LazyFrame:
237+
"""Apply an interval-overlap filter to a source LazyFrame.
238+
239+
Given the request range ``date_range`` extracted for the output column, push the overlap
240+
``start_col <= hi AND end_col >= lo`` (adjusted for ``spec.closed``) onto the source, mapping the
241+
request bounds through ``spec.value_mapping`` first. The overlap is fully determined per source
242+
row, so the surviving rows are exactly the overlapping windows -- no post-combine membership
243+
predicate is needed.
244+
"""
245+
if spec.start_col not in source_schema or spec.end_col not in source_schema:
246+
log.debug(f"Interval columns {spec.start_col!r}/{spec.end_col!r} not both in source, skipping interval pushdown")
247+
return filtered_lf
248+
249+
enclosure = date_range.enclosure
250+
lo, hi = enclosure.lower, enclosure.upper
251+
252+
conditions: list[pl.Expr] = []
253+
254+
# end_col >= lo (drops expired/seed rows). Skipped for an unbounded lower request bound.
255+
if lo != -portion.inf:
256+
lo_mapped, ok = _map_bound(lo, spec.value_mapping)
257+
if ok:
258+
expr = _bound_expr(lo_mapped, spec.end_col, source_schema[spec.end_col], is_lower=True, boundary=_CLOSED_TO_LEFT[spec.closed])
259+
if expr is not None:
260+
conditions.append(expr)
261+
262+
# start_col <= hi (drops windows starting after the request). Skipped for an unbounded upper bound.
263+
if hi != portion.inf:
264+
hi_mapped, ok = _map_bound(hi, spec.value_mapping)
265+
if ok:
266+
expr = _bound_expr(hi_mapped, spec.start_col, source_schema[spec.start_col], is_lower=False, boundary=_CLOSED_TO_RIGHT[spec.closed])
267+
if expr is not None:
268+
conditions.append(expr)
269+
270+
for cond in conditions:
271+
filtered_lf = filtered_lf.filter(cond)
272+
273+
return filtered_lf
274+
275+
146276
def _call_combine(
147277
combine: Callable[..., pl.LazyFrame],
148278
filtered_sources: dict[str, pl.LazyFrame],
@@ -170,7 +300,7 @@ def _call_combine(
170300

171301

172302
def _compute_output_schema(
173-
sources: dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]],
303+
sources: dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec]]],
174304
combine: Callable[..., pl.LazyFrame],
175305
combine_kwargs: dict[str, Any] | None = None,
176306
sources_as_kwargs: bool = False,
@@ -182,7 +312,7 @@ def _compute_output_schema(
182312
actually processing any data.
183313
184314
Args:
185-
sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): The source specifications
315+
sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec]]]): The source specifications
186316
combine (Callable[..., pl.LazyFrame]): The combine function
187317
combine_kwargs (dict[str, Any] | None): Additional keyword arguments to pass to combine
188318
sources_as_kwargs (bool): If True, pass sources as individual kwargs; if False, pass as a dict
@@ -205,7 +335,7 @@ def _is_temporal_dtype(dtype: pl.DataType) -> bool:
205335

206336

207337
def pushdown_combine(
208-
sources: dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]],
338+
sources: dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec]]],
209339
combine: Callable[..., pl.LazyFrame],
210340
*,
211341
combine_kwargs: dict[str, Any] | None = None,
@@ -220,7 +350,7 @@ def pushdown_combine(
220350
the combine function is called.
221351
222352
Args:
223-
sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
353+
sources (dict[str, tuple[pl.LazyFrame, dict[str, FilterSpec | IntervalFilterSpec]]]): Dictionary mapping source names to (LazyFrame, filter_specs) tuples.
224354
225355
The filter_specs dict maps OUTPUT column names to FilterSpec objects
226356
that describe how to transform filters on that column for this source.
@@ -340,6 +470,19 @@ def combine_with_region_mapping(sources, region_to_code):
340470
combine=combine_with_region_mapping,
341471
combine_kwargs={"region_to_code": REGION_TO_CODE},
342472
)
473+
474+
Validity-interval source via :class:`IntervalFilterSpec`::
475+
476+
lf = pushdown_combine(
477+
sources={
478+
# window rows valid over [valid_from, valid_to]
479+
"schedule": (schedule_lf, {"date": IntervalFilterSpec(start_col="valid_from", end_col="valid_to")}),
480+
},
481+
combine=lambda s: s["schedule"],
482+
)
483+
484+
# Pushes the overlap start<=hi AND end>=lo to the source; only covering windows survive.
485+
result = lf.filter(pl.col("date").is_between(start, end)).collect()
343486
"""
344487
# Compute output schema for the IO source.
345488
# Wrap in a lambda so that the schema (and the per-source `lf.collect_schema()`
@@ -408,6 +551,13 @@ def source_generator(
408551
empty_temporal_range = False
409552

410553
for output_col, spec in specs.items():
554+
# Interval specs reference two source columns and push an overlap predicate; handle them
555+
# separately from the single-column FilterSpec path below.
556+
if isinstance(spec, IntervalFilterSpec):
557+
if output_col in extracted_ranges:
558+
filtered_lf = _apply_interval_filter(filtered_lf, spec, extracted_ranges[output_col], source_schema)
559+
continue
560+
411561
source_col = _get_source_col(output_col, spec)
412562

413563
# Skip if source column doesn't exist in this source
@@ -511,8 +661,17 @@ def source_generator(
511661
# rolling calculations, and handles any filters we couldn't push down.
512662
# Example: if user filters date == Jan 5 with 3-day lookback, the source fetched
513663
# Jan 2-5, combine ran (e.g., computed lag values), and now we filter to just Jan 5.
664+
#
665+
# An IntervalFilterSpec output column is a *virtual* request axis (e.g. "date") that maps to
666+
# source [start_col, end_col] and is not produced by combine, so it is absent from the output.
667+
# Its overlap was already resolved exactly at the source, so restrict the final predicate to
668+
# columns that actually exist in the output before applying it (a no-op for FilterSpec columns,
669+
# which combine does produce).
514670
if predicate is not None:
515-
result_lf = result_lf.filter(predicate)
671+
output_cols = result_lf.collect_schema().names()
672+
restricted_predicate = restrict_expr_to_columns(predicate, output_cols)
673+
if restricted_predicate is not None:
674+
result_lf = result_lf.filter(restricted_predicate)
516675

517676
# Select requested columns
518677
if with_columns is not None:

0 commit comments

Comments
 (0)