Skip to content

[CALCITE-7736] Replace the Checker Framework with NullAway and JSpecify - #5213

Draft
vlsi wants to merge 43 commits into
apache:mainfrom
vlsi:CALCITE-7736
Draft

[CALCITE-7736] Replace the Checker Framework with NullAway and JSpecify#5213
vlsi wants to merge 43 commits into
apache:mainfrom
vlsi:CALCITE-7736

Conversation

@vlsi

@vlsi vlsi commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Preview, not ready to merge. NullAway still reports 576 errors in calcite-core and 126 in calcite-linq4j, so the nullness CI job is red on purpose. The point is to show the migration and the shape of what remains. See CALCITE-7736.

Why

The Checker Framework needs a Gradle plugin of its own, 48 .astub files that patch the nullness of the JDK and of third-party libraries, and two dedicated CI jobs. NullAway is a single Error Prone check: no separate plugin, no stub files, and nullness models for the JDK and for popular libraries out of the box. The annotations come from JSpecify, a specification that several checkers read, rather than from one checker's own package.

What

Six commits, each doing one thing:

Commit
Replace the Checker Framework with NullAway and JSpecify build and CI only
Move @Nullable and @NonNull from the Checker Framework to JSpecify mechanical rename; 1197 insertions against 1197 deletions, and no changed line touches anything but those two imports
Declare @NullMarked on the packages that NullAway verifies plus LintTest.testLintNullMarked
Migrate the Checker Framework annotations that JSpecify does not define @PolyNull, @MonotonicNonNull, @Pure, the initialization annotations, and the rest
Replace @PolyNull with @Contract 108 clauses
Give type parameters the nullable bounds the Checker Framework inferred 61 declarations

The rename commit is worth skimming rather than reading. Commits 1 to 3 do not build on their own, because the source still carries Checker Framework annotations after checker-qual is gone; from commit 4 onward every commit compiles.

NullAway is configured in JSpecify mode with the experimental generics support (JSpecifyExperimental, HandleWildcardGenerics, JSpecifyJDKModels, WarnOnGenericInferenceFailure) and with CheckContracts. It is an error in the projects listed in nullawayProjects and off elsewhere, so a nullness problem fails one CI job rather than every test job.

org.apache.calcite.linq4j.annotations is new and holds @Contract, @MonotonicNonNull, @RequiresNonNull, @EnsuresNonNull and @EnsuresNonNullIf. NullAway matches these by the last component of their name rather than by their package, so Calcite declares its own and takes no dependency on the checker.

The part worth reviewing

The two tools default an unwritten type parameter bound in opposite directions. CLIMB-to-top gives implicit bounds the top qualifier, so <T> under the Checker Framework means <T extends @Nullable Object>; JSpecify fills in Object, which under @NullMarked is non-null. Every unbounded type parameter therefore changed meaning, and Calcite relied on the Checker Framework reading — SqlShuttle extends SqlBasicVisitor<@Nullable SqlNode> was passed to SqlNode.accept(SqlVisitor<R>) with no suppression, which typechecks only if R admits a nullable argument.

Writing the bound out at 61 declarations took NullAway from 1126 errors to 576. Pair.of alone was worth 132: its class already had the bounds, but a static factory declares type parameters of its own.

The erasure is unchanged, so these are binary compatible.

How to verify

./gradlew -PenableErrorprone :linq4j:classes :core:classes

Needs JDK 21, which Error Prone 2.43 and later require.

classes, testClasses, checkstyleMain, checkstyleTest and autostyleCheck pass. :core:test and :linq4j:test run 18866 tests with no failures.

Open questions

  • nullawayProjects lists :linq4j and :core. The Checker Framework jobs also covered :server.
  • Should the annotations live in a separate calcite-annotations module rather than in calcite-linq4j?
  • NullAway crashes with an IndexOutOfBoundsException when a @Contract clause names more arguments than the call site passes: ContractHandler.onDataflowVisitMethodInvocation reads arguments by the antecedent's length, and validates the arity on declarations but not at call sites. Worth reporting upstream. Avoided here by not annotating receiver parameters or varargs methods.

vlsi added 14 commits August 23, 2026 22:11
The Checker Framework verified nullness through a Gradle plugin of its own, a
set of `.astub` files that patched the nullness of the JDK and of third-party
libraries, and two dedicated CI jobs. NullAway runs as an Error Prone check
instead, so it needs no separate plugin and no stub files: it ships nullness
models for the JDK and for popular libraries, and JSpecify supplies the
annotations.

Build:
* drop the `org.checkerframework` plugin and its configuration block, and
  delete the 48 `.astub` files
* add `com.uber.nullaway:nullaway` to the `errorprone` configuration and
  configure it for JSpecify, including the experimental generics support
  (`JSpecifyExperimental`, `HandleWildcardGenerics`, `JSpecifyJDKModels`,
  `WarnOnGenericInferenceFailure`)
* replace `org.checkerframework:checker-qual` with `org.jspecify:jspecify`
* raise Error Prone to 2.50.0 and the Error Prone plugin to 5.1.0, which
  NullAway requires

