Skip to content

feat(core): enforce Fetch offset/count expressions are integer-typed - #1074

Open
benbellick wants to merge 3 commits into
mainfrom
benbellick/feat/enforce-fetchrel-int
Open

feat(core): enforce Fetch offset/count expressions are integer-typed#1074
benbellick wants to merge 3 commits into
mainfrom
benbellick/feat/enforce-fetchrel-int

Conversation

@benbellick

Copy link
Copy Markdown
Member

Adds enforcement that FetchRel's offset_expr/count_expr resolve to an integer type (I8/I16/I32/I64), rejecting non-integer expressions at construction. The spec only recommends i64 for these fields:

https://github.com/substrait-io/substrait/blob/v0.99.0/proto/substrait/algebra.proto#L345-L366


Note: This PR was developed with AI assistance. All changes have been reviewed, and I take full responsibility for this contribution.

@benbellick
benbellick marked this pull request as ready for review August 6, 2026 21:18
@nielspardon

nielspardon commented Aug 7, 2026

Copy link
Copy Markdown
Member

trying to trigger CI through close / reopen. there was a long GH actions outage yesterday and the checks were not triggered

@nielspardon nielspardon closed this Aug 7, 2026
@nielspardon nielspardon reopened this Aug 7, 2026

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

Should unbound be exempt? Type.Unbound isn't an integer, so a DynamicParameter offset — or a field reference into an unbound column — now throws at construction. And since ProtoRelConverter.from() builds the POJO, such a plan fails to import, not merely to build locally. Type.Unbound's own Javadoc says a partially bound plan "may be serialized and exchanged, but it must be bound to concrete types before execution", which is exactly what an import-time type check can't honour. This is also the first rel-level validation that inspects an expression's type, so it sets the precedent for later ones. My inline suggestion exempts it, with the one-line revert noted there.

Three smaller ones:

  • TopN has the same two fields and the same spec wording, and is reachable via ProtoRelConverter.newTopN — validating one and not the other half-enforces the invariant. The same check with the message prefixed TopN compiles and keeps the suite green.
  • FetchRoundtripTest only ever uses i64. One case would pin the widened acceptance through proto conversion: offset(sb.i8(3)).count(sb.i32(7)).
  • from() now throws on plans it previously accepted, and the check isn't overridable, so consumers get no leniency seam. Worth deciding deliberately whether that carries a literal BREAKING CHANGE: footer — a heading alone produces no release-notes text.

Not for this PR: the spec's "evaluating to a negative integer should result in an error" is statically checkable for literals.

Suggestions below are all compiled against your head commit.

Comment on lines +32 to +56
@Value.Check
protected void check() {
getOffset()
.ifPresent(
offset -> {
Type type = offset.getType();
if (!type.isInteger()) {
throw new IllegalArgumentException(
"Fetch offset expression must have an integer type "
+ "(I8, I16, I32 or I64), but got: "
+ type);
}
});
getCount()
.ifPresent(
count -> {
Type type = count.getType();
if (!type.isInteger()) {
throw new IllegalArgumentException(
"Fetch count expression must have an integer type "
+ "(I8, I16, I32 or I64), but got: "
+ type);
}
});
}

@nielspardon nielspardon Aug 7, 2026

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.

Two things bundled here, since they touch the same lines.

For discussion: exempting unbound (see summary). If you'd rather not, drop && !(type instanceof Type.Unbound) and the second Javadoc paragraph — everything else stands on its own.

The rest: Javadoc, which -Xdoclint:all -Xwerror requires on a protected member; collapsing the duplicated branches; and rendering the type through StringTypeVisitor, so the message reads decimal<10,2> instead of leaking Immutables toString (today it really does say but got: Unbound{}).

Needs one import outside this hunk: import io.substrait.type.StringTypeVisitor;

Suggested change
@Value.Check
protected void check() {
getOffset()
.ifPresent(
offset -> {
Type type = offset.getType();
if (!type.isInteger()) {
throw new IllegalArgumentException(
"Fetch offset expression must have an integer type "
+ "(I8, I16, I32 or I64), but got: "
+ type);
}
});
getCount()
.ifPresent(
count -> {
Type type = count.getType();
if (!type.isInteger()) {
throw new IllegalArgumentException(
"Fetch count expression must have an integer type "
+ "(I8, I16, I32 or I64), but got: "
+ type);
}
});
}
/**
* Validates that the offset and count expressions are integer-typed. Both must evaluate to a
* non-negative integer; {@code i64} is recommended but not required, so any integer width is
* accepted (spec v0.99.0).
*
* <p>The {@link Type.Unbound unbound} type is also accepted, so that a partially bound plan can
* still be serialized and exchanged. The integer constraint can only be enforced once the type
* has been bound.
*
* @throws IllegalArgumentException if the offset or count expression is neither integer-typed nor
* unbound
*/
@Value.Check
protected void check() {
requireIntegerType(getOffset(), "offset");
requireIntegerType(getCount(), "count");
}
private static void requireIntegerType(Optional<Expression> expression, String field) {
expression.ifPresent(
e -> {
Type type = e.getType();
if (!type.isInteger() && !(type instanceof Type.Unbound)) {
throw new IllegalArgumentException(
"Fetch "
+ field
+ " expression must have an integer type (i8, i16, i32 or i64), but got: "
+ type.accept(new StringTypeVisitor()));
}
});
}

