feat!: id-based outer-reference resolution (Substrait v0.89.0) - #239
Merged
nielspardon merged 2 commits intoJul 29, 2026
Merged
Conversation
Resolve and emit correlated (outer) references by id (RelCommon.rel_anchor + OuterReference.rel_reference) in addition to the offset-based steps_out form, per Substrait v0.89.0. Completes the DAG / ReferenceRel scope of substrait-io#188. type_inference: dispatch OuterReference on its outer_reference_type oneof. steps_out resolves as before (against the correlated-subquery stack); rel_reference resolves against a plan-wide anchor index (_AnchorScope) built once in infer_plan_schema by walking every RelCommon.rel_anchor in the plan (subtrees, root, and subquery-embedded relations) and lazily inferring the anchored relation's output schema (memoized, cycle-guarded). This resolves correlated subqueries and, uniformly, an anchor set on a shared subtree reached via ReferenceRel. utils: add descriptor-driven expression/relation walkers (iter_plan_rels, _iter_rel_expressions, _iter_subquery_rels) and to_id_based_outer_references, a pure copy-and-rewrite pass that stamps a plan-wide-unique rel_anchor on each correlation's binding relation (its enclosing single-input host's input) and rewrites steps_out -> rel_reference. Dedupes by binding identity, is idempotent, and raises on a binding that cannot carry an anchor (ReferenceRel or a multi-input host). dataframe: to_plan/to_substrait emit id-based only, via a shared _finalize. The public outer(name, steps_out=N) API is unchanged -- steps_out stays the ergonomic level selector; only the emitted form changes. Builders stay agnostic: they still emit steps_out, and inference reads both. Closes substrait-io#188
Addresses code-review findings on the id-based outer-reference converter.
- Converter no longer raises (regressing to_plan()) on correlations whose
binding is a ReferenceRel (cache()d outer) or a multi-input host (join):
* a ReferenceRel binding is followed to the shared subtree it points at,
which is anchored (the reference has no emit, so its output is exactly
the subtree's);
* a multi-input host is anchored itself (a correlation there resolves
against the host's own output row);
* only a *reducing* join's condition scope (semi/anti) is genuinely
inexpressible id-based, so such a reference is left offset-based
(steps_out) -- still spec-valid and read by inference -- rather than
mis-anchored. A join's post_join_filter (output-scoped) is told apart
from its condition / residual_expression (combined-inputs-scoped) via a
new descriptor-driven _iter_named_direct_expressions.
- Anchor index keys on field presence (is not None), not truthiness, so a
legitimate rel_anchor of 0 is no longer silently dropped (inference and
converter, three sites).
- to_id_based_outer_references returns a non-correlated plan unchanged (no
copy) via a cheap _plan_has_steps_out pre-scan.
- Minor: nonlocal counter instead of a one-element list; return annotations
on DataFrame.to_plan / to_substrait.
Tests: replace the two converter tests asserting the old raises with tests
for the new behavior (ReferenceRel->subtree, join condition->join,
post_join_filter->join, reducing-join->kept steps_out, no-RelCommon raise);
add end-to-end DataFrame regression tests and a rel_anchor=0 inference test.
andrew-coleman
approved these changes
Jul 29, 2026
nielspardon
added a commit
to nielspardon/substrait-python
that referenced
this pull request
Jul 29, 2026
Substrait v0.96.0 (substrait-io#205) adds LateralJoinRel, whose right (dependent) input is evaluated once per left row and references the current left row via OuterReference.rel_reference pointing to the join's RelCommon.rel_anchor. Inference (type_inference.py): - infer_rel_schema handles the "lateral_join" rel type; output columns follow the same per-join-type shapes as a regular JoinRel (INNER/LEFT -> left+right, LEFT_SEMI/LEFT_ANTI -> left only, LEFT_MARK -> +mark). - id-based outer references resolve through the plan-wide _AnchorScope added in substrait-io#239. A lateral join's anchor denotes its *left* row, so _AnchorScope resolves it against the left input's schema. While the right input is inferred the left schema is bound to the anchor via _outer_anchor_binding (a scoped _AnchorScope chained to any enclosing one, so nested lateral joins compose); this also lets the builder infer the right input before the join relation is assembled. No separate outer_anchors map -- id-based resolution is unified on _AnchorScope. Builders (handle-based correlation): - builders.plan.lateral_join(left, right, type, ...): right is a function of a LateralInput handle to the left; handle.column(...) emits the id-based outer reference. The builder allocates a rel_anchor, infers the right input under _outer_anchor_binding, and assembles via _plan_from so shared subtrees propagate. - DataFrame.lateral_join(right, how, *, on, post_filter) + LateralLeft.col(): the DataFrame-layer handle. Capturing the handle avoids nesting-depth bookkeeping and makes referencing outside a lateral join impossible. - rel_anchor allocation: next_rel_anchor() is unique within a build; fresh_rel_anchors() resets numbering per materialization. DataFrame._finalize builds under fresh_rel_anchors, then normalizes any correlated-subquery steps_out to id-based via to_id_based_outer_references (substrait-io#239); its anchor pre-scan keeps DataFrame-correlation anchors clear of the lateral-join ones. Closes substrait-io#205.
nielspardon
added a commit
to nielspardon/substrait-python
that referenced
this pull request
Jul 29, 2026
Substrait v0.96.0 (substrait-io#205) adds LateralJoinRel, whose right (dependent) input is evaluated once per left row and references the current left row via OuterReference.rel_reference pointing to the join's RelCommon.rel_anchor. Inference (type_inference.py): - infer_rel_schema handles the "lateral_join" rel type; output columns follow the same per-join-type shapes as a regular JoinRel (INNER/LEFT -> left+right, LEFT_SEMI/LEFT_ANTI -> left only, LEFT_MARK -> +mark). - id-based outer references resolve through the plan-wide _AnchorScope added in substrait-io#239. A lateral join's anchor denotes its *left* row, so _AnchorScope resolves it against the left input's schema. While the right input is inferred the left schema is bound to the anchor via _outer_anchor_binding (a scoped _AnchorScope chained to any enclosing one, so nested lateral joins compose); this also lets the builder infer the right input before the join relation is assembled. No separate outer_anchors map -- id-based resolution is unified on _AnchorScope. Builders (handle-based correlation): - builders.plan.lateral_join(left, right, type, ...): right is a function of a LateralInput handle to the left; handle.column(...) emits the id-based outer reference. The builder allocates a rel_anchor, infers the right input under _outer_anchor_binding, and assembles via _plan_from so shared subtrees propagate. - DataFrame.lateral_join(right, how, *, on, post_filter) + LateralLeft.col(): the DataFrame-layer handle. Capturing the handle avoids nesting-depth bookkeeping and makes referencing outside a lateral join impossible. - rel_anchor allocation: next_rel_anchor() is unique within a build; fresh_rel_anchors() resets numbering per materialization. DataFrame._finalize builds under fresh_rel_anchors, then normalizes any correlated-subquery steps_out to id-based via to_id_based_outer_references (substrait-io#239); its anchor pre-scan keeps DataFrame-correlation anchors clear of the lateral-join ones. Closes substrait-io#205.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Substrait v0.89.0's id-based outer-reference resolution —
RelCommon.rel_anchor+OuterReference.rel_reference— alongside the existing offset-basedsteps_out, and moves the native DataFrame to emit the id-based form. This completes the DAG /ReferenceRelscope of #188 (the offset-basedsteps_outis ambiguous once a relation is shared).Mirrors the split substrait-java landed in #982: the builders stay agnostic (they still emit
steps_out, and inference reads both forms), while the higher-level DataFrame layer resolves natively by id.What changed
Consumer —
type_inference.pyresolves both forms.infer_expression_typedispatches anOuterReferenceon itsouter_reference_typeoneof:steps_outresolves as before (against the correlated-subquery stack);rel_referenceresolves against a plan-wide anchor index (_AnchorScope, mirroring the existing_SubtreeScope), built once ininfer_plan_schemaby walking everyRelCommon.rel_anchorin the plan — subtrees, root, and subquery-embedded relations — and lazily inferring the anchored relation's output schema (memoized, cycle-guarded). This resolves correlated subqueries and, uniformly, an anchor set on a shared subtree reached viaReferenceRel. The index keys on field presence, so a legitimaterel_anchorof0is a distinct anchor (not confused with "absent").Producer —
utils.to_id_based_outer_references(plan).A pure copy-and-rewrite pass that stamps a plan-wide-unique
rel_anchor(>= 1, per the spec) on the relation whose output row a correlation resolves against, and rewritessteps_out→rel_reference. Asteps_outreference names an enclosing query's row, which is the output of a specific relation, so the relation carrying that row is anchored:Filter/Project/ …) exposes its input's row. If that input is aReferenceRel(acache()-shared subtree, which has noRelCommon), the shared subtree it points at is anchored instead — the reference has noemit, so its output is exactly the subtree's. This is the DAG casesteps_outcannot address unambiguously.post_join_filter, or a leaf host's own filter, exposes the host's output row, so the host is anchored.residual_expressionexposes the combined left+right row; the join's own output equals that row for a non-reducing join, so the join is anchored. For a reducing join (semi/anti) the two differ and no relation carries that row — such a reference is left offset-based (steps_out, still spec-valid and read by inference) rather than mis-anchored.Dedupes by binding identity (references to the same scope share one anchor), reuses an anchor already present, pre-scans existing anchors, and is idempotent. A plan with nothing to rewrite is returned unchanged (no copy), via a cheap
_plan_has_steps_outpre-scan. Raises only if a resolvable binding carries noRelCommonat all (e.g. anUpdateRel), which no correlated-subquery shape produces. Backed by descriptor-driven walkers (_iter_rel_expressions,_iter_subquery_rels, and_iter_named_direct_expressions, which tags each of a join's expressions with its owning field so output-scopedpost_join_filteris told apart from the combined-inputs-scoped condition/residual) since nothing previously traversed expressions inside relations.DataFrame — emits id-based only.
to_plan/to_substraitrun the converter via a shared_finalize. The publicouter(name, steps_out=N)API is unchanged —steps_outstays the ergonomic level selector; only the emitted wire form changes.Breaking change
DataFrame-produced plans now encode correlated references as
rel_referencerather thansteps_out(except a reducing join's condition scope, which cannot be expressed id-based and stayssteps_out). The builders and inference are backward-compatible (both forms are read; builders still emitsteps_out).Relationship to #228
#228 (lateral-join inference) first built the id-based primitive scoped to
LateralJoinRel. This PR is the general mechanism. Once both land, #228 can drop its lateral-join-scopedouter_anchorscontextvar and reuse_AnchorScope(with one special case: a lateral-join anchor resolves to its left-input schema). The builders don't conflict — #228 emitsrel_referencedirectly, and this converter is idempotent on already-id-based references. Note #228 may emitrel_anchorvalues that the builder-side converter here never produces (e.g.0); the presence-based anchor index reads them correctly.Testing
tests/test_utils.py): anchor stamping/rewrite, same-scope dedup, distinct-scope anchors, pre-existing-anchor reuse, non-correlated no-op, idempotency;ReferenceRelbinding → shared-subtree anchor, multi-input join condition → join anchor,post_join_filter→ join anchor, reducing-join condition → keptsteps_out, and a no-RelCommon(UpdateRel) raise.tests/test_type_inference.py):rel_referenceresolving against an anchored shared subtree (the DAG case), a legitimaterel_anchor=0, unknown-anchor error, missing-plan-context error.tests/dataframe/test_frame.py): correlated-subquery tests assert the id-based form; end-to-end regression tests for a correlatedexistsover acache()d outer, in a joinpost_filter, and in a reducing-join condition (keptsteps_out).ruff check+ruff format --checkclean.Closes #188
🤖 Generated with AI