NullAway is an error in the projects listed in `nullawayProjects` and is off
elsewhere, so a nullness problem fails one CI job rather than every test job.

CI:
* drop the two `CheckerFramework` jobs
* fold nullness verification into the `errorprone` job, which moves to JDK 21
  because Error Prone 2.43 and later require it

This commit only moves the tooling; the source still carries Checker Framework
annotations and is migrated by the commits that follow.
… to JSpecify

A purely mechanical rename of
`org.checkerframework.checker.nullness.qual.Nullable` to
`org.jspecify.annotations.Nullable`, and of the matching `NonNull`. Both
annotations are `TYPE_USE`, so every existing position stays valid and no
annotation moves.

Autostyle rewrites either annotation to its JSpecify counterpart from now on,
alongside the rule that already rewrote the jsr305 ones.

The Checker Framework annotations that JSpecify does not define -- @polynull,
@MonotonicNonNull, @RequiresNonNull and the rest -- are still here and are
migrated by the next commit.
…fies

`@DefaultQualifier(NonNull, {FIELD, PARAMETER, RETURN})` is how the Checker
Framework said "types are non-null unless annotated". JSpecify says it with
`@NullMarked`, which covers every type position rather than three of them.

Only `calcite-linq4j` and `calcite-core` are verified, so only their main
packages get the annotation: `@NullMarked` claims that a package is fully
annotated, and in an unverified module nothing backs that claim. The 23
`package-info.java` files that carried `@DefaultQualifier` are converted, and
the remaining packages of those two modules get the annotation.

`babel`'s test `package-info.java` also carried `@DefaultQualifier`. It loses
the annotation rather than gaining `@NullMarked`, because `checker-qual` is
gone and test code is not verified.

NullAway skips a package that is not `@NullMarked`, so a missing annotation
costs coverage without saying a word. `LintTest.testLintNullMarked` walks the
source roots in `NULL_MARKED_ROOTS` and fails when a package there has no
`package-info.java`, or has one that does not declare `@NullMarked`. Widen that
list together with `nullawayProjects`.
…y does not define

JSpecify defines @nullable, @nonnull and @NullMarked, and nothing else. The
remaining Checker Framework annotations either move to a Calcite-owned
equivalent or go away.

Adds `org.apache.calcite.linq4j.annotations` with @contract,
@MonotonicNonNull, @RequiresNonNull, @EnsuresNonNull and @EnsuresNonNullIf.
NullAway matches these by the last component of their name rather than by
their package, so Calcite declares its own and takes no dependency on the
checker:

* `ContractUtils.hasSimpleNameContract` compares the simple name
* `Nullness.isMonotonicNonNullAnnotation` tests `endsWith(".MonotonicNonNull")`
* the field-contract handlers pass `exactMatch=false` to
  `NullabilityUtil.findAnnotation`, which also compares the suffix

Moved to that package: @MonotonicNonNull (32), @RequiresNonNull (21),
@EnsuresNonNull (23) and @EnsuresNonNullIf (14). The two @EnsuresNonNull that
named a parameter rather than a field are dropped, along with the four
@EnsuresNonNullIf whose expression was a method call: NullAway supports fields
only.

@polynull (300) becomes @nullable. It says the result is null exactly when the
argument is null, which @nullable weakens to "may be null"; the next commit
restores the other half with @contract.

Dropped, having no equivalent and no NullAway counterpart: @pure (81), the
initialization annotations (80), @KeyFor and @UnknownKeyFor (18), @covariant
(10), @HasQualifierParameter and @minlen. NullAway checks initialization on
its own, so the receiver parameters that existed only to carry
@UnderInitialization are removed with them.

`<@nullable R>` becomes `<R extends @nullable Object>`. The Checker Framework
reads an annotation on a type parameter declaration as a bound on the lower
bound, so `<@nullable R>` there means R *must* be nullable; JSpecify tracks
upper bounds only and can say no more than "may be".

215 @SuppressWarnings that named Checker Framework message keys such as
`argument.type.incompatible` become "NullAway".

`Nullness.castNonNull` keeps working as NullAway's `CastToNonNullMethod`; it
loses @pure and its parameter-naming @EnsuresNonNull, and its blanket
suppression narrows to the one method that needs it.
@polynull said the result is null exactly when the argument is null. The
previous commit weakened it to @nullable, which makes every caller treat the
result as nullable even when it passed a non-null argument. @contract states
the direction that matters at a call site:

    @contract("!null, _ -> !null")
    public static @nullable Integer plus(@nullable Integer b0, int b1)

NullAway is configured with `CheckContracts=true`, so it verifies each clause
against the method body rather than trusting it, and none of the 108 clauses
here is rejected.

Not every @polynull converts. A clause describes arguments, so it cannot
describe a receiver parameter, and it cannot describe a varargs method, whose
call sites pass a different number of arguments. Nor does it reach a type
argument such as `Enumerable<@nullable T>`, where the element rather than the
result is polymorphic. Those keep the plain @nullable.

Passing a clause whose length does not match the call crashes NullAway with an
IndexOutOfBoundsException from `ContractHandler.onDataflowVisitMethodInvocation`,
which reads arguments by the antecedent's length without checking the arity it
was given. It validates that on declarations but not at call sites; worth
reporting upstream.
…ramework inferred

