Skip to content

sdk: support nullable object returns - #15

Open
eunomie wants to merge 9 commits into
dagger:mainfrom
eunomie:java-sdk-nullable-objects-lead-4d7cf00b
Open

sdk: support nullable object returns#15
eunomie wants to merge 9 commits into
dagger:mainfrom
eunomie:java-sdk-nullable-objects-lead-4d7cf00b

Conversation

@eunomie

@eunomie eunomie commented Aug 17, 2026

Copy link
Copy Markdown
Member

Ports dagger/dagger#13879 ("sdk(improvement): support nullable object returns") to this repo.

What

GraphQL fields that return a nullable object or interface had no correct representation in the generated Java client. Every object-typed field was generated lazily — the method appended to the query chain and wrapped the result, so a field that resolved to null still handed back a valid-looking object, and the null only surfaced later as a confusing failure elsewhere.

Nullable methods now resolve the object's id before returning. A missing id becomes Optional.empty(); a present id is rebuilt as a normal, lazy client object. Non-null object methods are unchanged and stay lazy.

Optional<ObjectTypeDef> asObject = typeDef.asObject();
if (asObject.isPresent()) {
  System.out.println(asObject.get().name());
}

Module authors get the mirror capability — declaring Optional<T> as a function return type. That previously did not even build: DaggerType discarded the wrapper, so the generated entrypoint assigned the call to the unwrapped type and javac rejected it.

@Function
public Optional<Directory> maybeDirectory(boolean found) {
  return found ? Optional.of(dag().directory()) : Optional.empty();
}

Compatibility

Generated code is gated on the engine's schema version: below v1.0.0-beta.10 the previous lazy shape is kept, so generating against an older engine still produces a client that engine can serve.

That gate needed plumbing to be meaningful here. The codegen mojo only reads the live version when it queries the schema itself; on this repo's path mod.dang hands it a schema, so the version stayed at the pom's hardcoded 0.21.4 and the gate would have been permanently false. The engine's own version is now passed alongside the schema, with build metadata stripped so the +<commit> suffix does not become a cache key.

Note: the current engine is v1.0.0-beta.9, below the gate — so nothing in this repo's generated output changes yet. The new shape is exercised by the unit tests and by a check that compiles the client at a version past the gate.

One fix beyond the port

Nullability can disagree across an implements relationship: GraphQL lets an implementation strengthen a nullable field to non-null, requires transitive interfaces to be declared, and lets two unrelated interfaces disagree about the same field. Java has one method to satisfy every declaration at once, so optional-ness has to be constant across a whole implements component rather than decided per type — otherwise the generated client does not compile. Coerced fields stay lazy: a non-null field has nothing to resolve, so it costs no round trip and throws nothing.

The reference implementation in dagger/dagger does not handle this and generates uncompilable Java for those schemas. Worth filing upstream separately.

Tests

This repo had no tests and no way to run any: every maven invocation builds with -Dmaven.test.skip=true, and no check ran mvn test.

Test dependencies now live behind a tests maven profile rather than in the module dependency lists — maven resolves test-scoped dependencies even under -Dmaven.test.skip=true, so declaring them unconditionally would undo 1cc5baf and make every cold dagger generate download libraries it cannot use. A packager:unit-tests check runs them, mirroring the SDK's own codegen container (a bare mvn test cannot work: the codegen mojo needs the plugin installed and a schema to generate from).

Coverage: the version gate on both sides of the boundary; the generated shape above and below it; that the generated client compiles, including the disagreeing-nullability cases; the resolve-and-rebuild round trip against a local HTTP server, present and null; the module-side typedef registration and null serialization; and an end-to-end check that generates a module returning Optional<Directory> — which also compiles its entrypoint, the failure this fixes.

The design doc and implementation plan are in hack/designs/.

Add the feature doc and implementation plan for supporting nullable
GraphQL object and interface returns as Optional<T>, mirroring
dagger/dagger#13879.