Comment on lines +56 to +66
/**
* Returns whether this is one of the integer types {@link I8}, {@link I16}, {@link I32} or {@link
* I64}. The Substrait spec only "recommends" {@code i64} for contexts such as {@code FetchRel}'s
* {@code offset_expr}/{@code count_expr}, so callers that accept any integer width can use this
* to reject non-integer types.
*
* @return {@code true} if this is an integer type
*/
default boolean isInteger() {
return this instanceof I8 || this instanceof I16 || this instanceof I32 || this instanceof I64;
}

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.

The predicate itself is good. I would decouple the Javadoc from its caller, though: this is a general predicate on the public Type interface, and documenting FetchRel's recommendation here inverts the dependency - the type model should not need to know who is asking. The spec rationale belongs in Fetch.check(), where I have put it.

Suggested change
/**
* Returns whether this is one of the integer types {@link I8}, {@link I16}, {@link I32} or {@link
* I64}. The Substrait spec only "recommends" {@code i64} for contexts such as {@code FetchRel}'s
* {@code offset_expr}/{@code count_expr}, so callers that accept any integer width can use this
* to reject non-integer types.
*
* @return {@code true} if this is an integer type
*/
default boolean isInteger() {
return this instanceof I8 || this instanceof I16 || this instanceof I32 || this instanceof I64;
}
/**
* Returns whether this is one of the fixed-width signed integer types: {@link I8}, {@link I16},
* {@link I32} or {@link I64}.
*
* @return {@code true} if this is an integer type
*/
default boolean isInteger() {
return this instanceof I8 || this instanceof I16 || this instanceof I32 || this instanceof I64;
}

Comment on lines +1 to +64
package io.substrait.relation;

import static org.junit.jupiter.api.Assertions.assertThrows;

import io.substrait.TestBase;
import java.util.Arrays;
import org.junit.jupiter.api.Test;

/**
* Validation tests for {@link Fetch}, whose offset/count expressions must have an integer type.
* Round-trip coverage lives in the {@code io.substrait.type.proto} package.
*/
class FetchTest extends TestBase {

// Reuse the same schema shape as FetchRoundtripTest so the two files stay in sync.
final Rel table =
sb.namedScan(Arrays.asList("T"), Arrays.asList("a", "b"), Arrays.asList(R.I64, R.STRING));

/** Every integer width is an acceptable offset/count expression type. */
@Test
void integerWidthsAccepted() {
fetch().offset(sb.i8(1)).count(sb.i8(2)).build();
fetch().offset(sb.i16(1)).count(sb.i16(2)).build();
fetch().offset(sb.i32(1)).count(sb.i32(2)).build();
fetch().offset(sb.i64(1)).count(sb.i64(2)).build();
}

/** A non-integer offset expression is rejected at construction time. */
@Test
void nonIntegerOffsetRejected() {
assertThrows(IllegalArgumentException.class, () -> fetch().offset(sb.fp64(1.0)).build());
}

/** A non-integer count expression is rejected at construction time. */
@Test
void nonIntegerCountRejected() {
assertThrows(IllegalArgumentException.class, () -> fetch().count(sb.fp64(1.0)).build());
}

/** A non-integer type reaches the check via the proto conversion path too. */
@Test
void nonIntegerOffsetRejectedViaProto() {
// Build a FetchRel proto directly with a non-integer offset_expr so the Fetch POJO check is
// exercised by ProtoRelConverter rather than by direct construction.
io.substrait.proto.Rel inputProto = relProtoConverter.toProto(table);
io.substrait.proto.Expression fp64Expr =
io.substrait.proto.Expression.newBuilder()
.setLiteral(io.substrait.proto.Expression.Literal.newBuilder().setFp64(1.0).build())
.build();
io.substrait.proto.FetchRel fetchRel =
io.substrait.proto.FetchRel.newBuilder()
.setCommon(io.substrait.proto.RelCommon.newBuilder().build())
.setInput(inputProto)
.setOffsetExpr(fp64Expr)
.build();
io.substrait.proto.Rel protoRel =
io.substrait.proto.Rel.newBuilder().setFetch(fetchRel).build();
assertThrows(IllegalArgumentException.class, () -> protoRelConverter.from(protoRel));
}

private ImmutableFetch.Builder fetch() {
return Fetch.builder().input(table);
}
}