The two tools default an unwritten type parameter bound in opposite
directions. The Checker Framework's CLIMB-to-top rule gives implicit bounds the
top qualifier, so `<T>` there means `<T extends @nullable Object>`. JSpecify
fills in `Object`, which under `@NullMarked` is non-null, and its user guide
says as much: "`<E>` means `<E extends Object>` and that means it is not
`@Nullable`".

So every unbounded type parameter silently changed meaning. Calcite relied on
the Checker Framework reading, for instance here:

    public interface SqlVisitor<R> { ... }
    public class SqlShuttle extends SqlBasicVisitor<@nullable SqlNode> { ... }
    public abstract <R> R accept(SqlVisitor<R> visitor);

`SqlShuttle` is a `SqlVisitor<@nullable SqlNode>` and was passed to `accept`
with no suppression, which only typechecks if R admits a nullable argument.

Writes the bound out at 61 declarations: the Rex and Sql visitors and the 23
`accept` overrides, `Pair` and its factories, `PairList`, `ConsList`,
`FlatLists`, `Holder`, `ImmutableNullableList`, `TryThreadLocal`,
`Util.transform`, and the linq4j types `Enumerable`, `Enumerator`, `Queryable`,
`Function0`, `Function1`, `Function2` and `Ord`.

The erasure is unchanged, so this is binary compatible.

This accounts for most of what NullAway reported: 1126 errors down to 576 in
`calcite-core`. `Pair.of` alone was worth 132 -- its class already had the
bounds, but a static factory declares type parameters of its own.
`Visitor<R>` computes a value by walking a node tree, and there is nothing to
compute for an empty subtree: `Expressions.acceptNodes` returns the result of
the last node, or null when the list is empty, and `VisitorImpl` returns a bare
`null` for a `FunctionExpression` with no body and for a `GotoStatement` with no
expression.

`Visitor.visit` and `Node.accept(Visitor)` now say so, and the type variable
takes the nullable bound the Checker Framework used to infer for it. Under the
Checker Framework this was `VisitorImpl<@nullable R>`, which forced R to be
nullable; JSpecify tracks upper bounds only, so the annotation moves to the
result.

`UseCounter` extended `VisitorImpl<Void>`, a type whose only value is null.
It now extends `VisitorImpl<@nullable Void>`, like `MayThrowVisitor`.

Nothing outside `org.apache.calcite.linq4j.tree` implements `Visitor` or calls
the `accept` overload that takes one.
The seedless `aggregate` starts from the first element and returns null when
there is none. The `min` and `max` overloads that call it returned a non-null
type anyway, so an empty sequence produced a null the signature ruled out.
Eight of them now say `@Nullable`; the other overloads either seed the
accumulator or already wrap the call in `requireNonNull`, and are unchanged.
Four `min` and `max` overloads were already annotated, so this makes the family
consistent.

`aggregate` declares its accumulator `Function2<@nullable TSource, TSource,
TSource>`, and the reducers it is called with genuinely handle a null
accumulator -- every `MIN` and `MAX` constant in `Extensions` opens with
`v1 == null`. Their declarations now match; the `SUM` constants do not test for
null and keep the non-null accumulator.
`aggregate(source, seed, func)` is the reduce that takes a starting value, and
`min` and `max` start it at null. Its accumulator type variable was non-null, so
the seed, the reducer and the result all disagreed with the call.

`TAccumulate` and `TResult` take the nullable bound the Checker Framework used
to infer for them. The reducer parameter becomes
`Function2<TAccumulate, TSource, ? extends TAccumulate>`: the constants in
`Extensions` accept a null accumulator but never produce one, and the wildcard
is what lets a reducer with a non-null result feed a nullable seed.

Three more `min` overloads return null for an empty sequence and now say so,
and `long min(Enumerable, LongFunction1)` wraps the call in `requireNonNull`,
which is what the other overloads that unbox the accumulator already do.

`EqualityComparer<T>` takes the nullable bound as well; `Functions` builds
comparers over nullable elements.

The two `aggregate` bodies carry a NullAway suppression. Assigning the result of
a call returning `? extends TAccumulate` to a `TAccumulate` local makes NullAway
report the local as @nullable, even on `return result;` where the local's type
is the return type. Spelling the type argument exactly reports nothing, so the
wildcard is what triggers it.
NullAway reports a `castToNonNull` whose argument it can already prove non-null,
which is how it flags a cast that has stopped earning its place. Four of them in
`EnumerableDefaults`: `curAccumulator` is assigned from
`accumulatorInitializer.apply()` a few lines above each use, and `outerValue`
from `outerValues.get(i)`.
Four places read a value the JDK is entitled to leave null. The Checker
Framework knew about the first two from `InvocationHandler.astub`, which this
migration deleted; NullAway's own JDK models say the same thing.

* `InvocationHandler.invoke` receives a null `args` array when the proxied
  method declares no parameters. `Compatible` indexed it, and
  `FunctionExpression` forwarded it to a varargs call that requires an array.
