Skip to content

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

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

fix!: replace assert-based validation with real exceptions#1058
nielspardon wants to merge 1 commit 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.

Three concrete costs today, which is what a consumer sees:

  • ExpressionProtoConverter.toLiteral() — a protobuf oneof getter returns the default
    instance
    when its case is not set, so a non-literal where a literal is required is silently
    converted to an empty literal. We emit a wrong plan instead of failing.
  • 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 becomes an NPE inside
    Calcite rather than the clear "Table not found in Calcite catalog" message the same file
    already produces 150 lines earlier.

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. An assert that can fail is worse than useless; an assert
    that genuinely cannot fail costs nothing to write as an explicit throw.

:core

Site Now throws
VirtualTableScan.check() — name count vs depth-first named-field count, no null names/rows, row shape, rows not nullable, row field types match schema IllegalArgumentException
Expression.NestedList.check() — non-empty, all values same type IllegalArgumentException
ExpressionProtoConverter.toLiteral() 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 null-element checks now run before the
row.nullable() loop — a null row previously NPE'd there before reaching its own assert.

:isthmus

Site Now throws
SubstraitRelVisitor ×2 (INSERT/DELETE, UPDATE) — modify.getTable() IllegalArgumentException
SubstraitRelNodeConverter ×2 — relBuilder.getRelOptSchema() IllegalStateException
SubstraitRelNodeConvertercatalogReader, asserted on a value just assigned from a cast dropped as dead
SubstraitRelNodeConvertertargetTable IllegalStateException, reusing the existing message
SqlMapValueConstructorCallConverter — operand count even IllegalArgumentException
CallConverters.CASE — operand count odd IllegalArgumentException
FunctionConverter.matchKeys() — private, internal IllegalStateException
CreateTable.copy(), CreateView.copy()inputs.size() == 1 IllegalArgumentException

Two small refactors fall out of this: a requireTable(TableModify) helper in
SubstraitRelVisitor (which also collapses the repeated modify.getTable() calls) and a
requireRelOptSchema() helper in SubstraitRelNodeConverter shared by both schema checks. 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.

The three 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.
  • Row/schema types still compare with exact Type.equals, so nullability must match
    precisely. 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

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

1 participant