Skip to content

fix(isthmus)!: translate array indexes correctly - #1261

Open
bvolpato wants to merge 4 commits into
substrait-io:mainfrom
bvolpato:bvolpato/fix-array-item-index
Open

bvolpato wants to merge 4 commits into
substrait-io:mainfrom
bvolpato:bvolpato/fix-array-item-index

Conversation

@bvolpato

@bvolpato bvolpato commented Sep 3, 2026 •

Copy link
Copy Markdown
Member

Calcite's safe array operators use zero- or one-based indexes, while Substrait list references use zero-based offsets and interpret negative offsets from the end. Copying the literal index changes the selected value: for a = [10, 20, 30], a[1] becomes the second element and a[-1] becomes the last instead of null.

SELECT a[1] FROM (VALUES (ARRAY[10, 20, 30])) AS t(a);

Normalize indexes using each operator's declared base and check wide integer indexes before narrowing to the protobuf offset. For null or below-base indexes, retain the list reference with offset 2,147,483,647. The Substrait list-length limit makes that offset out of range, so the result is null while the array operand is still evaluated.

Derive list selections as nullable even when their elements are required, and propagate nullable parents through subsequent struct and map selections. This preserves nullable result types through protobuf conversion and avoids folding null accesses into malformed collection literals or losing computed-operand evaluation.

Downsides

Substrait list references cannot preserve the throwing behavior of unsafe OFFSET/ORDINAL operators or represent offsets outside the signed 32-bit range. These conversions fall through to unsupported-function handling. Converting ordinary list selections back to Calcite remains unsupported.

Summary by CodeRabbit

  • New Features
    • Added support for converting safe array-access expressions, including SAFE_OFFSET and SAFE_ORDINAL, into Substrait field selections. Valid indexes are converted to zero-based offsets, including indexes represented by larger numeric values.
  • Bug Fixes
    • List element selections are now nullable to account for indexes that may be out of range.
    • Field and map-value selections now reflect nullability inherited from their containing struct or map.

BREAKING CHANGE: Unsafe array access operators and unrepresentable indexes now fail conversion. List-selection result types are nullable, and subsequent struct or map selections inherit nullable parents. Callers must handle unsupported operators or indexes and use the resulting nullable types instead of assuming required elements imply a required selection result.

@bvolpato
bvolpato force-pushed the bvolpato/fix-array-item-index branch from 8659726 to 125c546 Compare September 4, 2026 16:21
Normalize safe array indexes by their operator base. Preserve null and
out-of-range low indexes without introducing negative list offsets, and
check wide indexes before narrowing.

BREAKING CHANGE: Unsafe array operators and unrepresentable offsets now
reach unsupported-function handling instead of changing semantics.
Folding an invalid or null index to NULL can remove a throwing cast or another computed array operand. Restrict this fold to literals and references into an existing record; reject other operands when their evaluation cannot be preserved.

Cover throwing casts, nested array selections, computed operands, and safe literal and column references for ITEM, SAFE_OFFSET, and SAFE_ORDINAL.
@bvolpato
bvolpato force-pushed the bvolpato/fix-array-item-index branch from 125c546 to bafd921 Compare September 29, 2026 05:19
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

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

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 47 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: substrait-io/substrait-java/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3a99ac97-1ddd-4294-ad04-e747075822ac
📥 Commits

Reviewing files that changed from the base of the PR and between 3424c92 and a3924d8.

📒 Files selected for processing (7)
  • core/src/main/java/io/substrait/expression/FieldReference.java
  • core/src/test/java/io/substrait/expression/FieldReferenceResolveTypeTest.java
  • core/src/test/java/io/substrait/relation/RelCopyOnWriteVisitorTest.java
  • core/src/test/java/io/substrait/type/proto/FieldReferenceRoundtripTest.java
  • isthmus/src/main/java/io/substrait/isthmus/expression/FieldSelectionConverter.java
  • isthmus/src/test/java/io/substrait/isthmus/NestedStructQueryTest.java
  • isthmus/src/test/java/io/substrait/isthmus/expression/FieldSelectionConverterTest.java

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f9ed6bd8-f2d1-4133-8162-13734e1102b6
📥 Commits

Reviewing files that changed from the base of the PR and between 4d21734 and 3424c92.

📒 Files selected for processing (5)
  • core/src/main/java/io/substrait/expression/FieldReference.java
  • core/src/test/java/io/substrait/type/proto/FieldReferenceRoundtripTest.java
  • isthmus/src/main/java/io/substrait/isthmus/expression/FieldSelectionConverter.java
  • isthmus/src/test/java/io/substrait/isthmus/NestedStructQueryTest.java
  • isthmus/src/test/java/io/substrait/isthmus/expression/FieldSelectionConverterTest.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

Field references now reflect container nullability, and list selections return nullable element types. Calcite array selection conversion now handles supported ITEM, SAFE_OFFSET, and SAFE_ORDINAL operators with their index bases.

Changes

Array Selection and Field Nullability

Layer / File(s) Summary
Field-reference nullability
core/src/main/java/io/substrait/expression/FieldReference.java, core/src/test/java/io/substrait/type/proto/FieldReferenceRoundtripTest.java
Struct and map selections account for container nullability. List selections return nullable element types. Round-trip tests cover nested and direct list selections.
Calcite array-index conversion
isthmus/src/main/java/io/substrait/isthmus/expression/FieldSelectionConverter.java, isthmus/src/test/java/io/substrait/isthmus/expression/FieldSelectionConverterTest.java, isthmus/src/test/java/io/substrait/isthmus/NestedStructQueryTest.java
Array conversion handles safe ITEM operators with offsets 0 or 1, adjusts supported indexes, and rejects unsupported operators. Tests cover indexing bases, invalid indexes, nested selections, and round trips.