* `Primitive.asList` adapts a primitive array, whose elements box to a non-null
  value, so the `Array.get` result is cast rather than checked.
* `EnumerableDefaults.takeTopN` looked up the key it had just read from
  `lastKey()`, and suppressed the finding on the declaration while dereferencing
  the value on the next line. It now uses `requireNonNull`.
`MergeUnionEnumerator.initEnumerators` carried `@RequiresNonNull("inputs")`.
`inputs` is a final field assigned in the constructor; the annotation existed
to get the Checker Framework's initialization checker past a call made from
that constructor. NullAway does its own initialization analysis, so the
annotation only made callers prove something already guaranteed, and the
suppression that silenced it goes with it.

`DefaultEnumerable.aggregate` carried `@Contract("!null, !null -> !null")`,
generated when `@PolyNull` was replaced. The original was polymorphic in the
accumulator function as well, so a non-null seed implied a non-null result. The
function may now return null, which makes the clause claim more than the method
delivers -- and NullAway, running with `CheckContracts`, said so.
`Linq4j.SingletonNullEnumerator` yields exactly one element and that element is
null. `Functions.Ignore` implements the function interfaces by returning null
from every `apply`. Both are meaningful only when their type argument is
instantiated nullable.

The Checker Framework could demand that: `<@nullable E>` on a type parameter
declaration constrains the lower bound, so E had to be nullable. JSpecify tracks
upper bounds only, and `<E extends @nullable Object>` says "may be" rather than
"must be". Neither class is public, and both carry a suppression naming the
reason.
`MemoryFactory` keeps a fixed-size window of rows for MATCH_RECOGNIZE, backed by
a `@Nullable Object[]`. `MemoryEnumerator` pads it with `add(null)` once the
input runs out, so that the last rows can still be read with their following
context, and a slot that has not been written yet reads back as null either way.

`add` takes `@Nullable E`, `Memory.get` returns it, and both type parameters
take the nullable bound.
vlsi added 5 commits August 24, 2026 11:38
An outer join emits a row where one side is missing, and the merge and
correlate joins build that row from a null. The types said otherwise.

* `ExtendedEnumerable.correlateJoin` takes
  `Function2<TSource, ? super @nullable TInner, TResult>`. Its own javadoc
  already said "for semi/anti join inner argument is always null", and
  `EnumerableDefaults.correlateJoin` already declared the parameter that way.
* `CartesianProductJoinEnumerator` accepts
  `Enumerator<? extends @nullable TInner>`, which is what a left join hands it
  as the single null inner row; its result selector already took
  `@Nullable TInner`.
* `Linq4j.enumerator(Collection)` and `Linq4j.IterableEnumerator` take the
  nullable bound, so that null row can be enumerated at all.
* Merge join widens its result selector before handing it to `nestedLoopJoin`,
  which serves right and full joins too and so requires one that tolerates a
  null left row. Merge join rejects those join types up front.

`IterableEnumerator.moveNext` carries a NullAway suppression: assigning
`Iterator<? extends T>.next()` to a field of type `T` is reported as assigning
@nullable to @nonnull, the same limitation the seeded `aggregate` hits.

`WrapMap.get` and `remove` take a nullable key, as `Map` requires, and wrapping
one throws. That is what the map did before it was annotated; `put` already
carried the same suppression.

With this, NullAway reports nothing in calcite-linq4j.
`RexVisitorImpl`, `RexBiVisitorImpl` and `SqlBasicVisitor` are the traversals
that subclasses extend and override where they care; every method they define
returns null. That is meaningful only when the result type is instantiated
nullable, which JSpecify cannot require: it tracks upper bounds, so
`<R extends @nullable Object>` says "may be" rather than "must be". The Checker
Framework said it with `<@nullable R>`, which constrains the lower bound.

Same treatment as `Linq4j.SingletonNullEnumerator` and `Functions.Ignore`.
…Void

A visitor that computes nothing was written `RexVisitorImpl<Void>`. `Void` has
null as its only value, so a non-null `Void` is a type nothing inhabits, and the
traversal returns exactly the null it rules out.

68 sites across `RexVisitorImpl`, `RexVisitor`, `SqlBasicVisitor`, `SqlVisitor`
and `RexBiVisitorImpl` now say `@Nullable Void`, including the parameters and
fields that hold such a visitor.
NullAway reports a `castToNonNull` whose argument it can already prove non-null,
which is how it flags a cast that has stopped earning its place. 46 of them
across 20 files, most in the `FlatLists` and `PairLists` constructors, where the
elements arrive as non-null parameters.
…ides

`java.util.List` declares `<T extends @nullable Object> T[] toArray(T[] a)`:
the type variable carries the nullability and the array argument is required.
The six `FlatNList` overrides declared `<T2> @nullable T2[] toArray(T2 @nullable
[] a)`, which disagreed on all three counts, and then had to cast the argument
back to non-null before reading its length.

The six `Object[] toArray()` overrides carried a receiver parameter,
`Flat1List<@nullable T> this`, which is how the Checker Framework said "only
when T is nullable". JSpecify has no such form, and the annotation is what
crashed NullAway when the migration generated a `@Contract` for these methods.

