fix!: replace assert-based validation with real exceptions - #1058
Open
nielspardon wants to merge 1 commit into
Open
fix!: replace assert-based validation with real exceptions#1058nielspardon wants to merge 1 commit into
nielspardon wants to merge 1 commit into
Conversation
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
force-pushed
the
fix/replace-assert-validation
branch
from
August 3, 2026 11:36
ffc0d0e to
cb11d79
Compare
nielspardon
marked this pull request as ready for review
August 3, 2026 11:44
nielspardon
requested review from
andrew-coleman,
benbellick,
bestbeforetoday,
bvolpato and
vbarua
August 3, 2026 11:44
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
assertcompiles behind$assertionsDisabled, so the invariant checks in:coreand:isthmusfired in Gradle's test JVMs (which enable-eaby default) and nowhere else. Theinvariants 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,
AssertionErroris anError,not an
Exception, so a host that wraps plan conversion incatch (Exception e)to report abad 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 defaultinstance 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.NestedList—getType()readsvalues().get(0), so an empty nested list throwsIndexOutOfBoundsExceptionfrom an unrelated place instead of the assert's"use
ExpressionCreator.emptyList()" hint.SubstraitRelNodeConvertertargetTable— a missing catalog entry becomes an NPE insideCalcite rather than the clear
"Table not found in Calcite catalog"message the same filealready produces 150 lines earlier.
The rule applied
RelNode) →IllegalArgumentException→
IllegalStateExceptionassertleft, anywhere. An assert that can fail is worse than useless; an assertthat genuinely cannot fail costs nothing to write as an explicit throw.
:coreVirtualTableScan.check()— name count vs depth-first named-field count, no null names/rows, row shape, rows not nullable, row field types match schemaIllegalArgumentExceptionExpression.NestedList.check()— non-empty, all values same typeIllegalArgumentExceptionExpressionProtoConverter.toLiteral()IllegalArgumentExceptionVariadicParameterConsistencyValidator(already threwAssertionErrorunconditionally)IllegalArgumentExceptionVirtualTableScan.check()'s compound assert is split into one check per invariant so themessage 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.:isthmusSubstraitRelVisitor×2 (INSERT/DELETE, UPDATE) —modify.getTable()IllegalArgumentExceptionSubstraitRelNodeConverter×2 —relBuilder.getRelOptSchema()IllegalStateExceptionSubstraitRelNodeConverter—catalogReader, asserted on a value just assigned from a castSubstraitRelNodeConverter—targetTableIllegalStateException, reusing the existing messageSqlMapValueConstructorCallConverter— operand count evenIllegalArgumentExceptionCallConverters.CASE— operand count oddIllegalArgumentExceptionFunctionConverter.matchKeys()— private, internalIllegalStateExceptionCreateTable.copy(),CreateView.copy()—inputs.size() == 1IllegalArgumentExceptionTwo small refactors fall out of this: a
requireTable(TableModify)helper inSubstraitRelVisitor(which also collapses the repeatedmodify.getTable()calls) and arequireRelOptSchema()helper inSubstraitRelNodeConvertershared by both schema checks. ThetargetTablenull check deliberately stays after theswitch— the CTAS branch returnsearlier and legitimately has no pre-existing table, so hoisting it to the lookup would break
handleCreateTableAs.The three stale
@throws AssertionErrorJavadoc tags are updated.Guarding against regressions
A custom PMD rule
AvoidAssertStatement(//AssertStatement) insubstrait-pmd.xmlfails thebuild on any new
assert, and its violation message states theIllegalArgumentException/IllegalStateExceptionrule above at the offending line. PMD scans test source sets too, so theone assert in isthmus test code (
RepeatRel.copy()) is converted as well.Two calls worth a second opinion
Plan.Root.check()is the precedent — hardIllegalArgumentExceptionforthe invariant,
LOGGER.warnonly for its one legacy allowance.Type.equals, so nullability must matchprecisely. 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.
:sparkneeded no changes: its Scala sources userequire(...), not the Javaassertkeyword.BREAKING CHANGE: validation that previously used
assertnow throws unconditionally. Callerscatching
AssertionErrormust catchIllegalArgumentExceptionorIllegalStateExceptioninstead, and code running without
-ea— i.e. most deployments — will now see these checksfire:
VirtualTableScanandExpression.NestedListreject malformed input both from theirbuilders and from
ProtoRelConverter/ProtoExpressionConverter, andVariadicParameterConsistencyValidatorthrowsIllegalArgumentExceptionrather thanAssertionError.Closes #1047
🤖 Generated with AI