sdk: support nullable object returns - #15
Open
eunomie wants to merge 9 commits into
Open
Conversation
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
marked this pull request as ready for review
August 17, 2026 08:12
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.
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
nullstill 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.Module authors get the mirror capability — declaring
Optional<T>as a function return type. That previously did not even build:DaggerTypediscarded the wrapper, so the generated entrypoint assigned the call to the unwrapped type and javac rejected it.Compatibility
Generated code is gated on the engine's schema version: below
v1.0.0-beta.10the 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.danghands it a schema, so the version stayed at the pom's hardcoded0.21.4and 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
implementsrelationship: 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/daggerdoes 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 ranmvn test.Test dependencies now live behind a
testsmaven 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 colddagger generatedownload libraries it cannot use. Apackager:unit-testscheck runs them, mirroring the SDK's own codegen container (a baremvn testcannot 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/.