`ComparableListImpl.toArray` casts what it delegates to. NullAway's JDK model
reports `Collection.toArray()` as returning `@Nullable Object[]` at a call site
while requiring an override to return `Object[]`, so a class that both
implements `List` and calls `toArray` on another list has no signature that
satisfies both.
vlsi added 4 commits August 24, 2026 12:55
`PairList` is declared `<T extends @nullable Object, U extends @nullable
Object>`, but the classes in `PairLists` that implement it, and the `MapEntry`
they hand back, declared plain `<T, U>`. A nullable type argument was therefore
rejected at every one of them.

`MutablePairList` keeps both halves of every pair in one
`List<@nullable Object>`, so the element type cannot carry the nullability of T
and of U separately, and reading a slot back produced a `@Nullable` value at 26
call sites. They now go through one `element` helper that explains the packing
and carries the suppression, rather than each site arguing the point.

`backingList` answers with `Arrays.asList` and `Collections.emptyList` instead
of `ImmutableList`, which rejects a `@Nullable` element type. The array-backed
implementation already answered that way.
`select` maps each element through a function, and the function is free to
return null: `SqlFunctions` does exactly that when it projects a column that may
be absent. The `<TResult>` type parameter said otherwise on `ExtendedEnumerable`,
`ExtendedQueryable`, `QueryableFactory`, `EnumerableDefaults` and the classes
that implement them, so a nullable type argument was rejected at the call.

Adds the nullable bound to every `<TResult>` and `<TSource, TResult>` method in
linq4j, and to the two overrides of those methods in core.
A SQL array or multiset may hold nulls, and an outer join hands the adapter a
null collection outright, so the types these helpers work with have to say so.

* `Functions.compareLists` and `compareMaps` accept `? extends @nullable Object`
* `Linq4j.product` gives its element type a nullable bound
* the two outer-join adapters in `SqlFunctions` take `@Nullable List<@nullable
  Object>` rather than `List<Object>`, which is what they were already handed
* `mapFromEntries` builds a `Map<@nullable Object, @nullable Object>` rather
  than a raw one
* `IS_JDK_8` reads `java.version`, which is always set, through `requireNonNull`

`ArrayCartesianProductEnumerable` calls `toArray` through a lambda rather than a
method reference, because the JDK model reports one signature at a call site and
requires another in an override.
… from

Three findings turned out to be NullAway defects rather than Calcite ones, and
are now filed upstream. The places that work around them say which:

* uber/NullAway#1726, a `@Contract` clause whose antecedent arity does not match
  the call crashes the analysis. The rule in the contributing guide says so.
* uber/NullAway#1727, a call returning `? extends T` reads as `@Nullable` when
  `T` has a nullable upper bound. Both `EnumerableDefaults.aggregate` overloads.
* uber/NullAway#1728, `Collection.toArray()` reports one signature at a call
  site and requires another in an override. `FlatLists` twice, and the lambda in
  `SqlFunctions` that replaced a `List::toArray` method reference.
vlsi added 3 commits August 24, 2026 14:15
…getOrDefault

`getOrDefault(key, new BitSet())` cannot return null: the maps hold non-null
`BitSet` values and the default is non-null. NullAway reported all 16 calls as
nullable anyway, because the fields are declared `HashMap`, which overrides
`getOrDefault`, and the model of `Map.getOrDefault` is not carried over to the
override. A field declared `Map` reports nothing, and so does `TreeMap`, which
inherits the method rather than overriding it.

One `edgesOf` helper reads the map with `get` and answers an empty set, which
depends on no model and says at one place what the 16 call sites were each
implying.

Reported as uber/NullAway#1729, see
nullaway-bugs/jdk-model-shadows-inherited-library-model.md.
… elements

`ImmutableNullableList` exists to hold nulls, and `Pair` is declared
`<T1 extends @nullable Object, T2 extends @nullable Object>`. Their static
factories said otherwise: a static method declares type parameters of its own,
and these had none, so a nullable element was rejected at every call.

* `ImmutableNullableList`, its three `copyOf` overloads and `builder`. The inner
  `Builder` already had the bound, which is why `builder()` returning
  `Builder<Double>` could not feed a `Builder<@nullable Double>`.
* the 16 remaining statics on `Pair`: `zip`, `toMap`, `forEach`,
  `forEachIndexed`, `adjacents`, `firstAnd` and the rest.

Together these account for 62 of the findings, most of them in `RelMdSize`,
which computes a size per column and has no size for some of them.
Same treatment `FlatLists` got. `java.util.List` declares
`<T extends @nullable Object> T[] toArray(T[] a)`; the override declared
`<T> @nullable T[] toArray(T @nullable [] a)` and then cast the argument back to
non-null to read its length. `Object[] toArray()` carried a
`ConsList<@nullable E> this` receiver parameter, which is how the Checker
Framework said "only when E is nullable".