Priority: ➖ Normal

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

Change: Bug fix

Suggested reviewers: nielspardon

Merge Risk: ⚪ Minimal · up to 3424c

This change fixes array index translation for safe Calcite operators and makes field-reference nullability more accurate. No concrete merge-blocking problem was identified. The nullability change may affect downstream code that assumed non-null element types.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 3424c

The inspected changes preserve operand expressions and reject unsupported array operators and oversized offsets. However, invalid-index handling depends on a list-size guarantee that remains unverified across consumers. No security vulnerability or expanded privilege boundary was established.

Retained concerns

  • Low · architecture · inferred: Null and below-base indexes are encoded as ordinary list offsets at Integer.MAX_VALUE. Their null-result guarantee depends on an unverified maximum-list-length contract. If a consumer permits an element at that offset, the translation could return data instead of null. This is an unresolved portability assumption, not a verified security finding.
Security review details

Security Blast Radius

  • inferred — The demonstrated propagation is through translated query expressions, shared field-reference typing and protobuf consumers. The supplied evidence does not establish deployment-level tenant, service or data-store exposure.

Trust Boundaries and Controls

  • observed — Literal-only indexing, safe-operator checks, supported-base checks and wide-offset validation constrain the array-reference translation. These checks limit semantic misinterpretation; they are not tenant authorization controls.

Resilience and Maintainability Implications

  • observed — The throwing-operand test asserts that the original Calcite expression throws and that translation retains its operand tree. Additional tests check nullable types and protobuf round trips. These assertions support structural error preservation, but do not prove downstream execution behavior.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.13% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, specific, and uses Conventional Commit format. It summarizes the array-index conversion fix and marks it as breaking.
Description check ✅ Passed The description explains the rationale, behavior changes, limitations, and breaking change. It provides the required context for the squash-merge commit message.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@bvolpato
bvolpato marked this pull request as ready for review September 29, 2026 05:27

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

Thanks, the offset translation itself looks right. Before a line-by-line pass I'd like to settle one design question on the NULL-folding path (inline).

Use a guaranteed out-of-range Substrait list offset for null and below-base indexes, retaining computed operand evaluation and collection composition.

Infer nullable list-selection results consistently through nested references and protobuf conversion.

BREAKING CHANGE: List selections now report nullable element types, and selections through nullable struct or map parents propagate that nullability.
Merge current main and reuse the field-type finders in nonthrowing reference resolution, preserving bounds and map-key validation.

Cover nullable parents and nested list selections, and update the retained reference expectation to its nullable type.
@bvolpato

bvolpato commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

I went with the reference approach so the array operand is still evaluated. The spec’s list-length limit makes the chosen positive offset out of range. Nullable result types now survive nested selections and protobuf conversion, including the collection, ROW, and UDT cases you raised.

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

Thanks, the out-of-range offset works: the spec caps lists at 2,147,483,647 elements, so no element can be at that offset. Please move the :core nullability change into its own fix(core)! PR for this one to stack on. It changes derived types for every core user, only this PR's title reaches the changelog, and the spec doesn't say how selection nullability should work, so that PR should say so.

}
return expr.fields().get(index);
Type field = expr.fields().get(index);
return field.withNullable(field.nullable() || expr.nullable());

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.

Only widen when the parent is nullable, and do the same for map values at line 651. Type.Unbound.nullable() throws, so selecting an unbound field now crashes, e.g. in an identity RelCopyOnWriteVisitor pass over a scan with an unbound column.

Suggested change
return field.withNullable(field.nullable() || expr.nullable());
return expr.nullable() ? field.withNullable(true) : field;

public Type visit(Type.ListType expr) throws RuntimeException {
return expr.elementType();
// An out-of-range offset returns null even when the list's elements are required.
return expr.elementType().withNullable(true);

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.

Decide how VirtualTableScan's exact row-type check (VirtualTableScan.java:95) should handle this. A virtual-table row holding list_element{0} over list<i32> against a REQUIRED i32 schema column now fails ProtoRelConverter.from. substrait-go builds plans of that shape.

return Optional.empty();
}
long offset = Integer.MAX_VALUE;
if (!(literal instanceof Expression.NullLiteral)) {

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.

Check ((RexLiteral) reference).isNull() before line 76 converts the literal. An untyped a[NULL] throws Unable to convert the type NULL there, so only a[CAST(NULL AS INTEGER)] reaches this branch.

Comment on lines +114 to 120
if (index.get() >= operator.offset) {
offset = index.get() - operator.offset;
}
}
if (offset > Integer.MAX_VALUE) {
return Optional.empty();
}

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.

Clamp instead of rejecting: any offset of 2,147,483,647 or more selects nothing, so a[3000000000] can use the same out-of-range offset. unrepresentableOffsetsAreRejected and the "Downsides" sentence then need to change too.

Suggested change
if (index.get() >= operator.offset) {
offset = index.get() - operator.offset;
}
}
if (offset > Integer.MAX_VALUE) {
return Optional.empty();
}
if (index.get() >= operator.offset) {
offset = Math.min(index.get() - operator.offset, Integer.MAX_VALUE);
}
}

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