Skip to content

feat(core)!: support comparison_function_reference in SortField - #1342

Open
anasik wants to merge 1 commit into
substrait-io:mainfrom
anasik:core-sortfield-comparison-function
Open

anasik wants to merge 1 commit into
substrait-io:mainfrom
anasik:core-sortfield-comparison-function

Conversation

@anasik

@anasik anasik commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Models SortField.comparison_function_reference, which the POJO previously had no representation for, causing a plan using it to fail to read. SortField.direction() becomes Optional<SortDirection>, paired with a new comparisonFunction(), with a @Value.Check enforcing exactly one is set.

Both proto conversion directions are updated, along with the RANGE-window ordering validation added for #1198, which now also rejects a custom comparison function on a RANGE bound's ordering expression — closing the non-type-compatibility half of that issue (the type-compatibility half remains blocked on #1227). isthmus and Spark reject a comparison-function sort field outright: Calcite's and Spark's collation models have no representation for a custom comparator.

Also folds RelProtoConverter's independent SortField proto-conversion logic into ExpressionProtoConverter's, which it had been duplicating.

Closes #1330

BREAKING CHANGE: Expression.SortField.direction() now returns Optional<SortDirection> instead of SortDirection.

Summary by CodeRabbit

  • New Features

    • Sort fields can use either a sort direction or a custom comparison function, including in sort and window expressions.
    • String representations now display custom comparison functions for sort and Top-N operations.
  • Bug Fixes

    • Sort fields with both options—or neither—are rejected.
    • RANGE frames with PRECEDING or FOLLOWING bounds now reject ordering by a custom comparison function.
    • Conversions to Calcite and Spark report custom comparison functions as unsupported when a sort direction is unavailable.

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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 418d4c7e-8539-493c-a587-c4b9a9e76cc5

📥 Commits

Reviewing files that changed from the base of the PR and between a8aa30d and cd90db5.

📒 Files selected for processing (14)
  • core/src/main/java/io/substrait/expression/Expression.java
  • core/src/main/java/io/substrait/expression/WindowBound.java
  • core/src/main/java/io/substrait/expression/proto/ExpressionProtoConverter.java
  • core/src/main/java/io/substrait/expression/proto/ProtoExpressionConverter.java
  • core/src/main/java/io/substrait/relation/ProtoRelConverter.java
  • core/src/main/java/io/substrait/relation/RelProtoConverter.java
  • core/src/test/java/io/substrait/expression/SortFieldTest.java
  • core/src/test/java/io/substrait/type/proto/ConsistentPartitionWindowRelRoundtripTest.java
  • core/src/test/java/io/substrait/type/proto/SortRelRoundtripTest.java
  • examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java
  • isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java
  • isthmus/src/main/java/io/substrait/isthmus/expression/ExpressionRexConverter.java
  • isthmus/src/test/java/io/substrait/isthmus/SubstraitExpressionConverterTest.java
  • isthmus/src/test/java/io/substrait/isthmus/SubstraitRelNodeConverterTest.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

Sort fields can now use either a sort direction or a custom comparison function. Core protobuf conversion supports both forms. RANGE-frame validation rejects custom comparison ordering, and Spark and Calcite converters report custom comparison sort fields as unsupported.

Changes

Custom comparison sort fields