The two delegated `toArray()` calls are cast, and say why: the JDK model reports
`@Nullable Object[]` at a call site while the override check accepts only
`Object[]` (uber/NullAway#1728).
vlsi added 6 commits August 24, 2026 14:38
The runtime works on SQL arrays, multisets and maps, whose elements and values
are nullable, and on rows whose fields may be absent. The types said otherwise.

* `Linq4j.asEnumerable` gives its element type a nullable bound, at all four
  overloads: viewing a collection as an `Enumerable` should not lose the fact
  that it holds nulls
* `Functions.compareMaps` accepts a nullable key type as well as a nullable
  value type, which is what a cast `(Map<?, ?>)` produces
* `FlatLists.ofSingle` and `of(List)` take nullable elements. They build a
  `Flat1List` and a flat list, both of which already admit them
* `flatListOuter` takes `@Nullable List<@nullable Object>`, which is what an
  outer join hands it, and its body already tested for null
* `mapFromString` builds `Map<String, @nullable String>`: a pair with no
  separator has no value. `slice` collects `structAccess` results, which are
  null for an absent field

Three locals that hold a `toArray()` result are declared `@Nullable Object[]`,
and say why (uber/NullAway#1728).
Six utilities that everything else calls declared type parameters without a
nullable bound, so a nullable argument was rejected at every call.

* `Ord` holds a nullable element type; its seven statics now say so
* `PairList`'s seven statics, matching the interface they build
* `Util.firstDuplicate`, which compares elements rather than dereferencing them
* `SqlOperator.acceptCall`, both overloads, and
  `SqlBasicVisitor.ArgHandler.instance`: a visitor result may be nullable, and
  `acceptCall` passes it through

`Util.first` is the Elvis operator: its result is non-null when the fallback
is, which `@Contract("_, !null -> !null")` states and NullAway verifies. Callers
in `RelBuilder`, `SqlValidatorImpl` and `ModelHandler` relied on that.

`Ord.zip(Iterator)` suppresses one finding: `next()` on an
`Iterator<? extends E>` reads as `@Nullable E` when `E` has a nullable upper
bound (uber/NullAway#1727).

91 findings.
Eight files, all saying that a value cannot be null where it can.

* `ImmutableIntList.toArray`, both overrides, match the JDK signature
  `<T extends @nullable Object> T[] toArray(T[] a)` and stop casting the
  argument back to non-null to read its length. Same as `FlatLists` and
  `ConsList`.
* `RexNode.accept(RexBiVisitor, P)` and the `P` of `RexBiVisitor` and
  `RexBiVisitorImpl` take a nullable bound. `LogicVisitor` returns a nullable
  `Logic`, and could not be passed to its own `accept`.
* `Util.skip`, `SqlParserUtil.replaceSublist` and `ResultSetEnumerable.of` join
  the statics that carry the bound of the type they work on.
* `ArrayTable` reads a column whose values may be null into a list and an array
  that say so.

`Permutation.isValid` loses `@RequiresNonNull({"sources", "targets"})`: both are
`int[]`, and a primitive array is never null, so the precondition the Checker
Framework needed during construction states nothing NullAway does not know.

`JdbcTypeImpl` is suppressed and says why. Its `XXX_NULLABLE` constants return
null for a null column, and cannot say so: the enum implements `JdbcType` raw,
because a constant cannot carry its own type argument.
… needed

`@RequiresNonNull` states which nullable fields a caller must have checked.
`RexLiteral.computeDigest`, `RexLiteral.digestIncludesType` and
`Resources.Prop.getDefault` named fields that are `final` and non-null:
`RexLiteral.type` and `typeName`, and `Resources.Element.method`. The Checker
Framework needed them because the methods run while the object is still under
construction, which it tracked and NullAway does not. Under NullAway the
annotations state nothing new and reject every caller.

Same reason `Permutation.isValid` lost its own in the previous commit.

Also here: the 16 overrides of `RexNode.accept(RexBiVisitor, P)` take the
bounds the abstract method now has, and `ImmutableNullableList.of`, all eight
overloads, joins `copyOf` and `builder` in admitting the nulls the class exists
to hold. `JdbcUtils` builds its connection key from a url, user, password and
driver class, of which only the url is required.

42 findings.
…ead preconditions

`canBeLong` answers `o instanceof Boolean || ...`, which is false for null.
`@Contract("null -> false")` states that, and NullAway narrows the caller in
`ColumnLoader` accordingly, where a dropped `@EnsuresNonNullIf` used to.

`LoptMultiJoin` loses `@RequiresNonNull` from six methods: `joinStart` and
`nFieldsInJoinFactor` are `int[]`, and a primitive array is never null. Same as
`Permutation`, `RexLiteral` and `Resources`.

`LoptOptimizeJoinRule` reads each table out of `simpleFactors` once, through
`requireNonNull`, rather than looking the same key up three times. The keys come
from that map, which a dropped `@KeyFor` used to say.

`ChunkList` packs the neighbouring chunks into slots 0 and 1 of the element
array, so those two writes are suppressed and say why. The two places that
narrow a chunk reference now assign the result of `castNonNull` instead of
casting the same nullable local at each use.

`CalciteConnectionConfigImpl` suppresses its six plugin accessors: `getPlugin`
comes from Avatica, which is not annotated, so NullAway cannot infer one `T`
that suits both the `Class` argument and a nullable default. The `@Contract` on
each stays, and NullAway verifies it.

Three lambdas in `ColumnLoader` drop their explicit parameter type, which was
non-null where the functional interface says nullable.
…nd two more dead preconditions

Nine overrides of `SqlOperator.acceptCall` take the bound the base method got
two commits ago. A visitor result may be nullable, and `acceptCall` passes it
through.

Four `InvocationHandler.invoke` implementations move the annotation from the
elements to the array: `@Nullable Object @nullable [] args`. The JDK passes null
for `args` when the proxied method declares no parameters, which is what the
deleted `InvocationHandler.astub` used to say.

`ReflectiveConvertletTable.map` and `SimpleProfiler.Run.columns` are `final` and
non-null, so the `@RequiresNonNull` naming them stated nothing and rejected
their callers.
vlsi added 4 commits August 24, 2026 15:55
…hat @contract cannot

`Util.first` is the Elvis operator of the codebase, and its
`@Contract("_, !null -> !null")` is honoured everywhere except where it matters
most. NullAway consults a contract in its dataflow but not in the JSpecify
generics check, which infers the type argument from the declared return type
alone and reports

    inference failure: type variable T constrained to be both @nonnull and @nullable

at 20 call sites. The same contract on a non-generic method reports nothing, and
an explicit type witness silences the generic one, so the two halves of NullAway
disagree rather than the calls being wrong. Reported as uber/NullAway#1730, see
nullaway-bugs/nullable-return-type-breaks-generic-inference.md.

    static <T extends @nullable Object> T firstNonNull(@nullable T v0, T v1)

says the same thing without a contract: `T` is inferred non-null from the
fallback, `@Nullable T` still accepts a nullable first argument, and the result
is non-null. `first` stays for the calls whose fallback is itself nullable.

`CalciteSystemProperty` already static-imports Guava's `MoreObjects.firstNonNull`
and calls the new one qualified.
…owings

Four proxy invocation handlers index `args`, which the JDK leaves null when the
proxied method declares no parameters, so they read it through `requireNonNull`.
Three places declare a `toArray()` result `@Nullable Object[]`, which is what
the JDK model gives them.

`Util.LINE_SEPARATOR` and `FILE_SEPARATOR` read properties that are always set,
and `toUrl` reads `file.separator` a second time rather than the constant it
already has.

`deepEquals0` used to narrow its argument through `@EnsuresNonNullIf`, which
NullAway supports for fields only, so the three `deepEquals` in
`LogicalFilter`, `LogicalJoin` and `LogicalAsofJoin` say `requireNonNull`
instead.

`SqlBinaryOperator` and `SqlTypeUtil` ask for a charset after `inCharFamily`
has established there is one.
`UnboundMetadata.bind` answers null when the provider has no metadata for a
node, which three implementations already did and none declared.

`LazyReference` holds nothing until its supplier has run, so its
`AtomicReference` is `<@nullable T>` and the two `(T) null` casts go away.

`jsonObjectAggAdd` and `jsonArrayAggAdd` took a raw `Map` and `List` and put
null into them, which is what SQL/JSON asks for under NULL ON NULL. They now
take `Map<String, @nullable Object>` and `List<@nullable Object>`.

`Utilities.compare` is suppressed: its `Comparator` parameter is raw, so
NullAway checks the call against the erased `compare(Object, Object)`, while a
SQL comparator is exactly the thing that orders nulls.

`Matcher` casts the row at offset 0 of the memory window, which is the one the
predicate has just accepted. The window itself is padded with nulls at either
end, which is why `Memory.get` is nullable.
A run of one-off corrections, all of the same shape: a value that can be absent
was flowing into a type that said it could not.

* `Collections.nCopies(n, null)` builds a list of absent values, in
  `ProfilerImpl` twice and `AggregateReduceFunctionsRule`
* `CacheUtil` returns what `toArray()` gives it, `@Nullable Object[]`
* `EnumerableTableModify` keys an update by a source row, whose column values
  may be null
* `RexSimplify.evaluate` returns a value or an exception, exactly one of which
  is set, so both halves of the `Pair` are nullable
* `RelToSqlConverter` collects into a list that becomes a `SqlNodeList`
* `SerializableCharset.readObject` reads back what `writeObject` wrote, and
  `DelegatingScope` looks up a key it took from the map's own key set
* `EnumerableWindow` asks for an offset after ruling out CURRENT ROW, which is
  the case that has none

`SqlNode.toList` and `RelMdColumnUniqueness` replace a method reference with a
lambda. A reference resolves against the JDK model, which disagrees with the
functional interface it is being assigned to; a lambda is inferred from the
target type instead.
vlsi added 3 commits August 24, 2026 16:33
…ract guards

`SqlValidatorNamespace.unwrap` answers null when the namespace is not of the
requested type, which `AbstractNamespace` has always done and neither it nor
`DelegatingNamespace` declared. Six call sites in `SqlValidatorImpl` and
`SqlValidatorUtil` ask through `requireNonNull`; each has already established
the type it is asking for.

`NumberUtil.add` and `RelMdPercentageOriginalRows.quotientForPercentage` write
one `if` per argument rather than `if (a == null || b == null)`, so that no
control-flow merge precedes the `return null`. `CheckContracts` treats a return
reached by a merge as reachable even when every incoming edge is not, and
reports the `@Contract("!null, !null -> !null")` as violated. Reported as
uber/NullAway#1731, see nullaway-bugs/contract-check-misses-merged-guard.md. The
other 108 contracts in the codebase verify without complaint.

Also: `TryThreadLocal.of` and `Functions.generate` take the nullable bound of
the type they build, and `Prepare.THREAD_INSUBQUERY_THRESHOLD` holds a threshold
that may be unset.
…ssion lists hold nulls

`getNamespaceOrThrow(node).unwrap(XNamespace.class)` asks for a namespace kind
the caller has just built, at five places in `SqlValidatorImpl`, and now says so
with `requireNonNull` rather than relying on the declaration `unwrap` no longer
makes.

`Expressions.list` takes a nullable element bound, at all three overloads: a
generated expression list holds a null comparer when the row type needs none.
Five `Util.first(physType.comparer(), ...)` calls move to `firstNonNull`, whose
fallback is a constant expression.

`ArrayTable` permutes a column into an array that keeps the column's nulls.

`SqlRowOperator` reads a field name once rather than testing one call and using
another, `FormatModels` checks the match it just found, and
`CalciteConnectionImpl` reads `user.name`, which is not guaranteed.
Calling `Util.firstNonNull` here drags `Util`'s static initialiser into
`CalciteSystemProperty`'s, and `Util` reads
`CalciteSystemProperty.DEFAULT_CHARSET`, which is still null at that point. The
result is an `ExceptionInInitializerError` from a class-initialisation cycle
that takes every test using `CalciteAssert` with it.

The call was already `MoreObjects.firstNonNull` and stays that way. The previous
commit qualified it as `Util.firstNonNull` only to settle a name clash that the
static import had already settled.
vlsi added 4 commits August 24, 2026 17:22
… the bytecode wildcard

`VolcanoPlanner.normalizePlan` reads a group after `find()` has succeeded,
`MatchNode` names the row it reads at end of stream, `DataContexts.MapDataContext`
maps a name to a value that may be absent, and `SqlBasicCall.set` copies a list
whose elements may be null into an array that says so.

`SqlNodeList` and `SubstitutionVisitor` suppress `containsAll`, `removeAll` and
`retainAll`. An unbounded wildcard read from a bytecode signature behaves as
`? extends Object` and rejects a nullable type argument, while the same
`Collection<?>` written in annotated source accepts one. Reported as
uber/NullAway#1732, see nullaway-bugs/bytecode-unbounded-wildcard-rejects-nullable.md.
`SubstitutionVisitor` puts the suppression on one `removeParents` helper rather
than on the constructor that calls it twice.
…alues

A profiled row carries a value per column, and a column may have none, so
`Collector.add` and its three overrides take `List<@nullable Comparable>` and
`CompositeCollector` keeps a `@Nullable Comparable[]`.

`FlatLists.of(T, T, T)`, the six statics of `CompositeList` and
`SqlBasicCall.set` take the nullable element bound of the lists they build.
`LatticeSuggester` keys a node by its parent, and a root has none;
`AggregateReduceFunctionsRule` names the extra columns it projects, of which the
new ones have no name yet.
…ived its reason

`SqlToRelConverter` reads the project it has just cast rather than casting it at
each use, and asks for a `DmlNamespace` after `isWrapperFor` has established
there is one. `SqlValidatorImpl` collects aliases into a list that admits the
null a child of the FROM clause may have, and looks up a column by an index it
took from the map it is reading.

`JavaRowFormat.copy` returns a list of statements and never null, so the
`castNonNull` around it in `EnumUtils` goes away.

`FilterProjectTransposeRule` answers `replaceIfs` with null when the input has
no distribution, rather than a singleton list holding null. `replaceIfs` takes a
supplier that may answer null, and does the same thing with it.

`CalciteCatalogReader` falls back on a family constant, which is what
`firstNonNull` is for.
`ProfilerImpl` separates the two kinds of row it works with: a scanned row uses
`NullSentinel` for a SQL null and so holds no Java null, while the sketch path
builds a sparse row filled only at the ordinals of the space it feeds.
`Collector.add` takes `List<? extends @nullable Comparable>` so it accepts
both, and each collector casts the ordinals its own space owns.

Type parameters that carry the nullable bound of what they build:
`RexWindowBound.accept` and its override, `Functions.ignore2`, `Util.combine`,
`SqlNodeList.toArray`, `HepPlanner.onCopyHook`, `EnumerableTableModify.keyOf`
and the maps keyed by it, and `ArrayTable.asList`.

`SqlNode.toList` and `RelBuilder` replace a method reference and a Guava call
whose wildcard comes from bytecode with a lambda and a direct iterator check.
`TableFunctionScanNode` drops its raw `Enumerable` for a typed one.

`ArrayTable.permute` is suppressed: an array creation keeps a non-null component
type whatever it is assigned to, so writing a nullable element reports even
though both arrays are declared `@Nullable Comparable[]`.
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant