Skip to content

fix!: replace assert-based validation with real exceptions - #1058

Open
nielspardon wants to merge 3 commits into
substrait-io:mainfrom
nielspardon:fix/replace-assert-validation
Open

fix!: replace assert-based validation with real exceptions#1058
nielspardon wants to merge 3 commits into
substrait-io:mainfrom
nielspardon:fix/replace-assert-validation

Conversation

@nielspardon

@nielspardon nielspardon commented Aug 3, 2026

Copy link
Copy Markdown
Member

assert compiles behind $assertionsDisabled, so the invariant checks in :core and :isthmus fired in Gradle's test JVMs (which enable -ea by default) and nowhere else. The invariants were documented by the code and enforced in CI, but not in the place where a malformed plan actually causes damage. And when they do run, AssertionError is an Error, not an Exception, so a host that wraps plan conversion in catch (Exception e) to report a bad plan does not catch it — it propagates as a hard failure.

Two of these checks cost a consumer something today:

  • Expression.NestedListgetType() reads values().get(0), so an empty nested list throws IndexOutOfBoundsException from an unrelated place instead of the assert's "use ExpressionCreator.emptyList()" hint.
  • SubstraitRelNodeConverter targetTable — a missing catalog entry passes null into LogicalTableModify.create, which NPEs inside Calcite, rather than producing the clear "Table not found in Calcite catalog" message the same file already produces 150 lines earlier.

The rest are invariants that today's call sites cannot violate. They stay as explicit guards — an assert that genuinely cannot fail costs nothing to write as a throw — but they are not fixes for anything.

The rule applied

  • caller-facing invariant (builder input, a proto message, a Calcite RelNode) → IllegalArgumentException
  • internal invariant / configuration ("cannot happen unless the converter is misconfigured") → IllegalStateException
  • no bare assert left, anywhere.

:core

Site Now throws
VirtualTableScan.check() — name count vs depth-first named-field count, row shape, rows not nullable, row field types match schema IllegalArgumentException
Expression.NestedList.check() — non-empty, all values the same type IllegalArgumentException
ExpressionProtoConverter.toLiteral() — guard; all call sites are statically Expression.Literal IllegalArgumentException
VariadicParameterConsistencyValidator (already threw AssertionError unconditionally) IllegalArgumentException

VirtualTableScan.check()'s compound assert is split into one check per invariant so the message names the mismatched counts, and the schema's field types are now derived once per relation instead of once per row.

NestedList's homogeneity check compares element types modulo nullability. The assert it replaces compared types exactly, which under -ea rejected SELECT ARRAY[not_null_col, nullable_col]: SQL list constructors do not cast their values to a common type, so such a list legitimately holds values that differ only in nullability. Enforcing the old comparison in production would have broken queries that convert today.

:isthmus

Site Now throws
SubstraitRelNodeConverter ×2 — relBuilder.getRelOptSchema() IllegalStateException
SubstraitRelNodeConvertertargetTable IllegalStateException, reusing the existing message
SubstraitRelNodeConvertercatalogReader, asserted on a value just assigned from a cast dropped as dead
SqlMapValueConstructorCallConverter — operand count even IllegalArgumentException
CallConverters.CASE — operand count odd IllegalArgumentException
CreateTable.copy(), CreateView.copy()inputs.size() == 1 IllegalArgumentException
SubstraitRelVisitor ×2 (INSERT/DELETE, UPDATE) — guard; Calcite's TableModify constructor already dereferences the table IllegalArgumentException
FunctionConverter.matchKeys() — guard; both lists come from the same operand stream IllegalStateException

Two small refactors fall out of this: a requireTable(TableModify) helper in SubstraitRelVisitor (which also collapses the repeated modify.getTable() calls) and requireRelOptSchema() / requireCatalogReader() helpers in SubstraitRelNodeConverter, shared by the write and update paths and protected so subclasses overriding those visit methods can reuse them. The targetTable null check deliberately stays after the switch — the CTAS branch returns earlier and legitimately has no pre-existing table, so hoisting it to the lookup would break handleCreateTableAs.

