Skip to content

fix(isthmus)!: keep a struct's declared field nullability in Calcite - #1317

Merged
nielspardon merged 3 commits into
substrait-io:mainfrom
alexandrefimov:issue-1154-nested-field-nullability
Sep 29, 2026
Merged

nielspardon merged 3 commits into
substrait-io:mainfrom
alexandrefimov:issue-1154-nested-field-nullability

Conversation

@alexandrefimov

@alexandrefimov alexandrefimov commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

A Substrait struct declares its own nullability and its fields' separately. TypeConverter applied the struct's with createTypeWithNullability, which Calcite documents as widening a record's fields along with the record, so struct?<i32, fp64> came back as struct?<i32?, fp64?>.

enforceTypeWithNullability canonizes the result the same way and leaves each field as declared. A computed field inside a nullable struct now converts back instead of reporting that the row type cannot say what the schema said.

SchemaCollector now gives a table a NOT NULL row type, as a virtual table already has; otherwise a LEFT join did not widen its columns.

A hint's output names now reach a column that holds a struct: describesColumnsOf compares the declared and converted types ignoring names and nullability at every depth.

Summary by CodeRabbit

  • Bug Fixes
    • Preserved the declared nullability of record fields during type conversion, including required fields within nullable records.
    • Corrected schema handling so required columns remain non-nullable, while columns on the nullable side of a left join are nullable.
    • Improved output-name matching for struct columns, including nested and computed values.

Closes #1154
Closes #1214

BREAKING CHANGE: Substrait-to-Calcite conversion keeps a struct field's declared nullability, so a required field inside a nullable struct stays NOT NULL. SchemaCollector gives a table a NOT NULL row type.

A Substrait struct declares its own nullability and its fields' separately.
TypeConverter applied the struct's with createTypeWithNullability, which
Calcite documents as widening a record's fields along with the record, so
struct?<i32, fp64> came back as struct?<i32?, fp64?>.

Apply it with enforceTypeWithNullability, which the factory canonizes the
same way but which leaves each field as declared. A computed field inside
a nullable struct now converts back instead of reporting that the row type
cannot say what the schema said.

Closes substrait-io#1154
@coderabbitai

coderabbitai Bot commented Sep 23, 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: de6e08e5-8116-4688-bb5c-781d191652f0

📥 Commits

Reviewing files that changed from the base of the PR and between 6b4df48 and 6db5951.

📒 Files selected for processing (8)
  • isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java
  • isthmus/src/main/java/io/substrait/isthmus/SubstraitRelVisitor.java
  • isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java
  • isthmus/src/main/java/io/substrait/isthmus/expression/LiteralConverter.java
  • isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java
  • isthmus/src/test/java/io/substrait/isthmus/NestedExpressionsTest.java
  • isthmus/src/test/java/io/substrait/isthmus/OutputNamesTest.java
  • 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; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change preserves declared field nullability when converting nullable Substrait structs to Calcite. It also separates row nullability from field nullability and updates output-name matching for nested types.

Changes

Struct conversion and output names

Layer / File(s) Summary
Apply struct nullability without widening fields
isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java, isthmus/src/main/java/io/substrait/isthmus/expression/CallConverters.java, isthmus/src/main/java/io/substrait/isthmus/expression/LiteralConverter.java, isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java, isthmus/src/test/java/io/substrait/isthmus/NestedExpressionsTest.java
ToRelDataType.n uses enforceTypeWithNullability to apply struct nullability without widening field nullability. Tests cover nullable structs with independently nullable or required fields and round trips.
Separate row and field nullability
isthmus/src/main/java/io/substrait/isthmus/SchemaCollector.java, isthmus/src/main/java/io/substrait/isthmus/SubstraitRelVisitor.java, isthmus/src/test/java/io/substrait/isthmus/SchemaCollectorTest.java, isthmus/src/test/java/io/substrait/isthmus/VirtualTableScanTest.java, isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java
SchemaCollector.toSchema makes the outer row type non-nullable while retaining field nullability. Tests cover required fields, nullability on the right side of a LEFT join, and virtual-table schema round trips.
Match output names across nested types
isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java, isthmus/src/test/java/io/substrait/isthmus/OutputNamesTest.java, isthmus/src/test/java/io/substrait/isthmus/VirtualTableScanTest.java
Output-name matching compares struct, map, and array types recursively while ignoring nullability and nested struct field names. Tests cover computed structs and emit-mapped struct values.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: nielspardon

Merge Risk: ⚪ Minimal · up to 6db59