@nielspardon nielspardon Aug 7, 2026

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.

Whole-file suggestion, since the pieces share imports. What changes:

  • Mixed widths - integerWidthsAccepted only paired each width with itself, so a helper comparing the two types to each other rather than checking each independently would pass every current test and fail this one.
  • Nullable integers - the bigger gap: the spec assigns meaning to null (offset null -> 0, count null -> ALL), and nothing pins that through the check today.
  • Non-literal expressions - SubstraitRelVisitor deliberately passes arbitrary Calcite RexNodes through as offset/count, so a field reference and a cast belong here.
  • unbound - encodes whatever we settle in the summary; delete if we go the other way.
  • assertDoesNotThrow + message assertions, so a failure names the offending combination and nonIntegerCountRejected cannot pass on the offset branch firing.

FetchRel and RelCommon are importable; io.substrait.proto.Expression and io.substrait.proto.Rel have to stay qualified - they collide with the POJO types.

Suggested change
package io.substrait.relation;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.substrait.TestBase;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
/**
* Validation tests for {@link Fetch}, whose offset/count expressions must have an integer type.
* Round-trip coverage lives in the {@code io.substrait.type.proto} package.
*/
class FetchTest extends TestBase {
// Reuse the same schema shape as FetchRoundtripTest so the two files stay in sync.
final Rel table =
sb.namedScan(Arrays.asList("T"), Arrays.asList("a", "b"), Arrays.asList(R.I64, R.STRING));
/** Every integer width is an acceptable offset/count expression type. */
@Test
void integerWidthsAccepted() {
fetch().offset(sb.i8(1)).count(sb.i8(2)).build();
fetch().offset(sb.i16(1)).count(sb.i16(2)).build();
fetch().offset(sb.i32(1)).count(sb.i32(2)).build();
fetch().offset(sb.i64(1)).count(sb.i64(2)).build();
}
/** A non-integer offset expression is rejected at construction time. */
@Test
void nonIntegerOffsetRejected() {
assertThrows(IllegalArgumentException.class, () -> fetch().offset(sb.fp64(1.0)).build());
}
/** A non-integer count expression is rejected at construction time. */
@Test
void nonIntegerCountRejected() {
assertThrows(IllegalArgumentException.class, () -> fetch().count(sb.fp64(1.0)).build());
}
/** A non-integer type reaches the check via the proto conversion path too. */
@Test
void nonIntegerOffsetRejectedViaProto() {
// Build a FetchRel proto directly with a non-integer offset_expr so the Fetch POJO check is
// exercised by ProtoRelConverter rather than by direct construction.
io.substrait.proto.Rel inputProto = relProtoConverter.toProto(table);
io.substrait.proto.Expression fp64Expr =
io.substrait.proto.Expression.newBuilder()
.setLiteral(io.substrait.proto.Expression.Literal.newBuilder().setFp64(1.0).build())
.build();
io.substrait.proto.FetchRel fetchRel =
io.substrait.proto.FetchRel.newBuilder()
.setCommon(io.substrait.proto.RelCommon.newBuilder().build())
.setInput(inputProto)
.setOffsetExpr(fp64Expr)
.build();
io.substrait.proto.Rel protoRel =
io.substrait.proto.Rel.newBuilder().setFetch(fetchRel).build();
assertThrows(IllegalArgumentException.class, () -> protoRelConverter.from(protoRel));
}
private ImmutableFetch.Builder fetch() {
return Fetch.builder().input(table);
}
}
package io.substrait.relation;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.substrait.TestBase;
import io.substrait.expression.Expression;
import io.substrait.expression.ExpressionCreator;
import io.substrait.proto.FetchRel;
import io.substrait.proto.RelCommon;
import io.substrait.type.StringTypeVisitor;
import io.substrait.type.Type;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
/**
* Validation tests for {@link Fetch}, whose offset/count expressions must have an integer type.
* Round-trip coverage lives in the {@code io.substrait.type.proto} package.
*/
class FetchTest extends TestBase {
final Rel table =
sb.namedScan(Arrays.asList("T"), Arrays.asList("a", "b"), Arrays.asList(R.I64, R.STRING));
/** Every integer width is acceptable, in every offset/count combination. */
@Test
void integerWidthsAccepted() {
List<Expression> widths = Arrays.asList(sb.i8(1), sb.i16(1), sb.i32(1), sb.i64(1));
for (Expression offset : widths) {
for (Expression count : widths) {
assertDoesNotThrow(
() -> fetch().offset(offset).count(count).build(),
() -> "offset " + render(offset) + " / count " + render(count));
}
}
}
/**
* A nullable integer is accepted: the spec assigns meaning to a null offset (treated as 0) and a
* null count (all remaining rows).
*/
@Test
void nullableIntegerAccepted() {
Expression nullOffset = ExpressionCreator.typedNull(N.I64);
Expression nullCount = ExpressionCreator.typedNull(N.I32);
assertDoesNotThrow(() -> fetch().offset(nullOffset).count(nullCount).build());
}
/** The check inspects the expression's type, so non-literal integer expressions are accepted. */
@Test
void nonLiteralIntegerExpressionsAccepted() {
// Column "a" is i64. Isthmus passes arbitrary Calcite RexNodes through as offset/count, so the
// check has to hold for more than literals.
assertDoesNotThrow(() -> fetch().count(sb.fieldReference(table, 0)).build());
Expression cast =
ExpressionCreator.cast(R.I64, sb.i32(1), Expression.FailureBehavior.THROW_EXCEPTION);
assertDoesNotThrow(() -> fetch().count(cast).build());
}
/**
* An unbound offset/count is accepted: a partially bound plan may be serialized and exchanged
* before its types are known, so the integer constraint cannot be enforced yet.
*/
@Test
void unboundAccepted() {
Expression unboundParameter =
Expression.DynamicParameter.builder()
.type(Type.Unbound.builder().build())
.parameterReference(0)
.build();
assertDoesNotThrow(() -> fetch().offset(unboundParameter).count(unboundParameter).build());
}
/** A non-integer offset expression is rejected at construction time. */
@Test
void nonIntegerOffsetRejected() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> fetch().offset(sb.fp64(1.0)).build());
assertTrue(e.getMessage().contains("offset"), e.getMessage());
assertTrue(e.getMessage().contains("fp64"), e.getMessage());
}
/** A non-integer count expression is rejected at construction time. */
@Test
void nonIntegerCountRejected() {
IllegalArgumentException e =
assertThrows(IllegalArgumentException.class, () -> fetch().count(sb.fp64(1.0)).build());
assertTrue(e.getMessage().contains("count"), e.getMessage());
}
/** A non-integer type reaches the check via the proto conversion path too. */
@Test
void nonIntegerOffsetRejectedViaProto() {
// Build a FetchRel proto directly with a non-integer offset_expr so the Fetch POJO check is
// exercised by ProtoRelConverter rather than by direct construction.
io.substrait.proto.Expression fp64Expr =
io.substrait.proto.Expression.newBuilder()
.setLiteral(io.substrait.proto.Expression.Literal.newBuilder().setFp64(1.0))
.build();
io.substrait.proto.Rel protoRel =
io.substrait.proto.Rel.newBuilder()
.setFetch(
FetchRel.newBuilder()
.setCommon(RelCommon.newBuilder())
.setInput(relProtoConverter.toProto(table))
.setOffsetExpr(fp64Expr))
.build();
assertThrows(IllegalArgumentException.class, () -> protoRelConverter.from(protoRel));
}
private ImmutableFetch.Builder fetch() {
return Fetch.builder().input(table);
}
private static String render(Expression expression) {
return expression.getType().accept(new StringTypeVisitor());
}
}

