Skip to content

fix(isthmus)!: apply the projection a read relation carries - #1280

Merged
nielspardon merged 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1204-read-fields
Sep 30, 2026
Merged

nielspardon merged 2 commits into
substrait-io:mainfrom
alexandrefimov:issue-1204-read-fields

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

AbstractReadRel applies a read's projection when it derives the record type, so the relation produces the columns the mask keeps. The conversion built the scan from the initial schema and gave the Calcite node every column of it, mask or no mask.

An emit mapping selects by index, and so does every field reference a parent relation makes. Both index the columns the relation produces, so against that node they landed on the wrong columns, with no error. A VirtualTableScan carrying a projection was refused outright rather than converted against those columns.

NamedScan t(a i64, b string, c fp64), projection = mask[a, c], emit = [1]
  record type      Struct{[FP64]}          -- column c
  Calcite row type RecordType(VARCHAR b)   -- column b

The mask now becomes a projection above the scan and below the emit mapping, for a named table and a virtual one alike. Converting back gives that projection rather than the mask -- a Project over the whole schema, selecting the same columns in the same order -- so a round trip keeps the record type and not the encoding.

A mask that selects inside a column -- some of a struct's fields, some of a list's elements -- throws UnsupportedOperationException: applying it would mean rebuilding the column's value, which this conversion does not do.

The masked columns come out in the order the mask lists them, as substrait-io/substrait#1225 proposes: DuckDB writes SELECT c2, c0 FROM t_mix as one read masking [2, 0], and every consumer surveyed there that applies the mask reads the listed order. The text pulling the other way is the MaskExpression comment in algebra.proto (spec v0.102.0), which says a mask only removes elements; substrait-io/substrait#1247 changes it.

This addresses the projection part of #1204, which also names filter and best_effort_filter. #1260 proposes support for filter; best_effort_filter is still dropped.

BREAKING CHANGE: a NamedScan carrying a projection now converts to Calcite with the mask applied, where it used to convert with the mask dropped and give a tree whose columns were not the relation's. A VirtualTableScan carrying one converts as well, where it used to be refused. A NamedScan whose mask selects inside a column now throws UnsupportedOperationException, where that projection used to be dropped in silence along with the rest. To convert such a plan, have the mask select the whole column: isthmus refuses a nested field reference in a ProjectRel above the read as well, so neither place can prune inside a column.

Summary by CodeRabbit

  • New Features
    • Read projections are now applied to named and virtual table scans, with selected columns returned in the order specified by the projection.
    • Output names are preserved for projected read relations.
  • Bug Fixes
    • Virtual table scans with whole-column projections can now be converted. Projections that select fields within a column remain unsupported.

`AbstractReadRel` applies a read's projection when it derives the record type, so the relation produces the columns the mask keeps. The conversion built the scan from the initial schema and gave the Calcite node every column of it, mask or no mask.

An emit mapping selects by index, and so does every field reference a parent relation makes. Both index the columns the relation produces, so against that node they landed on the wrong columns, with no error. A `VirtualTableScan` carrying a projection was refused outright rather than converted against those columns.

```
NamedScan t(a i64, b string, c fp64), projection = mask[a, c], emit = [1]
  record type      Struct{[FP64]}          -- column c
  Calcite row type RecordType(VARCHAR b)   -- column b
```

The mask now becomes a projection above the scan and below the emit mapping, for a named table and a virtual one alike. Converting back gives that projection rather than the mask -- a `Project` over the whole schema, selecting the same columns in the same order -- so a round trip keeps the record type and not the encoding.

A mask that selects inside a column -- some of a struct's fields, some of a list's elements -- throws `UnsupportedOperationException`: applying it would mean rebuilding the column's value, which this conversion does not do.

The masked columns come out in the order the mask lists them. Spec v0.102.0 does not settle whether a mask may reorder at all: `expressions/field_references.md` describes a mask as removing columns and raises reordering as an open question. `MaskExpressionTypeProjector` already derives the record type in that order, though, and refusing a mask that reorders would reject plans the model accepts.

This addresses the projection part of substrait-io#1204, which also names `filter` and `best_effort_filter`. substrait-io#1260 applies `filter`; `best_effort_filter` is still dropped.