Signed-off-by: Yves Brissaud <yves@dagger.io>
The SDK reactor has no tests and no way to run any: every maven invocation in
this repository builds with -Dmaven.test.skip=true, and CI is the dagger checks,
none of which runs maven test.

Add the capability behind a `tests` maven profile rather than in the module
dependency lists. Maven resolves test-scoped dependencies even under
-Dmaven.test.skip=true, so declaring them unconditionally would undo 1cc5baf and
make every cold `dagger generate` download libraries it cannot use. The surefire
pin lives in the profile too, so builds without it keep resolving the plugin
version they resolve today.

Wire it to a packager check. A bare `mvn test` over the reactor does not work:
dagger-java-sdk binds the codegen mojo at generate-sources, which needs the
plugin installed and a schema to generate from. So the check mirrors the SDK's
own codegen container. The schema comes from a dependency-free module, since a
module's introspection schema carries its dependencies' types and the reactor
must compile against plain core types.

The engine version is passed alongside the schema. Without it the mojo keeps the
pom literal, which is what supplying a schema means today; build metadata is
stripped so the +<commit> suffix does not become a cache key.

The check then compiles the client once more at a version past the nullable
object gate. The engine this runs against is still below it, so nothing else
would ever generate that shape against the real schema.

prebuilt/m2 carries copies of the poms this changes, so they are refreshed here.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Nullable object and interface fields are only resolvable from engine
v1.0.0-beta.10 onwards. Add the predicate the generators will consult, so
generating against an older engine keeps producing the shape that engine can
serve.

A version that is absent or not a release version is a development build and
gets the current shape, matching what the other SDKs do.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A nullable object field cannot be answered lazily: whether it resolved to an
object or to null is only knowable by asking the engine. Fetch its id, and
either report null or return a builder rooted at node(id:) so everything the
caller does next is lazy again.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Generate the resolving shape for nullable object and interface fields: the
method returns Optional and declares the query exceptions every other non-lazy
accessor already declares. Non-null fields are untouched and stay lazy.

Interface declarations widen to Optional<? extends T> so an implementation may
narrow the element type.

One case the reference implementation does not handle: nullability may disagree
across an implements relationship. GraphQL lets an implementation strengthen a
nullable field to non-null, requires transitive interfaces to be declared, and
allows two unrelated interfaces to disagree — but Java has one method to satisfy
every declaration at once. Optional-ness therefore has to be constant across a
whole implements component, not decided per type, or the generated client does
not compile. Coerced fields stay lazy: a non-null field has nothing to resolve,
so it costs no round trip and throws nothing.

The committed codegen plugin jar is rebuilt here, since generation resolves the
plugin from prebuilt/m2 rather than from source.

Signed-off-by: Yves Brissaud <yves@dagger.io>
A function declared to return Optional<Directory> did not build: DaggerType
discarded the wrapper, so the generated entrypoint assigned the call to the
unwrapped type. Keep the wrapper as a type of its own, registering the return
as optional and serializing the empty case as null.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Codegen only reads the version off the CLI when it has to query the schema
itself. Generation hands it the schema, so the version stayed at whatever the
pom said — 0.21.4 — and the generator could not tell which shapes the engine on
the other end supports.

Pass the live engine version, minus build metadata: the +<commit> suffix
changes on every engine build and would rebuild every module's SDK for nothing.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Generation compiles the entrypoint it produces, so generating a module whose
function returns a nullable object is also the assertion that the entrypoint
the annotation processor writes for it is valid Java — the failure this fixes.

Reuses the generate fixture rather than adding a managed module, so the module
inventory the discovery checks assert on stays as it is.

Signed-off-by: Yves Brissaud <yves@dagger.io>
Signed-off-by: Yves Brissaud <yves@dagger.io>
@eunomie
eunomie marked this pull request as ready for review August 17, 2026 08:12
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