Layer / File(s) Summary
Sort-field model and RANGE validation
core/src/main/java/io/substrait/expression/Expression.java, core/src/main/java/io/substrait/expression/WindowBound.java, core/src/test/java/io/substrait/expression/SortFieldTest.java, core/src/test/java/io/substrait/type/proto/ConsistentPartitionWindowRelRoundtripTest.java
SortField now requires exactly one of a direction or a comparison function. RANGE-frame validation rejects ordering that uses a custom comparison function. Tests cover invalid sort fields and RANGE-frame rejection.
Protobuf conversion and round-trip coverage
core/src/main/java/io/substrait/expression/proto/*, core/src/main/java/io/substrait/relation/*, core/src/test/java/io/substrait/type/proto/SortRelRoundtripTest.java, core/src/test/java/io/substrait/type/proto/ConsistentPartitionWindowRelRoundtripTest.java
Expression and relation converters read and write comparison-function references. Tests cover sort and window round trips.
Consumer handling and formatting
examples/substrait-spark/src/main/java/io/substrait/examples/util/SubstraitStringify.java, isthmus/src/main/java/io/substrait/isthmus/*, isthmus/src/test/java/io/substrait/isthmus/*, spark/src/main/scala/io/substrait/spark/logical/ToLogicalPlan.scala
Stringification displays a comparison function when no direction is present. Calcite and Spark conversion reject custom comparison sort fields. Tests cover Calcite rejection.

Priority: ➖ Normal

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

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant SortField as Expression.SortField
  participant Encoder as ExpressionProtoConverter
  participant Proto as Protobuf SortField
  participant Decoder as ProtoExpressionConverter
  participant Lookup as lookup
  SortField->>Encoder: Convert with toProto
  Encoder->>Proto: Write expression and comparison-function reference
  Proto->>Decoder: Convert with fromSortField
  Decoder->>Lookup: Resolve referenced scalar function
  Lookup-->>Decoder: Return scalar function variant
Loading

Suggested reviewers: nielspardon

Merge Risk: ⚪ Minimal · up to cd90d

The custom-comparison sort-field changes are mergeable after normal checks; no actionable issue was established.

Security Architecture Review

Security architecture risk: 🔵 Low · up to cd90d

Custom comparisons now round-trip through the core model, while the inspected Spark and Calcite adapters reject comparisons they cannot represent. The public API change still warrants review, and downstream integration coverage is incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The relevant exposure is to callers supplying sort fields in plans and to consumers of the widened core contract. Tenant, credential, network, and deployment exposure cannot be determined from the available source.

Trust Boundaries and Controls

  • observed — A supplied comparison-function reference passes through extension lookup; the model enforces mutually exclusive sort forms, and inspected adapters reject the unsupported form before constructing ordering.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.21% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 14 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 a concise Conventional Commit title. It clearly identifies the breaking core change: support for comparison_function_reference in SortField.
Description check ✅ Passed The description explains the rationale, API changes, conversion updates, validation behavior, unsupported integrations, linked issue context, and the BREAKING CHANGE footer. It satisfies the available…
Linked Issues check ✅ Passed The PR satisfies the coding requirements in [#1330]. Expression.SortField models the sort oneof with optional direction() and comparisonFunction(), and construction rejects both-set and neither-…
Out of Scope Changes check ✅ Passed The changes remain within [#1330]. The RANGE validation implements the follow-on rule identified in the issue. The isthmus and Spark changes reject custom comparison functions that those consumers can…
  • 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.

@anasik
anasik force-pushed the core-sortfield-comparison-function branch from a8aa30d to cd90db5 Compare September 29, 2026 16:58

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

Looks good overall. Two small things: Spark aggregate measures still accept a comparison-function sort without complaint, and fromSortField should reject an unset sort_kind itself instead of going through the direction branch.

private def toSortOrder(sortField: SExpression.SortField): SortOrder = {
val expression = sortField.expr().accept(expressionConverter, EmptyVisitationContext.INSTANCE)
val (direction, nullOrdering) = sortField.direction() match {
if (!sortField.direction().isPresent) {

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 this same guard to fromMeasure, just before val filter (line 113). fromMeasure never reads function.sort(), so a measure ordered by a custom comparator now converts as an unordered aggregate. Before this PR the plan failed to read, so this turns a loud failure into a silent one, and it doesn't match the description's "Spark rejects a comparison-function sort field outright". Dropping direction sorts in general is #1094.

    if (function.sort().asScala.exists(!_.direction().isPresent)) {
      throw new UnsupportedOperationException(
        "A sort field using a custom comparison function is not supported")
    }

Comment on lines +748 to 758
if (s.getSortKindCase() == SortField.SortKindCase.COMPARISON_FUNCTION_REFERENCE) {
return Expression.SortField.builder()
.expr(from(s.getExpr()))
.comparisonFunction(
lookup.getScalarFunction(s.getComparisonFunctionReference(), extensions))
.build();
}
return Expression.SortField.builder()
.direction(Expression.SortDirection.fromProto(s.getDirection()))
.expr(from(s.getExpr()))
.direction(Expression.SortDirection.fromProto(s.getDirection()))
.build();

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.

Switch on the oneof case and reject SORTKIND_NOT_SET explicitly. Right now an unset sort kind still fails with Unknown type: SORT_DIRECTION_UNSPECIFIED, and fixing that is the other half of #1093's second item.

Suggested change
if (s.getSortKindCase() == SortField.SortKindCase.COMPARISON_FUNCTION_REFERENCE) {
return Expression.SortField.builder()
.expr(from(s.getExpr()))
.comparisonFunction(
lookup.getScalarFunction(s.getComparisonFunctionReference(), extensions))
.build();
}
return Expression.SortField.builder()
.direction(Expression.SortDirection.fromProto(s.getDirection()))
.expr(from(s.getExpr()))
.direction(Expression.SortDirection.fromProto(s.getDirection()))
.build();
Expression expr = from(s.getExpr());
switch (s.getSortKindCase()) {
case DIRECTION:
return Expression.SortField.builder()
.expr(expr)
.direction(Expression.SortDirection.fromProto(s.getDirection()))
.build();
case COMPARISON_FUNCTION_REFERENCE:
return Expression.SortField.builder()
.expr(expr)
.comparisonFunction(
lookup.getScalarFunction(s.getComparisonFunctionReference(), extensions))
.build();
default:
throw new IllegalArgumentException("SortField has no sort_kind set");
}

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.

core: SortField cannot represent comparison_function_reference, so plans using it fail to read

2 participants