Comment on lines +44 to +46
assertFalse(R.list(R.I64).isInteger());
assertFalse(R.map(R.I64, R.I64).isInteger());
}

@nielspardon nielspardon Aug 7, 2026

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.

Worth pinning the two types where "not an integer" is a decision rather than an obvious fact - unbound especially, since it is what the summary's unbound question turns on.

(Separately: the two equalsIgnoringNullability tests below are unrelated to this PR. They are net-new coverage for a previously untested method so I am not going to argue for removing them, just noting they would read better as their own commit.)

Suggested change
assertFalse(R.list(R.I64).isInteger());
assertFalse(R.map(R.I64, R.I64).isInteger());
}
assertFalse(R.list(R.I64).isInteger());
assertFalse(R.map(R.I64, R.I64).isInteger());
}
@Test
void unboundAndUserDefinedAreNotIntegers() {
assertFalse(Type.Unbound.builder().build().isInteger());
assertFalse(R.userDefined("urn:test", "t").isInteger());
}

…ypes readably

Adopt review feedback: fold the duplicated offset/count branches into a
requireIntegerType(Optional, field) helper that still produces per-field
errors, render the offending type via StringTypeVisitor (decimal<10,2>
rather than Immutables toString), and add the doclint-required Javadoc on
check(). Decouple Type.isInteger()'s Javadoc from its caller so the type
model no longer references FetchRel; the spec rationale lives in Fetch.
…ringNullability tests

Pin UserDefined as a non-integer Type (the one non-obvious case now that
Unbound is out of scope). Drop the equalsIgnoringNullability tests, which
were net-new coverage for a previously untested method unrelated to this PR.
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