BREAKING CHANGE: a `NamedScan` carrying a projection now converts to Calcite with the mask applied, where it used to convert with the mask dropped and give a tree whose columns were not the relation's. A `VirtualTableScan` carrying one converts as well, where it used to be refused. A `NamedScan` whose mask selects inside a column now throws `UnsupportedOperationException`, where that projection used to be dropped in silence along with the rest.

@nielspardon nielspardon left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cite substrait-io/substrait#1225 in the description as the basis for applying the mask in the order it lists. Its survey (DuckDB writes SELECT c2, c0 FROM t_mix as one read masking [2, 0], and every consumer surveyed reads the listed order, with none rejecting it) is much stronger ground than MaskExpressionTypeProjector happening to derive the record type that way, and naming algebra.proto's MaskExpression comment as the text pulling the other way keeps the choice reviewable. Could you also send the spec change you offered in substrait-io/substrait#1225 alongside this, so the order no longer rests on consensus alone?

@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Done, and the spec change is substrait-io/substrait#1247.

@nielspardon nielspardon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add to the BREAKING CHANGE: footer what a consumer should do about the new throw for a mask that selects inside a column, since CONTRIBUTING asks the footer for both what breaks and what to do instead. A ProjectRel above the read isn't an escape hatch there, because isthmus refuses a nested field reference as well.

Comment thread isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java Outdated
Comment thread isthmus/src/test/java/io/substrait/isthmus/ReadProjectionTest.java Outdated
…s apply it

applyProjection is protected like the other apply methods, its Javadoc
says why maintainSingularStruct is not read, and applyOutputNames names
it among the projections hint names land on. The shared-name test now
masks the two columns that share a name.
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Done: the footer now says to select the whole column.

@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: ce96966e-f8cb-4e61-8720-4a1fa3076a7d

📥 Commits

Reviewing files that changed from the base of the PR and between fff6390 and 146f54f.

📒 Files selected for processing (3)
  • isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java
  • isthmus/src/test/java/io/substrait/isthmus/ReadProjectionTest.java
  • isthmus/src/test/java/io/substrait/isthmus/VirtualTableScanTest.java
💤 Files with no reviewable changes (1)
  • isthmus/src/test/java/io/substrait/isthmus/VirtualTableScanTest.java

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

Named and virtual table scans now apply optional read projections during conversion. The converter selects top-level columns in mask order and throws UnsupportedOperationException when a mask selects inside a column. Tests cover projected output and related mappings.

Changes

Read projection conversion

Layer / File(s) Summary
Apply read masks to scans
isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java
Named and virtual table conversion paths apply optional projections. The helper selects top-level columns in mask order, leaves scans unchanged when no projection exists, and rejects masks that select inside a column. Output-name documentation now includes read projections.
Validate projected scan output
isthmus/src/test/java/io/substrait/isthmus/ReadProjectionTest.java, isthmus/src/test/java/io/substrait/isthmus/VirtualTableScanTest.java
Tests cover column selection and ordering, emit mappings, parent field references, output-name hints, duplicate names, virtual-table rows, and nested-mask rejection. The prior test that expected virtual-table projections to fail is removed.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: nielspardon

Merge Risk: ⚪ Minimal · up to 146f5

The change aligns converted scan output with whole-column read masks and explicitly rejects unsupported nested selections. No concrete merge-blocking issue remains in the supplied evidence; merge after normal checks pass.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 146f5

The change corrects column selection and reduces downstream exposure to unselected columns. Nested selections now fail explicitly rather than producing an incorrectly shaped result. No introduced authorization bypass was established, but output projection does not guarantee that excluded columns are never accessed at the source.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated impact propagates through consumers of the converted read’s row type, including emit mappings and parent filters. The evidence does not establish tenant, credential, service, or environment exposure beyond that relational-plan scope.

Trust Boundaries and Controls

  • inferred — A read mask is a schema/output boundary, not a demonstrated authorization control. Whether excluded columns are physically accessed depends on later planning and source enforcement, neither of which is established by this conversion change.

Resilience and Maintainability Implications

  • observed — The static conversion entrypoint allocates a fresh builder and context per invocation, isolating mutable conversion state across calls through that API. This does not establish safe concurrent reuse or failure recovery for a manually shared converter instance.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, uses a valid Conventional Commit format, and clearly describes applying projections carried by read relations.
Description check ✅ Passed The description provides a detailed rationale, explains the implementation and limitations, documents affected behavior, and includes the required BREAKING CHANGE footer.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nielspardon nielspardon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants