Skip to content

Commit 5814bab

Browse files
authored
feat: expose residual_expression and post_join_filter on join builders (#225)
1 parent 7397f2e commit 5814bab

5 files changed

Lines changed: 393 additions & 24 deletions

File tree

src/substrait/builders/plan.py

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
resolve_expression,
2020
)
2121
from substrait.extension_registry import ExtensionRegistry
22-
from substrait.type_inference import infer_plan_schema, join_output_names
22+
from substrait.type_inference import (
23+
_join_output_struct,
24+
infer_plan_schema,
25+
join_output_names,
26+
)
2327
from substrait.utils import (
2428
merge_extension_declarations,
2529
merge_extension_urns,
@@ -487,6 +491,7 @@ def resolve(registry: ExtensionRegistry) -> stp.Plan:
487491
left_ns = infer_plan_schema(bound_left, registry=registry)
488492
right_ns = infer_plan_schema(bound_right, registry=registry)
489493

494+
# The join condition binds against the combined left+right schema.
490495
ns = stt.NamedStruct(
491496
struct=stt.Type.Struct(
492497
types=list(left_ns.struct.types) + list(right_ns.struct.types),
@@ -497,11 +502,28 @@ def resolve(registry: ExtensionRegistry) -> stp.Plan:
497502
bound_expression: stee.ExtendedExpression = resolve_expression(
498503
expression, ns, registry
499504
)
500-
bound_post = (
501-
resolve_expression(post_join_filter, ns, registry)
502-
if post_join_filter is not None
503-
else None
504-
)
505+
506+
# The output names must match the columns the join type actually emits
507+
# (semi/anti drop a side, mark appends a boolean).
508+
type_name = stalg.JoinRel.JoinType.Name(type)
509+
out_names = join_output_names(type_name, left_ns.names, right_ns.names)
510+
511+
# post_join_filter is applied to each output record after
512+
# join-type-specific output formation (semantically a FilterRel above the
513+
# join), so it resolves against the output schema -- which for semi/anti
514+
# joins is a single side, not the combined schema.
515+
bound_post = None
516+
if post_join_filter is not None:
517+
output_ns = stt.NamedStruct(
518+
names=out_names,
519+
struct=_join_output_struct(
520+
type_name,
521+
bound_left.relations[-1].root.input,
522+
bound_right.relations[-1].root.input,
523+
registry=registry,
524+
),
525+
)
526+
bound_post = resolve_expression(post_join_filter, output_ns, registry)
505527

506528
rel = stalg.Rel(
507529
join=stalg.JoinRel(
@@ -516,12 +538,6 @@ def resolve(registry: ExtensionRegistry) -> stp.Plan:
516538
)
517539
)
518540

519-
# The join condition resolves against the combined left+right schema, but
520-
# the output names must match the columns the join type actually emits
521-
# (semi/anti drop a side, mark appends a boolean).
522-
out_names = join_output_names(
523-
stalg.JoinRel.JoinType.Name(type), left_ns.names, right_ns.names
524-
)
525541
return stp.Plan(
526542
version=default_version,
527543
relations=[stp.PlanRel(root=stalg.RelRoot(input=rel, names=out_names))],
@@ -1023,6 +1039,9 @@ def builder(
10231039
left_keys: Iterable[Union[str, int]],
10241040
right_keys: Iterable[Union[str, int]],
10251041
type,
1042+
*,
1043+
post_join_filter: Optional[ExtendedExpressionOrUnbound] = None,
1044+
residual_expression: Optional[ExtendedExpressionOrUnbound] = None,
10261045
extension: Optional[AdvancedExtension] = None,
10271046
) -> UnboundPlan:
10281047
def resolve(registry: ExtensionRegistry) -> stp.Plan:
@@ -1033,24 +1052,65 @@ def resolve(registry: ExtensionRegistry) -> stp.Plan:
10331052
keys = _comparison_join_keys(
10341053
list(left_keys), list(right_keys), left_ns, right_ns, registry
10351054
)
1036-
names = join_output_names(
1037-
rel_cls.JoinType.Name(type), left_ns.names, right_ns.names
1038-
)
1055+
type_name = rel_cls.JoinType.Name(type)
1056+
names = join_output_names(type_name, left_ns.names, right_ns.names)
1057+
1058+
# post_join_filter is applied to each output record after
1059+
# join-type-specific output formation (semantically a FilterRel above
1060+
# the join), so it resolves against the output schema -- which for
1061+
# semi/anti joins is a single side. residual_expression is evaluated
1062+
# on each candidate key-match (both rows present), so it resolves
1063+
# against the combined left+right schema. Each is built only when the
1064+
# corresponding predicate is supplied.
1065+
bound_post = None
1066+
if post_join_filter is not None:
1067+
output_ns = stt.NamedStruct(
1068+
names=names,
1069+
struct=_join_output_struct(
1070+
type_name,
1071+
bound_left.relations[-1].root.input,
1072+
bound_right.relations[-1].root.input,
1073+
registry=registry,
1074+
),
1075+
)
1076+
bound_post = resolve_expression(post_join_filter, output_ns, registry)
1077+
1078+
bound_residual = None
1079+
if residual_expression is not None:
1080+
combined_ns = stt.NamedStruct(
1081+
struct=stt.Type.Struct(
1082+
types=list(left_ns.struct.types) + list(right_ns.struct.types),
1083+
nullability=stt.Type.Nullability.NULLABILITY_REQUIRED,
1084+
),
1085+
names=list(left_ns.names) + list(right_ns.names),
1086+
)
1087+
bound_residual = resolve_expression(
1088+
residual_expression, combined_ns, registry
1089+
)
1090+
10391091
rel = stalg.Rel(
10401092
**{
10411093
rel_name: rel_cls(
10421094
left=bound_left.relations[-1].root.input,
10431095
right=bound_right.relations[-1].root.input,
10441096
keys=keys,
10451097
type=type,
1098+
post_join_filter=bound_post.referred_expr[0].expression
1099+
if bound_post
1100+
else None,
1101+
residual_expression=bound_residual.referred_expr[0].expression
1102+
if bound_residual
1103+
else None,
10461104
advanced_extension=extension,
10471105
)
10481106
}
10491107
)
10501108
return stp.Plan(
10511109
version=default_version,
10521110
relations=[stp.PlanRel(root=stalg.RelRoot(input=rel, names=names))],
1053-
**_merge_plan_metadata(bound_left, bound_right),
1111+
**_merge_plan_metadata(
1112+
bound_left, bound_right, bound_post, bound_residual
1113+
),
10541114
)
10551115

10561116
return resolve

src/substrait/dataframe/frame.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,17 @@ def nested_loop_join(
406406
)
407407
)
408408

409-
def _equi_join(self, builder, rel_cls, other, left_on, right_on, how):
409+
def _equi_join(
410+
self,
411+
builder,
412+
rel_cls,
413+
other,
414+
left_on,
415+
right_on,
416+
how,
417+
post_filter,
418+
residual,
419+
):
410420
if how not in _JOIN_TYPES:
411421
raise ValueError(
412422
f"unknown join type {how!r}; expected one of {sorted(_JOIN_TYPES)}"
@@ -420,7 +430,19 @@ def _equi_join(self, builder, rel_cls, other, left_on, right_on, how):
420430
[right_on] if isinstance(right_on, (str, int)) else list(right_on)
421431
)
422432
return self._next(
423-
builder(self._plan, other._plan, left_keys, right_keys, join_type)
433+
builder(
434+
self._plan,
435+
other._plan,
436+
left_keys,
437+
right_keys,
438+
join_type,
439+
post_join_filter=(
440+
_unbound(post_filter) if post_filter is not None else None
441+
),
442+
residual_expression=(
443+
_unbound(residual) if residual is not None else None
444+
),
445+
)
424446
)
425447

426448
def hash_join(
@@ -429,14 +451,27 @@ def hash_join(
429451
left_on: Union[str, int, Iterable[Union[str, int]]],
430452
right_on: Union[str, int, Iterable[Union[str, int]], None] = None,
431453
how: str = "inner",
454+
*,
455+
post_filter: Union[Expr, Any, None] = None,
456+
residual: Union[Expr, Any, None] = None,
432457
) -> "DataFrame":
433458
"""Physical hash equi-join on key columns.
434459
435460
``left_on``/``right_on`` are column names/indices; ``right_on`` defaults
436461
to ``left_on``. ``how`` accepts the same values as :meth:`join`.
462+
``post_filter`` is an optional predicate applied to the join output;
463+
``residual`` is an optional non-equi condition evaluated alongside the
464+
key equalities. Both bind against the concatenated left+right schema.
437465
"""
438466
return self._equi_join(
439-
_plan.hash_join, stalg.HashJoinRel, other, left_on, right_on, how
467+
_plan.hash_join,
468+
stalg.HashJoinRel,
469+
other,
470+
left_on,
471+
right_on,
472+
how,
473+
post_filter,
474+
residual,
440475
)
441476

442477
def merge_join(
@@ -445,10 +480,23 @@ def merge_join(
445480
left_on: Union[str, int, Iterable[Union[str, int]]],
446481
right_on: Union[str, int, Iterable[Union[str, int]], None] = None,
447482
how: str = "inner",
483+
*,
484+
post_filter: Union[Expr, Any, None] = None,
485+
residual: Union[Expr, Any, None] = None,
448486
) -> "DataFrame":
449-
"""Physical sort-merge equi-join on key columns (inputs assumed sorted)."""
487+
"""Physical sort-merge equi-join on key columns (inputs assumed sorted).
488+
489+
``post_filter`` and ``residual`` behave as in :meth:`hash_join`.
490+
"""
450491
return self._equi_join(
451-
_plan.merge_join, stalg.MergeJoinRel, other, left_on, right_on, how
492+
_plan.merge_join,
493+
stalg.MergeJoinRel,
494+
other,
495+
left_on,
496+
right_on,
497+
how,
498+
post_filter,
499+
residual,
452500
)
453501

454502
def repartition(self, n: int = 0) -> "DataFrame":

tests/builders/plan/test_join.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import substrait.plan_pb2 as stp
44
import substrait.type_pb2 as stt
55

6-
from substrait.builders.extended_expression import literal
6+
from substrait.builders.extended_expression import column, literal
77
from substrait.builders.plan import default_version, join, read_named_table
88
from substrait.builders.type import boolean, i64, string
99
from substrait.extension_registry import ExtensionRegistry
@@ -69,3 +69,62 @@ def test_join():
6969
)
7070

7171
assert actual == expected
72+
73+
74+
def _post_field(plan):
75+
ref = plan.relations[-1].root.input.join.post_join_filter.selection
76+
return ref.direct_reference.struct_field.field
77+
78+
79+
def test_join_post_join_filter_binds_output_schema():
80+
# post_join_filter is applied to the join output (semantically a FilterRel
81+
# above the join), so it resolves against the output schema. For an inner join
82+
# the output is the combined schema [id, is_applicable, fk_id, flag], so a
83+
# filter on the right-side boolean `flag` binds to index 3; for a right-semi
84+
# join the output is the right side only [fk_id, flag], so it binds to index 1.
85+
left = read_named_table("l", named_struct)
86+
right = read_named_table(
87+
"r",
88+
stt.NamedStruct(
89+
names=["fk_id", "flag"],
90+
struct=stt.Type.Struct(
91+
types=[i64(nullable=False), boolean()],
92+
nullability=stt.Type.NULLABILITY_REQUIRED,
93+
),
94+
),
95+
)
96+
97+
inner = join(
98+
left,
99+
right,
100+
literal(True, boolean()),
101+
stalg.JoinRel.JOIN_TYPE_INNER,
102+
post_join_filter=column("flag"),
103+
)(registry)
104+
assert _post_field(inner) == 3
105+
106+
right_semi = join(
107+
left,
108+
right,
109+
literal(True, boolean()),
110+
stalg.JoinRel.JOIN_TYPE_RIGHT_SEMI,
111+
post_join_filter=column("flag"),
112+
)(registry)
113+
assert list(right_semi.relations[-1].root.names) == ["fk_id", "flag"]
114+
assert _post_field(right_semi) == 1
115+
116+
117+
def test_join_post_join_filter_on_dropped_side_raises():
118+
# A right-semi join drops the left side from its output, so a post_join_filter
119+
# on a left column cannot resolve -- it fails fast rather than emitting a
120+
# dangling field reference against the combined schema.
121+
left = read_named_table("l", named_struct)
122+
right = read_named_table("r", named_struct_2)
123+
with pytest.raises(ValueError, match="not in list"):
124+
join(
125+
left,
126+
right,
127+
literal(True, boolean()),
128+
stalg.JoinRel.JOIN_TYPE_RIGHT_SEMI,
129+
post_join_filter=column("id"), # left-only column, absent from output
130+
)(registry)

0 commit comments

Comments
 (0)