No actionable merge-blocking issue is established for the struct-nullability and output-name changes; the PR is ready for normal merge checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 6db59

The conversion behavior is a breaking contract change, but the inspected paths retain structural checks before applying output names and show no new privileged action. Effects on downstream consumers remain uncertain.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated change to output-name handling reaches Calcite projection metadata; whether downstream consumers use those names for security decisions is not established.

Trust Boundaries and Controls

  • observed — A relation supplies the output-name hint. The converter declines to apply it for incompatible column shapes, invalid name cardinality, empty or duplicate top-level names, or a non-projection result.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 28 functions across 11 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 The PR meets #1154 and #1214. For #1154, TypeConverter uses enforceTypeWithNullability, which preserves field nullability inside nullable structs. CalciteTypeTest, VirtualTableScanTest, and re…
Out of Scope Changes check ✅ Passed The changes stay within the linked issue scope. The SchemaCollector change keeps the relation row type non-null while preserving declared field nullability. This supports the struct-nullability beha…
Title check ✅ Passed The title is concise, specific, and uses a valid Conventional Commit format. It accurately states the main change: preserving declared struct field nullability in Calcite.
Description check ✅ Passed The description explains the rationale, implementation changes, affected behavior, linked issues, and breaking changes. It satisfies the repository template requirements.
  • 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.

Normalize the collected row type's outermost struct to NOT NULL in SchemaCollector (isthmus/src/main/java/io/substrait/isthmus/SchemaCollector.java:73-75), the way the virtual-table path already does. Without it a nullable schema struct now produces a Calcite row type that is a nullable record with NOT NULL columns, and since createTypeWithNullability returns a type unchanged when the nullability already matches, Calcite's own outer-join widening becomes a no-op — a LEFT join's null-generating side keeps columns that Join.getRecordType() calls nullable. proto/substrait/type.proto says a relation schema's outermost struct should be NULLABILITY_REQUIRED, so this is also what the schema was supposed to be.

        // A relation's row type says what its columns are, not whether a value is there, and
        // Calcite builds one NOT NULL wherever it derives one -- so a schema struct contributes
        // its fields and not its own nullability.
        RelDataType rowType =
            typeFactory.createTypeWithNullability(
                typeConverter.toCalcite(typeFactory, namedStruct.struct(), namedStruct.names()),
                false);

Comment thread isthmus/src/test/java/io/substrait/isthmus/SchemaCollectorTest.java Outdated
A nullable schema struct became a nullable Calcite record with NOT NULL
columns. createTypeWithNullability returns such a type unchanged when
asked for nullable, so outer-join widening kept the null-generating
side's columns NOT NULL. Normalize the outermost struct as the
virtual-table path already does; type.proto asks for a required
outermost struct anyway.

@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.

Make describesColumnsOf compare a struct column field by field, ignoring nullability (isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java:1289-1293) — its two-arg equalSansNullability compares the whole type string, so now that a nullable struct's fields keep NOT NULL, a Project whose struct column comes from an if_then silently drops its hint's output names ([renamed] on main, [s0] here). The same change fixes #1214, so landing it there instead works too.

      RelDataType declared = typeConverter.toCalcite(typeFactory, fields.get(field));
      RelDataType actual = rowType.getFieldList().get(field).getType();
      // A struct column is compared field by field, ignoring each field's nullability and name:
      // only the top-level names are restated here, and the declared type is converted without a
      // name list, so the names inside it are placeholders.
      boolean describes =
          declared.isStruct() && actual.isStruct()
              ? SqlTypeUtil.equalAsStructSansNullability(typeFactory, declared, actual, null)
              : SqlTypeUtil.equalSansNullability(declared, actual);
      if (!describes) {
        return false;
      }

Comment thread isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java
Comment thread isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java
…ility

describesColumnsOf compared a struct column's whole type, nested names
and field nullability included. The declared type converts without a name
list, so a column holding a struct never matched and the hint's names were
dropped. Now that a nullable struct keeps its fields' nullability, a
struct computed by an if_then lost its names the same way. Types are now
compared ignoring names and nullability at every depth, through structs,
lists and maps.

Also restates the comments that described the removed widening, adds full
round trips to the nullable struct virtual-table tests, and pins a nested
struct in CalciteTypeTest.
@alexandrefimov

Copy link
Copy Markdown
Contributor Author

Fixed here in 6db5951, so this now closes #1214 too. equalAsStructSansNullability ignores names and nullability only on the outermost struct, so a struct inside a struct still lost the names. The new check is recursive and also covers lists and maps of structs.

@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

2 participants