SqlMapValueConstructorCallConverter also grows two fixes the operand-count check exposed: it ran after the cast that would already have thrown ClassCastException, and the map it builds is now insertion-ordered so a map literal keeps the key order written in the query.

The stale @throws AssertionError Javadoc tags are updated.

Guarding against regressions

A custom PMD rule AvoidAssertStatement (//AssertStatement) in substrait-pmd.xml fails the build on any new assert, and its violation message states the IllegalArgumentException / IllegalStateException rule above at the offending line. PMD scans test source sets too, so the one assert in isthmus test code (RepeatRel.copy()) is converted as well.

Two calls worth a second opinion

  • No grace period. Plan.Root.check() is the precedent — hard IllegalArgumentException for the invariant, LOGGER.warn only for its one legacy allowance.
  • VirtualTableScan row/schema types still compare with exact Type.equals, so nullability must match precisely there. That is the strictest reading of the spec and the most likely thing to reject another producer's plan, but relaxing it would be a semantic change rather than part of this one.

:spark needed no changes: its Scala sources use require(...), not the Java assert keyword.

BREAKING CHANGE: validation that previously used assert now throws unconditionally. Callers catching AssertionError must catch IllegalArgumentException or IllegalStateException instead, and code running without -ea — i.e. most deployments — will now see these checks fire: VirtualTableScan and Expression.NestedList reject malformed input both from their builders and from ProtoRelConverter / ProtoExpressionConverter, and VariadicParameterConsistencyValidator throws IllegalArgumentException rather than AssertionError.

Closes #1047

🤖 Generated with AI

@nielspardon
nielspardon force-pushed the fix/replace-assert-validation branch from ffc0d0e to cb11d79 Compare August 3, 2026 11:36
@nielspardon
nielspardon marked this pull request as ready for review August 3, 2026 11:44
Comment thread isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java Outdated

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

One blocker: the NestedList check now rejects SELECT ARRAY[nullable_col, notnull_col], which converts on main. Comments inline.

Three of the new checks are unreachable (VirtualTableScan null elements, toLiteral, requireTable) — the PR description cites them as motivating bugs.

Untested on the isthmus side: every new throw except requireCatalogReader.

Comment thread core/src/main/java/io/substrait/expression/Expression.java Outdated
Comment on lines 715 to +717
RelNode input = write.getInput().accept(this, context);
assert relBuilder.getRelOptSchema() != null;
final RelOptTable targetTable =
relBuilder.getRelOptSchema().getTableForMember(write.getNames());
final RelOptSchema relOptSchema = requireRelOptSchema();
final RelOptTable targetTable = relOptSchema.getTableForMember(write.getNames());

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.

Pre-existing, not this PR — flagging for a separate issue. Line 715 converts the input, then case CTAS re-converts it in handleCreateTableAs. With a rel_anchor on the input that throws UnsupportedOperationException: Duplicate rel_anchor=1. Base behaves identically.

Comment thread core/src/main/java/io/substrait/relation/VirtualTableScan.java Outdated
Comment thread core/src/main/java/io/substrait/relation/VirtualTableScan.java Outdated
Comment thread isthmus/src/main/java/io/substrait/isthmus/calcite/rel/CreateView.java Outdated
Comment thread isthmus/src/main/java/io/substrait/isthmus/SubstraitRelNodeConverter.java Outdated
<rule ref="category/java/design.xml/AvoidThrowingRawExceptionTypes" />
<rule ref="category/java/errorprone.xml/MissingSerialVersionUID" />

<rule name="AvoidAssertStatement" language="java"

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.

Please add AvoidAssertStatement to the rule list in AGENTS.md line 126-128.

}

/** A {@link RelOptTable} that delegates everything but the schema it claims to belong to. */
private static final class DelegatingRelOptTable implements RelOptTable {

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.

RelOptTableImpl.create(RelOptSchema, RelDataType, List<String>, Expression) returns the schema you pass from getRelOptSchema() — replaces this class. Also extend()/toRel() currently forward to the undecorated delegate.

Java assertions only run when the host JVM is started with -ea, which is
true for Gradle's test JVMs and for almost nothing else. The assert-based
invariant checks in :core and :isthmus were therefore enforced in CI and
silently skipped in every real deployment. What that costs today: a
non-literal where a literal is required is converted to an empty literal
(a protobuf oneof getter returns the default instance when its case is
not set), an empty NestedList throws IndexOutOfBoundsException from
getType() instead of pointing at ExpressionCreator.emptyList(), and a
missing Calcite catalog entry becomes an NPE inside Calcite rather than
the "Table not found in Calcite catalog" message the same file already
produces.

Caller-facing invariants -- builder input, proto messages, Calcite
RelNodes -- now throw IllegalArgumentException; internal and
configuration invariants throw IllegalStateException. One check is
dropped rather than converted: the null check on a value just assigned
from a cast in SubstraitRelNodeConverter.

A custom PMD rule (AvoidAssertStatement) keeps new asserts out of both
main and test sources, which is why the one assert in isthmus test code
is converted too.

BREAKING CHANGE: validation that previously used `assert` now throws
unconditionally. Callers catching AssertionError must catch
IllegalArgumentException or IllegalStateException instead, and code
running without -ea -- i.e. most deployments -- will now see these
checks fire: VirtualTableScan and Expression.NestedList reject malformed
input both from their builders and from ProtoRelConverter /
ProtoExpressionConverter, and VariadicParameterConsistencyValidator
throws IllegalArgumentException rather than AssertionError.

Closes substrait-io#1047
…ions

`LogicalTableModify` needs a `Prepare.CatalogReader`, and neither site that
builds one was checking that it had a usable value:

- The UPDATE path casts `RelOptTable.getRelOptSchema()`, which Calcite
  declares `@Nullable`. `TableModify` stores the reader unchecked and only
  dereferences it later (`getExpectedInputRowType()` for UPDATE), so a null
  survives conversion and fails as an NPE somewhere else entirely. Dropping
  the `catalogReader != null` assert here was wrong: the cast succeeds for
  null, and this is a different schema from the one `requireRelOptSchema()`
  validates.
- Both paths cast a `RelOptSchema` to `Prepare.CatalogReader` without
  checking the type, so a schema that is not a catalog reader fails as a
  `ClassCastException` rather than a reportable error.

`requireCatalogReader` now narrows the schema at both sites. It uses
`instanceof`, so one check covers the null and the wrong-type case, and the
message names the actual class. The schema sources are unchanged: UPDATE
still reads it from the resolved table, the write path from the RelBuilder.
- NestedList's homogeneity check compares element types modulo
  nullability. The assert it replaced compared them exactly, which
  rejected `SELECT ARRAY[not_null_col, nullable_col]` under -ea; SQL
  list constructors do not cast their values to a common type, so
  enforcing that in production would break queries that convert today.
- Drop the two null-element checks in VirtualTableScan: the generated
  Immutables builder already requireNonNulls every element, so they were
  unreachable. Hoist the schema field types out of the per-row loop.
- Check the map operand count before the cast that would otherwise throw
  ClassCastException first, and build the map literal insertion-ordered
  so it keeps the key order written in the query.
- Make requireRelOptSchema/requireCatalogReader protected so subclasses
  overriding the write and update visits can reuse them.
- Drop the @throws tag for the TableModify guard, which today's call
  sites cannot reach, and add the missing copy() @throws tags on the DDL
  relations.
- Cover the reachable new throws: DDL copy input counts, CASE and map
  operand counts, the missing RelOptSchema and unknown-table paths, and
  a list literal of mixed nullability.
- Document AvoidAssertStatement in the CONTRIBUTING PMD tripwire list.
@nielspardon
nielspardon force-pushed the fix/replace-assert-validation branch from 45780f2 to 38201af Compare August 11, 2026 11:27
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.

Replace assert-based validation with real exceptions in :core and :isthmus

3 participants