From 6d2a815e8d0acd2dd4f2b3271b60be5114824bbd Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 06:59:06 +0200 Subject: [PATCH 01/10] hack/designs: plan nullable object returns Add the feature doc and implementation plan for supporting nullable GraphQL object and interface returns as Optional, mirroring dagger/dagger#13879. Signed-off-by: Yves Brissaud --- .../2026-08-17-nullable-object-returns.md | 656 ++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 hack/designs/2026-08-17-nullable-object-returns.md diff --git a/hack/designs/2026-08-17-nullable-object-returns.md b/hack/designs/2026-08-17-nullable-object-returns.md new file mode 100644 index 0000000..ef76e86 --- /dev/null +++ b/hack/designs/2026-08-17-nullable-object-returns.md @@ -0,0 +1,656 @@ +# Nullable object returns for the Java SDK + +Status: proposed +Date: 2026-08-17 + +## Problem + +GraphQL fields that return a nullable object or interface (`field: Directory`, not +`field: Directory!`) have no correct representation in the generated Java client. +Today `ObjectVisitor`/`InterfaceVisitor` treat every object-typed field the same +way, nullable or not: the method is *lazy* — it appends the field to the query +chain and immediately wraps the resulting `QueryBuilder` in a client object, +without ever talking to the engine. + +```java +public Directory child() { + QueryBuilder nextQueryBuilder = this.queryBuilder.chain("child"); + return new Directory(nextQueryBuilder); // never null, even when the field is +} +``` + +For a nullable field that is wrong in a way the caller cannot recover from: the +returned object looks valid, and the null only surfaces later as a confusing +failure deep inside an unrelated query. This is not a hypothetical corner — +the core schema has 28 such fields, including the whole `TypeDef.as*` family +(see Risks). + +The module-authoring side is worse: a module function declared as +`Optional` does not merely misbehave, it **does not build**. +`DaggerType.of` unwraps `Optional<…>` and throws the optionality away +(`DaggerType.java:63`), so the generated entrypoint emits +`io.dagger.client.Directory res = obj.maybeDirectory(found);` +(`DaggerModuleAnnotationProcessor.java:608,679`) — a javac error. There is no +working behaviour to preserve on that side; the change is purely additive. + +Upstream fixed this across all SDKs in +[dagger/dagger#13879](https://github.com/dagger/dagger/pull/13879). Java's share +of that PR targets `dagger/dagger`'s in-tree `sdk/java`, which this repository +superseded. This document is the same feature, landed here. + +## Goals + +- Generated client: a nullable object/interface field returns `Optional`, + resolved eagerly. +- Generated client: a **non-null** object/interface field keeps today's lazy + behaviour, byte-for-byte. +- Module authors: `Optional` as a function return type registers an optional + object return and round-trips `null` correctly. +- The generated shape is gated on the engine's schema version so the SDK keeps + working against engines older than `v1.0.0-beta.10`. +- Produce the behaviour `dagger/dagger`'s own integration suite asserts of a + compliant Java SDK (`JavaSuite.TestOptionalReturn` and the + `testdata/modules/java/defaults` fixture). Those two files live in + `dagger/dagger`, not here; landing them is a companion change in that + repository and is out of scope for this branch. + +## Non-goals + +- Nullable *scalar* returns. Already representable (`TypeRef.formatType` boxes + `Boolean`/`Int`/`String` unconditionally, so a null scalar is a Java `null`, + not an unboxing NPE); unchanged. +- Nullable **list elements** (`[Directory]`) or nullable lists. Out of scope + upstream too, and the core schema currently has no list-of-nullable-object + field. `QueryBuilder.executeObjectListQuery` (`QueryBuilder.java:253`) would + need its own design. +- Nullable object *arguments*. `Optional` arguments already work end to end + (see Risks — the processor unwraps them before `DaggerType` ever sees them). +- Changing any non-null method's signature or laziness. +- A general `Nullable`/`Maybe` type. `java.util.Optional` is the idiom the + cross-SDK table names for Java. + +## Approach + +### Client side: resolve the ID, then rebuild lazily + +A nullable object method stops being lazy. It chains the field, asks the engine +for that object's `id`, and then either returns `Optional.empty()` (the field +resolved to `null`) or rebuilds a *normal, lazy* client object rooted at +`node(id:)`. Everything downstream of that point is lazy again — only one extra +round trip is introduced, at exactly the point where the caller has to make a +decision anyway. + +```mermaid +sequenceDiagram + participant C as Caller + participant M as generated method + participant Q as QueryBuilder + participant E as Engine + + C->>M: commit.releaseTag() + M->>Q: chain("releaseTag") + M->>Q: executeNullableObjectQuery("GitCommit") + Q->>E: query { commit { releaseTag { id } } } + alt field resolved to null + E-->>Q: { "releaseTag": null } + Q-->>M: null + M-->>C: Optional.empty() + else field resolved to an object + E-->>Q: { "releaseTag": { "id": "GitCommit@…" } } + Q-->>M: QueryBuilder rooted at node(id: "GitCommit@…") + M-->>C: Optional.of(new GitCommit(qb)) + end +``` + +Generated shape: + +```java +public Optional child() + throws InterruptedException, ExecutionException, DaggerQueryException { + QueryBuilder nextQueryBuilder = this.queryBuilder.chain("child"); + QueryBuilder objectQueryBuilder = nextQueryBuilder.executeNullableObjectQuery("Directory"); + return Optional.ofNullable(objectQueryBuilder).map(qb -> new Directory(qb)); +} +``` + +Because the method now performs a query it must declare the SDK's three checked +exceptions — the same ones every scalar and list method already declares. + +Interfaces get one extra wrinkle. In an interface *declaration* the return type +is widened to `Optional` so an implementing object can +narrow it: `ObjectVisitor` emits `implements ` (`ObjectVisitor.java:36`), +and a `Kennel` returning `Optional` only overrides a declaration typed +`Optional`. This is upstream's design, correctly scoped to +`TypeKind.INTERFACE` fields, and is covered by a compile test. + +`QueryBuilder` gains one package-private method: + +```java +QueryBuilder executeNullableObjectQuery(String graphqlTypeName) + throws ExecutionException, InterruptedException, DaggerQueryException { + String id = chain("id").executeQuery(String.class); + if (id == null) { + return null; + } + return new QueryBuilder(this.client).chainNode(graphqlTypeName, id); +} +``` + +This composes from primitives this repository's diverged `QueryBuilder` already +has, and the composition was traced end to end during plan review: + +- `chain("id")` pushes a real `QueryPart`. It must be `chain(String)`, **not** + `chain(List.of("id"))` — the latter records a *leaf*, and leaves are invisible + to the response path walk in `executeQuery(Class)`. (The list codegen path at + `ObjectVisitor.java:270` legitimately uses the leaf form; this one must not.) +- `buildQuery` (`QueryBuilder.java:136-149`) renders `query {child {id}}`, and + when the receiver is itself node-rooted it renders + `node(id:"…"){... on T {child {id}}}` — the inline fragment is applied at the + outermost part only, which is correct here. +- `executeQuery(Class)` walks `parts.descendingIterator()` and is null-safe at + every hop (`QueryBuilder.java:182-184`), so a JSON-`null` field yields Java + `null`. Unlike upstream's JsonPath-based version, this needs no extra guard. + +### Module side: `Optional` is a first-class `DaggerType` + +`DaggerType.of` stops discarding `Optional<…>` and instead returns a +`DaggerType.Optional` decorator around the inner type. It contributes +`.withOptional(true)` to the registered `TypeDef` and keeps `Optional` as +the Java type. A new `valueForSerialization(String)` hook (identity for every +other `DaggerType`) makes the generated invoker serialize `res.orElse(null)` +instead of the `Optional` wrapper, so an empty `Optional` reaches the engine as +JSON `null`. `JsonConverter.toJSON(null)` is an already-exercised path — the +void return uses it (`DaggerModuleAnnotationProcessor.java:695`). + +### Version gating, and the property that makes it real + +Upstream gates the new shape on the schema version: below `v1.0.0-beta.10` the +old lazy shape is kept. `Schema` here already carries a `version` string, so the +gate ports directly as `Schema.supportsNullableObjects()` (unparseable or absent +versions — dev builds — are treated as new). + +But the gate would be **inert and permanently false** in this repository. The +codegen mojo's `dagger.version` parameter (`DaggerCodegenMojo.java:38`) is only +overwritten with the live CLI version inside `daggerSchema()` +(`DaggerCodegenMojo.java:131`), the fallback taken when no schema file is +supplied. On the path this repository actually uses, `mod.dang:143` passes +`-Ddaggerengine.schema=/schema.json`, the mojo returns early +(`DaggerCodegenMojo.java:119`), and `version` stays whatever the pom says — the +hardcoded `0.21.4` +(`sdk/pom.xml:211`). + +So the gate comes with its plumbing fix: `mod.dang` threads an +`engineVersion: String!` through `sdkBuilt`/`vendoredSdk`/`vendoredSdkJar` and +passes `-Ddaggerengine.version=` alongside the schema it already +passes. Three candidate sources were measured against a live engine during plan +review: + +| source | value | verdict | +| --- | --- | --- | +| root `version` | `v1.0.0-beta.9+1c6e07b1` | **correct** — the live engine, same thing `dagger version` reports on the fallback path | +| `ModuleSource.engineVersion` | `v1.0.0-0` (this module), `v1.0.0-beta.7` (a fixture) | wrong — the module's *declared compatibility* version from `dagger-module.toml`, not the engine | +| `__schemaVersion` (already in the introspection query) | `v1.0.0` | wrong — a coarse API-compat version; it cannot express a beta.10 boundary | + +The dang expression is therefore the root `version` field, reachable exactly like +the existing root `container`/`directory`/`currentModule`. Build metadata is +stripped (`version.split("+")` first element) before it is passed: the `+` +suffix changes on every engine build and would otherwise become a Dagger cache +key that invalidates every module's `sdkBuilt` on each engine rebuild. + +The pom default is deliberately **not** changed. It is only consulted when a +caller supplies a schema without a version; every other path (a plain +`mvn install`, which falls through to the CLI) already resolves the real +version, and `mod.dang` will now always pass one. + +**Consequence worth stating plainly:** the local engine and CLI are +`v1.0.0-beta.9+1c6e07b1`, below the gate. Until the engine reaches beta.10 the +generated client keeps the old lazy shape, and the repository's generation +checks therefore do **not** exercise the new client codegen. Unit tests +covering both sides of the gate are the only in-repo coverage of the new shape, +which is why wiring tests into CI (below) is part of this change rather than a +nicety. + +## Alternatives considered + +**Return `T` and let it be `null`.** Rejected: it is the failure mode we are +fixing, silently. `Optional` is also what the cross-SDK table names for Java. + +**Keep nullable methods lazy and add a separate `Optional xOrEmpty()`.** +Rejected: doubles the API surface, and the laziness is precisely what cannot be +preserved — you cannot know whether the field is null without asking. + +**Drop the version gate entirely.** Tempting: this repository declares +`engineVersion = "v1.0.0-0"` everywhere, and it already requires unified IDs and +`node(id:)` (`QueryBuilder.chainNode`), so the gate's real coverage is the narrow +band `[1.0.0-beta.0, 1.0.0-beta.10)` rather than "old engines" in general. +Rejected anyway: it is ~15 lines, it keeps this a faithful port of what every +other SDK does, and it is cheap to delete later. The honest justification is +cross-SDK consistency, not old-engine support. + +**Bump `daggerengine.version` to a 1.0 literal instead of plumbing the real +version.** Rejected: trades one stale literal for another, and leaves the +generated `Version.VERSION` lying. + +**A `GraphQLTransport` interface as a test seam over the `final class +GraphQLClient`.** Rejected after review. It would have to be `public` (the two +classes are in different packages), i.e. new permanent public API for one test; +and the fake could not be built anyway, since `GraphQLResponse.fromBody` is +package-private in `io.dagger.client.graphql` (`GraphQLResponse.java:23`) and the +test lives in `io.dagger.client`. Instead the tests point a **real** +`GraphQLClient` at a `com.sun.net.httpserver.HttpServer` bound to `127.0.0.1:0` +returning canned JSON. Zero production change, no new dependency, and it covers +request formatting and response parsing too. + +**`mockito`.** Rejected: `GraphQLClient` is final, so it needs the inline mock +maker, and the HttpServer approach is both cheaper and more faithful. + +## Affected components + +| Component | Change | +| --- | --- | +| `sdk/dagger-codegen-maven-plugin` · `Schema` | `supportsNullableObjects()` version gate | +| `sdk/dagger-codegen-maven-plugin` · `ObjectVisitor`, `InterfaceVisitor` | `Optional` return type + resolved body for nullable object/interface fields | +| `sdk/dagger-java-sdk` · `QueryBuilder` | `executeNullableObjectQuery` | +| `sdk/dagger-java-annotation-processor` · `DaggerType` | `DaggerType.Optional` + `valueForSerialization` | +| `sdk/dagger-java-annotation-processor` · `DaggerModuleAnnotationProcessor` | serialize via `valueForSerialization` | +| `sdk/pom.xml`, module poms | a `tests` Maven **profile** carrying junit-jupiter, assertj and surefire | +| `.dagger/modules/e2e` | a `@check` that runs the unit tests; a nullable-return fixture check | +| `mod.dang` | thread the engine version, pass `-Ddaggerengine.version` | + +## Testing + +The repository currently has **no** `src/test` anywhere and no test-scope +dependencies. That is deliberate: commit `1cc5baf` ("java-sdk: drop test +dependencies from the vendored SDK reactor") removed them precisely because +*Maven resolves test-scoped dependencies even under `-Dmaven.test.skip=true`*, +so every cold `dagger generate` was downloading several MB it could never use. +Both generation paths still use that flag (`mod.dang:124`, `mod.dang:143`, +`.dagger/modules/packager/main.dang:36`). + +So test capability comes back **behind a Maven profile**, not unconditionally: + +- A `tests` profile in `sdk/pom.xml` (and the three child poms) supplies + junit-jupiter, assertj-core and a pinned Surefire. AssertJ is kept — one jar, + no transitive dependencies — because it makes the generated-source assertions + readable and lets the upstream test bodies port with minimal edits. +- No profile activation, no resolution: `dagger generate` and `packager` resolve + exactly what they resolve today, and `1cc5baf`'s optimisation is preserved. + Two caveats found in review, both handled in patch 2: adding profiles edits + the poms that `packager` *installs*, so the committed `prebuilt/m2` must be + regenerated in the same patch; and `dependency:list` alone cannot prove the + no-profile path is unchanged (it ignores plugin resolution, and a warm `~/.m2` + hides downloads), so the comparison is run against a **fresh empty local + repository** on both sides. + +And tests must actually **run**. CI here is Dagger Cloud checks driven by +`dagger.toml` (`e-2-e:*`, `packager:generate`, `sdk-sdk:*`, `load`, +`dagger-dang-sdk:generate`); there are no GitHub Actions build workflows, and no +existing check runs `mvn test`. + +The naive `mvn -Ptests test` over `sdk/` does **not** work, and both reviewers +caught it independently: `dagger-java-sdk`'s pom binds the codegen mojo at +generate-sources (`sdk/dagger-java-sdk/pom.xml:78-90`). With no +`-Ddaggerengine.schema` the mojo takes the CLI fallback and shells out to +`dagger`, which the pinned maven image does not contain; and the codegen plugin +must already be *installed*, which is exactly why `mod.dang`'s `codegenBase` +does a separate `--projects dagger-codegen-maven-plugin install` exec first. + +So the check lives on **`packager`**, which already owns the pinned maven image, +the shared `~/.m2` cache volume and `sdkSource(ws)`, and already knows how to +install the plugin. It mirrors `codegenBase`/`sdkBuilt`: + +``` +unitTests(ws: Workspace!): Void @check { + mvn.withoutEntrypoint + .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2")) + .withMountedFile("/schema.json", introspectionJSON) + .withDirectory("/dagger-io", sdkSource(ws)) + .withWorkdir("/dagger-io") + .withExec([… "--projects", "dagger-codegen-maven-plugin", "--also-make", "install", …]) + .withExec(["mvn", "-Ptests", "test", + "-Ddaggerengine.schema=/schema.json", + "-Ddaggerengine.version=" + engineVersion, + "-Dfmt.skip=true", "--no-transfer-progress"]) + .sync + null +} +``` + +`packager` has no way to reach an introspection JSON today — `mod.dang` gets it +from `polyfill.workspace(ws).moduleSource(…).core.introspectionSchemaJSON`. The +exact expression is resolved against a live engine during patch 2, adding the +`polyfill` dependency to `packager` if that is what it takes; if `packager` +cannot reach one at all, the check moves to `e2e`, which already depends on +`java-sdk` and takes a `Workspace!`. Passing `-Ddaggerengine.version` here also +means the unit-test run exercises the same gate value CI generation uses. + +Without this check, the profile and the four test classes would buy nothing. + +Coverage, mirroring the dual (present / null) cases upstream covers: + +1. **Version gate** — boundaries and dev versions (`v1.0.0-beta.9` false, + `v1.0.0-beta.10` true, `-dev` and `+` suffixes, unparseable, empty). +2. **Codegen shape** — below the gate a nullable object field still generates + `Directory child();` with no exceptions; at/above it generates + `Optional child()` with `DaggerQueryException`. +3. **Codegen compiles** — the covariant interface/object pair + (`Owner.pet(): Optional` / `Kennel.pet(): Optional`) + compiled in-process via `ToolProvider.getSystemJavaCompiler()`. The + *mixed*-nullability pair (interface field nullable, implementing object's + field non-null) is added to this test as well — see Risks. +4. **`QueryBuilder`** — against a local `HttpServer`: present case, a response + carrying an `id` produces a builder rooted at `node(id:"…"){... on T {…}}`; + null case, a response whose field is JSON `null` produces `null`. The + captured request body is asserted too. +5. **`DaggerType`** — `Optional` renders + `…withObject("Container").withOptional(true)`, keeps `Optional<…>` as its + Java type, and serializes as `res.orElse(null)`. Plus a case pinning that + `Optional` *arguments* are unchanged, and one for the newly-optional + `Optional` object **field** registration. +6. **End to end** — an e2e fixture module declaring + `Optional maybeDirectory(boolean found)`, generated through the + real `generateAll` path. This gives two things for free: the generated + entrypoint must **compile** (`mod.dang`'s `generatedEntrypoint` runs maven + with `dagger.proc=full`), which is exactly the failure described in Problem; + and its registered return type must carry `.withOptional(true)`. + +**What in-repo tests deliberately do not cover:** the live null round trip that +`JavaSuite.TestOptionalReturn` asserts +(`{missing: maybeDirectory(found:false), found: maybeDirectory(found:true)}` → +`{"missing": null, "found": {…}}`). Loading and *calling* a fixture module from +inside a check is not reachable here — `asModuleSource` needs a requester +session, which is why module-source handling still goes through the polyfill +(`main.dang:114`). That assertion lives in `dagger/dagger`'s suite against a real +engine, and coverage items 5 and 6 pin the two halves of it (the serialization +primitive, and the registration + compilation) from this side. + +## Risks + +- **Signature change across 28 core fields.** Every nullable object/interface + field in the core schema changes shape at/after beta.10. Counted from + `core/schema/base_schema.json`: `TypeDef.{asObject,asInterface,asEnum,asList,asScalar,asInput}`, + `Module.{source,runtime,sdk}`, `ModuleSource.sdk`, + `Container.{dockerHealthcheck,stat}`, `Directory.stat`, `File.stat`, + `Query.{node,loadStatFromID,loadSDKConfigFromID}`, `Check.error`, + `LLM.bindResult`, `Binding.asStat`, `ObjectTypeDef.constructor`, and + `sourceMap` on seven types. The `TypeDef.as*` kind-switch idiom is *correctly* + used today by anyone introspecting a module — `typeDef.asObject().name()` + becomes `typeDef.asObject().orElseThrow().name()`, gains three checked + exceptions, and costs one round trip per `as*`. This is the upstream feature + working as designed and every SDK takes the same hit, but it is a real + migration for downstream callers, not a free change. Nothing in this + repository's own Java (`sdk/`, `templates/`, `helpers/`) calls any of the 28, + so this tree does not break. +- **Interface/object nullability mismatch.** If an object implements an + interface whose same-named field is nullable while the object's own field is + non-null, `InterfaceVisitor` emits `Optional pet()` and + `ObjectVisitor` emits the lazy `Dog pet()` — which does not compile against + the `implements` clause. Upstream shares this hole; its compile test only + covers the both-nullable pair. Mitigation: add the mixed pair to the compile + test (coverage item 3). If it reproduces, coerce the `Optional` shape in + `ObjectVisitor` when an implemented interface declares the field optional. + *Whether dagger's schema can actually emit such a pair is unverified* — the + test is what settles it. +- **`Optional` object fields become optional typedefs.** `FieldInfo` keeps + the raw declared type (`DaggerModuleAnnotationProcessor.java:213`), so unlike + parameters, a public `Optional` field on a module object starts registering + `.withOptional(true)` (`:408`). This is a correctness improvement rather than + a regression, but it is a behaviour change the upstream PR does not call out. + Accepted deliberately, pinned by a test. +- **Optional *arguments* are safe — this is not the risk it looks like.** + `DaggerType.of` now wraps rather than unwraps, and it is used for parameters + too, but parameters never reach it wrapped: the processor strips `Optional<…>` + and records `isOptional` before building `ParameterInfo` + (`DaggerModuleAnnotationProcessor.java:296-303`, `:354`), appends + `.withOptional(true)` separately (`:730`), and re-wraps with + `Optional.ofNullable` at invocation (`:625`, `:657`, `:681`). `@Default` is + processed after unwrapping (`:306`, `:745`) and is untouched. Pinned by a test + rather than by hope. +- **Extra round trip.** Each nullable object access costs one query. Inherent to + the semantics; non-null paths are unaffected. +- **`-Ddaggerengine.version` changes a Dagger cache key.** The maven command in + `sdkBuilt` now varies with the engine version, so every module's SDK build + re-runs when the engine changes. Build metadata is stripped to keep that to + real version changes rather than every engine rebuild. +- **`Version.VERSION` churn.** The plumbing changes the literal baked into + generated `io.dagger.client.Version`. No generated client sources are + committed in this tree, so no diff churn here; downstream modules will see it + on regeneration. Note the constant has zero readers repo-wide, so "truthful + `Version.VERSION`" is a side benefit, not a justification. + +--- + +# Implementation plan + +Patches are a StGit series on `java-sdk-nullable-objects-lead-4d7cf00b`, each +signed off, each building and testing green on its own. `packager:generate` is +the one exception: it compares against the committed `prebuilt/m2`, which is +refreshed when its inputs finish changing rather than in every patch that +touches them, so it reports drift at the one patch in between. Ordering note: +the `QueryBuilder` +method lands **before** the codegen that emits calls to it, so no patch leaves +the tree able to generate uncompilable code. + +### Patch 1 — `hack/designs`: this document + +### Patch 2 — test capability, wired into CI + +- `sdk/pom.xml`: a `tests` profile with `junit-jupiter`, `assertj-core` and a + pinned `maven-surefire-plugin`; version properties alongside the existing + ones. Nothing outside the profile. +- The three child poms: the same profile adding the two test-scope deps. +- `.dagger/modules/packager/main.dang`: `unitTests(ws: Workspace!): Void @check` + as sketched in Testing — plugin install, then `mvn -Ptests test` with the + schema and version supplied. +- `prebuilt/m2`: regenerated (`dagger generate packager`), because the poms it + contains now carry the profile. +- Verify: `dagger check` lists the new check and it goes green; the no-profile + dependency set is unchanged, compared with a fresh empty local repository on + both `main` and this patch (not just `dependency:list` against a warm `~/.m2`). + +### Patch 3 — `Schema.supportsNullableObjects()` + gate test + +- `Schema`: the `1.0.0-beta.10` constant and the predicate, using + `ComparableVersion` from `org.apache.maven:maven-artifact` (add it explicitly + at `provided` scope if it is not already resolvable through + `maven-plugin-api`). +- `SchemaTest`: coverage item 1, including a `+` build-metadata case + since that is what the live engine actually reports. + +### Patch 4 — `QueryBuilder.executeNullableObjectQuery` + test + +- The method as shown in Approach. No seam, no production API change. +- `QueryBuilderTest`: coverage item 4, against `HttpServer` on `127.0.0.1:0`. + +### Patch 5 — client codegen + +- `ObjectVisitor.buildFieldMethod` and `InterfaceVisitor.buildFieldMethod` / + `generateType`: wrap the return type in `Optional<…>` (plus `? extends` on + interface *declarations*), emit the `executeNullableObjectQuery` body, add the + three exceptions. +- `InterfaceVisitor.needsExceptions`: object/interface fields need exceptions + when the gate is on and the field is optional. +- `NullableObjectCodegenTest`: coverage items 2 and 3, including the + mixed-nullability pair. + +### Patch 6 — module-side `Optional` return + +- `DaggerType.valueForSerialization`, `DaggerType.Optional`, and `DaggerType.of` + wrapping instead of unwrapping. +- `DaggerModuleAnnotationProcessor.functionInvoke`: serialize + `returnType.valueForSerialization("res")`. +- `DaggerTypeTest`: coverage item 5 — return, argument-unchanged, and field + cases. + +### Patch 7 — real engine version into codegen + +- `mod.dang`: thread `engineVersion: String!` through `sdkBuilt`, `vendoredSdk` + and `vendoredSdkJar`; the caller passes the root `version` field with build + metadata stripped; `sdkBuilt` adds `-Ddaggerengine.version=`. + +### Patch 8 — e2e nullable-return check + +- `.dagger/modules/e2e/fixtures/`: a fixture module function returning + `Optional`; a check that generates it and asserts the entrypoint + compiles and registers the return type as optional. Coverage item 6. +- Two existing-fixture edits this patch must carry, or it breaks the suite: + `modulesCwdCheck` asserts `fromRoot.length == 5` and `modulesCheck` enumerates + the managed modules by name (`.dagger/modules/e2e/main.dang`), so a new + managed fixture changes both; and `fixtures/.dagger-java-sdk-skip-generate` + suppresses generation fixture-wide, so the new check must exclude it the way + `generateCwdCheck` already does. Reusing the existing `generate/app` fixture + instead of adding a sixth module is the cheaper option and is preferred if the + assertions allow it. + +### Patch 9 — document the client API change + +- `sdk/README.md`: a short note that from engine `v1.0.0-beta.10` a nullable + object/interface field returns `Optional` and declares the SDK's three + checked exceptions, with `TypeDef.as*` as the worked example. `hack/designs/` + is not where a Java module author looks; this is the only user-facing surface + this repository has. + +### Verification + +Local, in order: + +1. `mvn -f sdk/pom.xml -Ptests test` — all new unit tests green. +2. `mvn -f sdk/pom.xml -Dmaven.test.skip=true -Dfmt.skip=true install` — the + build path every dagger check uses is unbroken, and the resolved dependency + set is unchanged from `main` when both are measured against a fresh empty + `-Dmaven.repo.local`. +3. `mvn -f sdk/pom.xml fmt:check` — `fmt-maven-plugin` binds `fmt:format` in the + build (`sdk/pom.xml:159`), so formatting is auto-applied rather than + enforced; run the check explicitly so this diff does not rely on a rewrite. +4. `dagger check` — the repository's real CI: the new `e-2-e:unit-tests`, plus + `e-2-e:*`, `packager:generate`, `sdk-sdk:*`, `load`, + `dagger-dang-sdk:generate`. `sdk-sdk` regenerates real modules end to end and + is the guard against generation regressions. + +## Progress + +- **Phase 0 — orientation: done.** Base `upstream/main` @ `ae4d315`, tree clean, + StGit stack empty. Fork remote `origin` = `eunomie/java-sdk` (a real GitHub + fork of `dagger/java-sdk`). CI is Dagger Cloud checks (`dagger.toml`), not + GitHub Actions. Design home created at `hack/designs/` (repo had none). + Sign-off trailer: `Signed-off-by: Yves Brissaud `; no AI + attribution anywhere. +- **Key orientation finding.** Among the production Java sources the upstream PR + touches, `ObjectVisitor`, `InterfaceVisitor`, `Schema`, `DaggerType` and + `DaggerModuleAnnotationProcessor` in this repository are byte-identical to + `dagger/dagger`'s `sdk/java` at `main`; only `QueryBuilder` diverges (in-house + `GraphQLClient` + string queries instead of SmallRye `DynamicGraphQLClient` + + `Document`). The PR's remaining Java files are test files, which have no + counterpart here at all. So the genuinely repo-specific work is the + `QueryBuilder` method, the missing test capability *and its CI wiring*, and + the version-property plumbing. +- **Phase 1 — feature doc: this document.** +- **Phase 2 — implementation plan: above.** +- **Phase 4 — implementation: done.** Ten StGit patches; `mvn -Ptests test` + green (12 tests), `dagger check` green (33/33, including the two new checks). + Deviations from the plan, all deliberate: + - The mixed-nullability interface/object case **reproduced** on first run of + the new compile test, exactly as the plan's risk predicted. Fixed in + `ObjectVisitor` by generating `Optional` for a non-null field whose + interface declares it nullable — kept lazy (`Optional.of(...)`, no query, no + exceptions), since a non-null field has nothing to resolve. This is a fix + the upstream reference does not have. + - The engine version is a `let` on the type rather than an argument threaded + through `sdkBuilt`/`vendoredSdk`/`vendoredSdkJar`: the root `version` field + is reachable directly, so threading it would have been noise. Dang has no + `.first`; the expression is `version.split("+")[0] ?? version`. + - `packager` gained a `polyfill` dependency to reach an introspection schema, + and the schema comes from `.dagger/modules/templates` rather than the + workspace root — the root module's schema carries polyfill's types, which + this codegen does not emit valid Java for. + - `dagger-java-sdk` needed `yasson` at test scope too: the SDK compiles + against the jakarta.json APIs only and a module supplies the implementation + at runtime. + - Patch 8 reuses the `generate/app` fixture, so no e2e assertion needed + changing after all. + - An extra patch regenerates `prebuilt/m2`, which ships the codegen plugin + jar and so has to be rebuilt *after* the codegen change, not with the poms. + - The `QueryBuilder` present-case assertion differs from upstream's by one + space: this repo renders `node(id:"…") {…}`, upstream's `Document` builder + renders `node(id:"…"){…}`. + + Verified by removing the fix and re-running: `e-2-e:nullable-return-check` + fails with the exact compile error from the Problem statement, so it is not + vacuous. + +- **Phase 5 — code review and fix: done, one round.** Two fresh reviewers + (Codex xhigh, Claude high) on the implemented diff, then a separate fixer. + Both independently confirmed the port is spec-correct — one generated the + client against the real core schema at `-Ddaggerengine.version=v1.0.0-beta.10` + and got exactly 28 `Optional` methods in upstream's shape, compiling. Every + finding was in the test net or the packaging, and every one was + mutation-verified before being accepted: + - **The interface fix was incomplete.** Coercing only the object side still + generated uncompilable Java for an interface implementing another interface, + and for two unrelated interfaces disagreeing about the same field. The rule + is not "an implemented interface declares it nullable" but *optional-ness is + constant across an implements component* — the lookup moved to + `AbstractVisitor` and both visitors now use it. Two more compile tests. + - **The compile test could not detect a missing `throws`.** Its stub + `QueryBuilder` declared no checked exceptions, so deleting the three + `addException` calls passed the suite. Stub made faithful. + - **Nothing compiled the real client above the gate.** `packager:unitTests` + now also compiles `dagger-java-sdk` at a literal `v1.0.0-beta.10`, ahead of + the engine it runs against. This is what actually proves the beta.10 flip + will not break the SDK. + - **The module-side serialization was unpinned:** reverting + `res.orElse(null)` to `res` passed all tests *and* the e2e check. The check + now asserts `res.orElse(null)` and anchors the typedef assertion to + `withObject("Directory").withOptional(true)`. + - **The surefire pin leaked out of the profile** — measured against fresh + local repositories, non-profile builds resolved 3.5.4 instead of Maven's + 3.2.5, contradicting this document's own claim. Moved into the profile. + - **A vacuous test removed** (`optionalArgumentsAreUnaffected` asserted + nothing about arguments) and replaced with the `Optional` object-field + test this document promised but did not have. + - **`prebuilt/m2` split** so no patch leaves `packager:generate` reporting + drift for longer than it must: the pom copies ride with the patch that edits + the source poms, the plugin jar with the patch that finishes changing the + plugin. The separate refresh patch is gone. + + Final state: 9 patches, 14 unit tests, `dagger check` 33/33, clean tree. + + For the human: the interface-hierarchy bug above is present in + `dagger/dagger`'s own `sdk/java` — worth filing upstream against + `ObjectVisitor`/`InterfaceVisitor` so the two implementations do not drift. + Not filed from here. + + Noted, not fixed: the tree is not `fmt:check`-clean on `main` + (`DaggerExceptionUtils`, `GraphQLClient`, `GraphQLResponse`, `GraphQLValues` + reformat under `fmt:format`, because every build path passes `-Dfmt.skip=true`). + Those reverts were kept out of this branch as unrelated churn. +- **Phase 3 — adversarial plan review: done, one round.** Two independent + reviewers (Codex xhigh, Claude high) on forked worktrees. Findings adopted: + tests were unwired from CI (both, blocking); unconditional test deps would + have undone commit `1cc5baf` (profile added); the `GraphQLTransport` seam was + unbuildable and unnecessary (dropped for `HttpServer`); patches 4/5 were + ordered wrong; the engine-version source resolved to root `version` with build + metadata stripped; the "highest risk" optional-argument concern was refuted + and replaced with the real ones (28 changed core fields, `Optional` object + fields, interface/object nullability mismatch); two Problem-statement claims + were factually wrong and are corrected. Reviewers independently confirmed the + byte-identical orientation claim and that upstream's `executeNullableObjectQuery` + composes correctly with this repo's diverged `QueryBuilder`. + + **Round 2** re-reviewed the revision. Both reviewers marked every finding + resolved except the CI wiring, which was still wrong: `mvn -Ptests test` would + have failed before reaching a test, because the codegen mojo needs a schema and + an already-installed plugin. Both independently recommended hosting the check + on `packager`; done, along with three consequences they surfaced — + regenerating `prebuilt/m2` (its poms gain the profile), proving the no-profile + dependency set against a fresh local repository rather than `dependency:list`, + and patch 8's collision with `modulesCwdCheck`'s `length == 5` assertion and + the fixture-wide skip marker. A user-facing README note was added as patch 9. + Round 2 also verified: dang's `String.split` treats `"+"` literally so the + build-metadata strip is sound; the pom-default bump is genuinely unnecessary + (`daggerengine.schema` defaults to empty, so a plain `mvn install` takes the + CLI path and resolves the real version); the `HttpServer` test approach is + workable, with the caveat that `GraphQLClient` sets no request timeout, so the + handler must always send response headers; and the e2e fixture's compile + coverage is real (`mod.dang:194` runs `mvn compile -Ddagger.proc=full`, and a + broken entrypoint fails the exec). From 276f6c1a1dc5cdac8f9bf2d43a89f24615726f54 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:27 +0200 Subject: [PATCH 02/10] java-sdk: run the SDK unit tests in CI 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 + 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 --- .dagger/modules/packager/dagger-module.toml | 5 ++ .dagger/modules/packager/main.dang | 52 +++++++++++++++++++ .../dagger-codegen-maven-plugin-0.21.4.pom | 17 ++++++ .../0.21.4/dagger-sdk-parent-0.21.4.pom | 45 ++++++++++++++++ sdk/dagger-codegen-maven-plugin/pom.xml | 17 ++++++ sdk/dagger-java-annotation-processor/pom.xml | 18 +++++++ sdk/dagger-java-sdk/pom.xml | 24 +++++++++ sdk/pom.xml | 45 ++++++++++++++++ 8 files changed, 223 insertions(+) diff --git a/.dagger/modules/packager/dagger-module.toml b/.dagger/modules/packager/dagger-module.toml index 2575e6c..bafd0f9 100644 --- a/.dagger/modules/packager/dagger-module.toml +++ b/.dagger/modules/packager/dagger-module.toml @@ -3,3 +3,8 @@ engineVersion = "v1.0.0-0" [runtime] source = "dang" + +[[dependencies]] + name = "polyfill" + source = "github.com/dagger/polyfill@main" + pin = "e90bbfc4843258a877a3a95b8db1571e7981e65f" diff --git a/.dagger/modules/packager/main.dang b/.dagger/modules/packager/main.dang index f451365..1b97d86 100644 --- a/.dagger/modules/packager/main.dang +++ b/.dagger/modules/packager/main.dang @@ -43,6 +43,58 @@ type Packager { .directory("/out") } + """ + Run the SDK's unit tests. + + Tests live behind the `tests` Maven profile (maven resolves test-scoped + dependencies even under -Dmaven.test.skip=true, so declaring them + unconditionally would slow every cold `dagger generate`), and nothing else in + this repository runs them — the generation paths all skip tests. Hence this + check. + + The reactor cannot be tested with a bare `mvn test`: dagger-java-sdk binds the + codegen mojo at generate-sources, which needs the plugin already installed and + a schema to generate from. So this mirrors the SDK's own codegen container: + install the plugin first, then test with the schema and the engine version + supplied — the same version the gate in Schema.supportsNullableObjects() reads. + """ + unitTests(ws: Workspace!): Void @check { + # A module's introspection schema includes its dependencies' types, so the + # schema is taken from a dependency-free module: the SDK reactor must compile + # against plain core types, the way a freshly generated Java module does. + # (The workspace root would drag in the polyfill, whose types this codegen + # does not generate valid Java for.) + let introspectionJSON = polyfill + .workspace(ws) + .moduleSource("/.dagger/modules/templates") + .core + .introspectionSchemaJSON + mvn + .withoutEntrypoint + .withMountedCache("/root/.m2", cacheVolume("sdk-java-maven-m2")) + .withMountedFile("/schema.json", introspectionJSON) + .withDirectory("/dagger-io", sdkSource(ws)) + .withWorkdir("/dagger-io") + .withExec(["mvn", "--projects", "dagger-codegen-maven-plugin", "--also-make", "install", "-T1C", "-Dmaven.test.skip=true", "-Dfmt.skip=true", "--no-transfer-progress"]) + .withExec(["mvn", "-Ptests", "test", "-Ddaggerengine.schema=/schema.json", "-Ddaggerengine.version=" + engineVersion, "-Dfmt.skip=true", "--no-transfer-progress"]) + # Then compile the client the gate produces from the *real* schema above it. + # The version is a literal, deliberately ahead of the engine this runs + # against: until the engine reaches it, generating at `engineVersion` only + # ever exercises the legacy shape, and nothing would compile the Optional + # one against the core schema — the shape the gate flips to. + .withExec(["mvn", "--projects", "dagger-java-sdk", "compile", "-Ddaggerengine.schema=/schema.json", "-Ddaggerengine.version=v1.0.0-beta.10", "-Dmaven.test.skip=true", "-Dfmt.skip=true", "--no-transfer-progress"]) + .sync + null + } + + """ + The live engine version, without build metadata. + + The `+` suffix changes on every engine build; keeping it would make + every container that embeds this string a fresh cache entry. + """ + let engineVersion: String! { version.split("+")[0] ?? version } + """ Build all committable assets and write them under `prebuilt/` at the workspace root (runs at `dagger generate`). diff --git a/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.pom b/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.pom index 5404745..960992a 100644 --- a/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.pom +++ b/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.pom @@ -78,4 +78,21 @@ UTF-8 + + + tests + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + diff --git a/prebuilt/m2/io/dagger/dagger-sdk-parent/0.21.4/dagger-sdk-parent-0.21.4.pom b/prebuilt/m2/io/dagger/dagger-sdk-parent/0.21.4/dagger-sdk-parent-0.21.4.pom index 3a7cc9b..bb7b189 100644 --- a/prebuilt/m2/io/dagger/dagger-sdk-parent/0.21.4/dagger-sdk-parent-0.21.4.pom +++ b/prebuilt/m2/io/dagger/dagger-sdk-parent/0.21.4/dagger-sdk-parent-0.21.4.pom @@ -202,6 +202,48 @@ + + + tests + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + + + + + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + @@ -218,6 +260,7 @@ --> 0.21.4 + 3.27.7 1.1.1 3.20.0 3.6.3 @@ -227,6 +270,7 @@ 3.0.1 0.14.0 3.28.0 + 5.14.1 3.7.1 3.13.0 3.11.2 @@ -236,6 +280,7 @@ 3.3.1 3.6.0 3.3.1 + 3.5.4 2.21.0 2.0.17 2.0.17 diff --git a/sdk/dagger-codegen-maven-plugin/pom.xml b/sdk/dagger-codegen-maven-plugin/pom.xml index 5404745..960992a 100644 --- a/sdk/dagger-codegen-maven-plugin/pom.xml +++ b/sdk/dagger-codegen-maven-plugin/pom.xml @@ -78,4 +78,21 @@ UTF-8 + + + tests + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + diff --git a/sdk/dagger-java-annotation-processor/pom.xml b/sdk/dagger-java-annotation-processor/pom.xml index a640219..eb71380 100644 --- a/sdk/dagger-java-annotation-processor/pom.xml +++ b/sdk/dagger-java-annotation-processor/pom.xml @@ -37,4 +37,22 @@ javaparser-core + + + + tests + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + diff --git a/sdk/dagger-java-sdk/pom.xml b/sdk/dagger-java-sdk/pom.xml index b28b956..bc41b55 100644 --- a/sdk/dagger-java-sdk/pom.xml +++ b/sdk/dagger-java-sdk/pom.xml @@ -144,5 +144,29 @@ + + tests + + + + org.eclipse + yasson + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + \ No newline at end of file diff --git a/sdk/pom.xml b/sdk/pom.xml index 3a7cc9b..bb7b189 100644 --- a/sdk/pom.xml +++ b/sdk/pom.xml @@ -202,6 +202,48 @@ + + + tests + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + + + + + org.junit.jupiter + junit-jupiter + ${junit-jupiter.version} + test + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + @@ -218,6 +260,7 @@ --> 0.21.4 + 3.27.7 1.1.1 3.20.0 3.6.3 @@ -227,6 +270,7 @@ 3.0.1 0.14.0 3.28.0 + 5.14.1 3.7.1 3.13.0 3.11.2 @@ -236,6 +280,7 @@ 3.3.1 3.6.0 3.3.1 + 3.5.4 2.21.0 2.0.17 2.0.17 From b4a089f978b2b1042d146783aac86bcdddca72c6 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 03/10] codegen: gate nullable object support on the schema version 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 --- .../dagger/codegen/introspection/Schema.java | 24 +++++++++++ .../codegen/introspection/SchemaTest.java | 41 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/SchemaTest.java diff --git a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/Schema.java b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/Schema.java index 6b04910..fc25676 100644 --- a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/Schema.java +++ b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/Schema.java @@ -8,9 +8,13 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; +import org.apache.maven.artifact.versioning.ComparableVersion; public class Schema { + private static final ComparableVersion NULLABLE_OBJECTS_VERSION = + new ComparableVersion("1.0.0-beta.10"); + public static class SchemaContainer { @JsonbProperty("__schema") @@ -69,6 +73,26 @@ public String getVersion() { return version; } + /** + * Whether the engine resolves nullable object and interface fields, which lets generated methods + * return {@code Optional}. Before v1.0.0-beta.10 the old lazy shape is generated instead. + * + *

A version that is absent or not a release version is a development build, and gets the + * current shape. + */ + public boolean supportsNullableObjects() { + if (version == null || version.isBlank()) { + return true; + } + + if (!version.matches("^v?\\d+\\.\\d+\\.\\d+.*$")) { + return true; + } + + String normalized = version.startsWith("v") ? version.substring(1) : version; + return new ComparableVersion(normalized).compareTo(NULLABLE_OBJECTS_VERSION) >= 0; + } + public Type query() { return types.stream() .filter(type -> queryType.getName().equals(type.getName())) diff --git a/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/SchemaTest.java b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/SchemaTest.java new file mode 100644 index 0000000..b4a439e --- /dev/null +++ b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/SchemaTest.java @@ -0,0 +1,41 @@ +package io.dagger.codegen.introspection; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class SchemaTest { + + @Test + void nullableObjectsAreSupportedFromBeta10Onwards() throws Exception { + assertThat(schemaAtVersion("v1.0.0-beta.9").supportsNullableObjects()).isFalse(); + assertThat(schemaAtVersion("v1.0.0-beta.9-dev").supportsNullableObjects()).isFalse(); + assertThat(schemaAtVersion("v0.21.4").supportsNullableObjects()).isFalse(); + + assertThat(schemaAtVersion("v1.0.0-beta.10").supportsNullableObjects()).isTrue(); + assertThat(schemaAtVersion("v1.0.0-beta.10-dev").supportsNullableObjects()).isTrue(); + assertThat(schemaAtVersion("v1.0.0-rc.1").supportsNullableObjects()).isTrue(); + assertThat(schemaAtVersion("v1.0.0").supportsNullableObjects()).isTrue(); + } + + @Test + void buildMetadataDoesNotChangeTheVerdict() throws Exception { + // The engine reports its version with a + suffix. + assertThat(schemaAtVersion("v1.0.0-beta.9+1c6e07b1").supportsNullableObjects()).isFalse(); + assertThat(schemaAtVersion("v1.0.0-beta.10+1c6e07b1").supportsNullableObjects()).isTrue(); + } + + @Test + void unknownVersionsAreTreatedAsDevelopmentBuilds() throws Exception { + assertThat(schemaAtVersion(null).supportsNullableObjects()).isTrue(); + assertThat(schemaAtVersion("").supportsNullableObjects()).isTrue(); + assertThat(schemaAtVersion("development").supportsNullableObjects()).isTrue(); + } + + private static Schema schemaAtVersion(String version) throws Exception { + byte[] introspection = "{\"__schema\":{\"types\":[]}}".getBytes(StandardCharsets.UTF_8); + return Schema.initialize(new ByteArrayInputStream(introspection), version); + } +} From dbd13b5285c2e011a972f373872dfbef4cd3b9eb Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 04/10] client: resolve nullable object fields to a rebuilt object or null 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 --- .../java/io/dagger/client/QueryBuilder.java | 18 +++++ .../io/dagger/client/QueryBuilderTest.java | 81 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 sdk/dagger-java-sdk/src/test/java/io/dagger/client/QueryBuilderTest.java diff --git a/sdk/dagger-java-sdk/src/main/java/io/dagger/client/QueryBuilder.java b/sdk/dagger-java-sdk/src/main/java/io/dagger/client/QueryBuilder.java index 4ec9e10..6b603dc 100644 --- a/sdk/dagger-java-sdk/src/main/java/io/dagger/client/QueryBuilder.java +++ b/sdk/dagger-java-sdk/src/main/java/io/dagger/client/QueryBuilder.java @@ -206,6 +206,24 @@ T executeQuery(Class klass) return jsonb.fromJson(value.toString(), klass); } + /** + * Resolve a nullable object field, returning a QueryBuilder that loads it via node(id:), or null + * when the field resolved to null. + * + *

The field's id has to be fetched to tell the two apart, so unlike a non-null object field + * this cannot stay lazy. What comes back is lazy again: the caller wraps it in a normal client + * object. + */ + QueryBuilder executeNullableObjectQuery(String graphqlTypeName) + throws ExecutionException, InterruptedException, DaggerQueryException { + // chain(String), not chain(List): only parts are walked when reading the response back. + String id = chain("id").executeQuery(String.class); + if (id == null) { + return null; + } + return new QueryBuilder(this.client).chainNode(graphqlTypeName, id); + } + List executeListQuery(Class klass) throws ExecutionException, InterruptedException, DaggerQueryException { List pathElts = diff --git a/sdk/dagger-java-sdk/src/test/java/io/dagger/client/QueryBuilderTest.java b/sdk/dagger-java-sdk/src/test/java/io/dagger/client/QueryBuilderTest.java new file mode 100644 index 0000000..84ed7f7 --- /dev/null +++ b/sdk/dagger-java-sdk/src/test/java/io/dagger/client/QueryBuilderTest.java @@ -0,0 +1,81 @@ +package io.dagger.client; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.dagger.client.graphql.GraphQLClient; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class QueryBuilderTest { + + @Test + void nullableObjectQueryRebuildsTheObjectFromItsId() throws Exception { + AtomicReference request = new AtomicReference<>(); + try (Server server = + Server.replying( + "{\"data\":{\"typeDef\":{\"asObject\":{\"id\":\"ObjectTypeDef@abc\"}}}}", request)) { + QueryBuilder resolved = + new QueryBuilder(server.client()) + .chain("typeDef") + .chain("asObject") + .executeNullableObjectQuery("ObjectTypeDef"); + + assertThat(request.get()).contains("query {typeDef {asObject {id}}}"); + assertThat(resolved).isNotNull(); + assertThat(resolved.chain(List.of("id")).buildQuery()) + .isEqualTo("query {node(id:\"ObjectTypeDef@abc\") {... on ObjectTypeDef {id}}}"); + } + } + + @Test + void nullableObjectQueryReturnsNullWhenTheFieldIsNull() throws Exception { + AtomicReference request = new AtomicReference<>(); + try (Server server = Server.replying("{\"data\":{\"typeDef\":{\"asObject\":null}}}", request)) { + QueryBuilder resolved = + new QueryBuilder(server.client()) + .chain("typeDef") + .chain("asObject") + .executeNullableObjectQuery("ObjectTypeDef"); + + assertThat(request.get()).contains("query {typeDef {asObject {id}}}"); + assertThat(resolved).isNull(); + } + } + + /** A GraphQL endpoint serving one canned response, so a real client can be exercised. */ + private record Server(HttpServer http, GraphQLClient client) implements AutoCloseable { + + static Server replying(String payload, AtomicReference request) throws IOException { + HttpServer http = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + http.createContext("/query", exchange -> respond(exchange, payload, request)); + http.start(); + String url = "http://127.0.0.1:" + http.getAddress().getPort() + "/query"; + return new Server(http, new GraphQLClient(url, "token", Map.of())); + } + + // GraphQLClient sets no request timeout, so every path must send a response. + private static void respond( + HttpExchange exchange, String payload, AtomicReference request) throws IOException { + try (exchange) { + request.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] body = payload.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("content-type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + } + } + + @Override + public void close() { + client.close(); + http.stop(0); + } + } +} From f52b1ca1de12d1e0aa0574380319f4eb5360b994 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 05/10] codegen: return Optional for nullable object and interface fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- .../dagger-codegen-maven-plugin-0.21.4.jar | Bin 67901 -> 71585 bytes .../introspection/AbstractVisitor.java | 58 ++++ .../introspection/InterfaceVisitor.java | 57 +++- .../codegen/introspection/ObjectVisitor.java | 40 ++- .../NullableObjectCodegenTest.java | 273 ++++++++++++++++++ 5 files changed, 417 insertions(+), 11 deletions(-) create mode 100644 sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java diff --git a/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.jar b/prebuilt/m2/io/dagger/dagger-codegen-maven-plugin/0.21.4/dagger-codegen-maven-plugin-0.21.4.jar index e4c09a036e2ae6e7994879f652fc651716b912f1..84b72202f3b6fccfab98a7fd1f2d495ffbbe34cd 100644 GIT binary patch delta 21684 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%0DCi8UARV8 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

    6cokn#qeyE7Apbv8BH% zMues$0Dm*S8ml6t7{UcgR*(^SVxylM@7*j~F>$vNXzDYu_Z7#lCQIWC^M_yFY z@J5;wNe}h+Nym$hbmO0>!Q&;L)q00d#h(!&(l*yRY-i~tyfH9F#~woRandCogn(BC z(JuoYucm_6ezPFAFL(V#cAia!2BAY7=1Xk=nNRti4|-pjQ&m`=*ubN1#jS$YUByY- z&vv>qkwrA6?VuSC;`B#a$xog`x`!GRV7bd139_WzP)zl0YYxWk-*)%gaBN|_>_l#$ z#$;PtsVR4BuLEi0C<3mGZOlVysEg#R)UVO$*?XF|5wLP_Rh?@^&KH26m)sQmg3=x^ zi;v{)vkJZebr^SA5$hOqiCP3NfB#ue!m+a)LpWU zJy;(KW2TW)>n&i?o>^&7NjixWYA(nNIaTs#^=tXwFohyFc>kj8wGP0EpBIb5g z6EUxjqRA}6BF#-m=LS5bXxZr`5gGj;01PM-d~Arx8CKVr4gA=WLU0Z=pt(5u&-Aud z>*z&=nsq6bM~N~h2zeULa6c*08`N?S;mPwY!MlO7jOKHS7a?d`M4{gqZim^O%38Fg zlW6k^r!_LY0JBix{vKJlPiE%r|3ZDP5F`Jp7{}?eH#i&lV^M)YU#QwBFC#P;DVSSA%bZT+SdCxLu(R&963NGu6iK9PRzwn9vLl!^%@ee$ zxG#shv%eWk6W)pc{`E6D)N(O_A?VYd(>c<8*bIoNfx;mz_&ZB|@#T5^ z2JL!X^u?H;1fJWb6E&sg!ZyZ!+YQwLm>`dPS;yvGj#j*Ax504w_XHu35?2Kll=Nihm zSx29W|7?;Gw)WkgO-{35gs#@D{J>B39~w82-7%?q(*6}^ipDGAj1Lfnq2~3r1hu@Cnnw4bvHfNlp&CF_yPncuI^iC!m2#Vqxp<^B zAO6d3PT;nNXBy9RR;5}07wM8L3eRt*E*xib{4K0`-J#ijsHpjS?Pjm}C3@&l+LeW4 zD-(m0j+$~X54ok00CZje8WVu#IE9mp-kh;`y3O%w@=|u;lQtJ3&-2#jVw5b$cN;B! z=^Hb+VkqDhyWho83M+Dn=$!p&(Q;`c#wh6bWWUq2d$xp<{CvFIDAF8hqkYQK{&Hd2 zEIZ$ee%xLt*&!Dj{;{ha$@6`WRVgi;zuk>t?dNBtMnK7j(wKL0MImu13U~KQkQxno znPqxa>EI~p#pd7e^5?@+XD($;KlH1{gYL%EUW5aR=Z24sJoGgt?n|*KmyU}$%je&c z?N1GbAC_kurW{Hw$z*Txp|?DyjnvU!CGJI1C_6c;Jf;h!i6XFW#`dtHX{FvKJ%z~? z+E@2Osn+YVJC82kk0c6nq;^wRPo~l@RZ+i0RASb5v}{(sdN=RQWp2J8G8m15A3}5H zRs#XYDXk)-Ny`s4qkLb1%Dshbdv+Jc+}d8%;@H3lqlNXHnJv<&U4tX9-#5^G4mpWo z`>Tl*L^G}fT3J{|G47;dvGg-sy&3Z>T1mMoeQl*v#UW}f&h9Ut!8DbVid;(bUvn$h zk|rQ_gI-$|#;v{HiiW*&at)!o-!|o1+=qaR>s^ViA8J2mANl2t?Uh~uqIEl=0|9n@ z^06YPVek(%@}U}fZars5g~J`Ql=1hPRHCPu2L?O)=H7Kg5|b;^w@vJPI^aO6zB~vX z4%$_yy)cU=VF`&mt)RQNaFE2dt$5C-)U_h=eX{zE1u9`p3mVKqPe<& zs7LRzo$riBkXLF*%{I*#qkXzUhdOTn{RXZ?F6yxU$9|biRNm{|eIu<*U#G6vaN1$1 zFwHjF{{EN7dcy~l(+LJLKV6!rzw{z*&%#fQ5iI-|+u}#krYa3d{HGq{q9FB7r5>@}~E z{;WR$KH$)Q>ipg7PUW16@v3`0K?uL?Qj`Nlspqf62Wn0&ER2=6Ub*zf=X$u0+@fEi z!g`o^U>%OgIbU(O69wiA>R_hVAxj)p;i+qquf0}e*3;rWlDIa6CS2luQiKsek4Nq& z87bO@`XbE=~|25Y`Ub6pP`V##`zCv>@@3C)frcpjvqDgb-%FjrIUFd;M$`b(%Z zQQ_RgUg&mfh&8^ch06Lp443-~YKUi27MpQMy5lpBw{V85j5d3_Ceb$Rk@(cp3Yeo_}G+3$prf$62;P4ocwm$8Uh8?rtU!ojE3wyYv&YXEM=VkLE z^Y7AT-|t-xF;+t*5Z)HDVcys?f2=^)sjBZGkSc!F(sX*f(GRe=T>?o>r*?BQKA`E!Rtt)0h2iiTfk#9u|N zkZOgJy;cmscU@(ljnpoem5YA9e`^+5w&)cQC)v{bbD=%0X2eJ${MY#vNkdHXB)R6E za>G0JtgjI_Q_!$B&V1mR#m%Zl{&I!bUhV)j-p*+CrrWFdopX}57K=NRn<&@LE}}-` z2+53=*OX;)9rJR*tw90t+S2Z|8*O}fETWb9?meI#*AIR3LmP>|5gLwQ)3L|AO$`o7h@=5lF{+oMolT%q-qAz2Rcz{2}(C6#rsN zD&EP`c1^kbDiHabs@5fOfasUk7$hqzDcER`08WzLvzDnGIryV7qSdp|r&i!#@Ng!5 zogOiRtWZrDu}v?_cq6 zrZk(b`93a(eC%f|(~nHo?-i=jpA*o#_6g$CD^b7abfu+98l1dG9z}bVsE*2-j^#x8 zRS8DNAK5!Ziw#sYp73_hC8cbZn1$p*<6#_@VN9#8d#rwNEvw$2pi0{b5+)MYBmPXt z4Lm%J>(4qFRPc!m;N#-lW$5BGBS3z+Yq^97to~Sqh+1yA-4LaS6~m!h3Tu{msySGD z)v=Sng7O!}(~_qum;=_uQQ3x#dg(N3OSyREx;zcx_J_8_A{mH}`E1rQt%-qGI95OD z#wQ`hJQdlW3ENY)Bc_@K51=ct#oVe&u}A4_S+CDeNBQJ{#~za$1SuJaRC&j7v{(ws zbgF4vZ$L|%yqjAKnv^4qZUVXY@wa>bK(%=@1-D@Qdc;x(ceHAnq_xcjdB7m$U0!}= zl?Y~jsxb4YSKW6cb$wrZZPllY?xd$^r^xrHvO%F#0Ce>v>)_nf* zOi(X4C_bQ?;)8P&!JSX~tPWsRtA3&6uF1fkn@fU0KqyK@l%8EFi_EaCUI6E_c~xcu z9Jm%k)Aoz*6O}btd~rF^rSQF4T6r8g>TBTG@z)g!pQrAs4ToU8hvEnG%H zDa26hB!V~acxzrBLjR`Q$BTtMLQ<~8;Gh{8IJ=?BpdA<4}JqNUw7D~$9khSSz( zY;n`V0GQ%*Yrm4+r*u~UZpC_nKhBMqo3Fwj6vv_d~~;=RT#2;il5NKBK* z=?g1KOJ_QTvWlPImaLocpK|7H;5IP-_#2UF1*kk?lQKFeNVnPN#H>6(^_ZdwzC0}T zg|lHx=T8DJjGyj2r-93Cj5ejpJSyqr=p37~danP_lUMWhH^_AOufgt$R&qx&R{R;P{y<8W@F3gPL{-;6N84si#(2jD)@a1y&+hNgBB?Tov6Q1pEp~_ zN2xNjrTz11r?uT3&iDu~@&3+$Bs39#7%Jf>i4day^{Pa1UU`7!pP~dFv!R-=q`*!( ze`DNTkQ~_j*&hr9NrHP!|3SjI6_7C4$NZ0e(d1TwJP0HLR)zgDrbq~afXEZ-Kp+P2 zQ`^5M6L27qDEP?r4{L)#;$T+*RSsuQC<23I!06Cx=1L&H0TKgCMqaZB%I%6A`oCUD zcz9yhos|APa)V_)UW@J}e7ym>2mRkqIe2)&|4OdUxxtV^)L$8*f15%fn#phfdo4Hk zy!VeE(KoMS`iTAGG=PRA9NYv6fH4FAjH&(v0sWKp1L5Jlxn5Uf{x$K(yvw&gR`PFw zc%lDECjCbhI`KzE0lALNY3&a?K|r$L?)ASpc8Vxr90KA2SMUA*@Mgk&{Od=lcl1a3 z7yh+}0pa~08bLK? delta 17989 zcmV)AK*YbHuLQl41h67Q4RIj3IWqzP0P+L?05y{#sw0=a4FMH@l~iqS5=&nxHk(U`+h7Tvu2FcVur{?Z47QYvKogfIrGOyO3&#<}jDr zo1LARXJ($A`|7spyXi% z9wA7Beb7&g7NO~>M48xPC*e?z1)<(a^X)pzc8MX^Jgnms!DB0_g^_zl6G?cI9>f~9 z8=Wv72NN+8kx@FZ^-W@~%N};w(qWW6R8S@4-rF`2w(4nrUX32%^}==hZ>tGeOB!Ll zqaykIVBD8_PxMEuDR<&fjCw*VyPqkuW~dV8G&P?&PTGuMgdRxqP-!-a^%0@^w`>W^ zGUAvj8W1-BojdpN(8eY-7A445YaFNBi+OM^X2t`dmc|sJ^yotn?&hKHAU<>6r|%W@89cs2#AYhDQfFP3_5l|CELkNN-5J?OIDoken zBm==*;EKa&Ye zG85gc;QZhJ9q;+P@ArP+&zqked=9{=^tKZ==_j!7IjYz#W{#YnNC|KwZ`eTa-Yu&Bc7I%32jDAmJ>n6`u zPkoSQ_xJ-weY+>tm)7U)^F*RXtiIWNHdb~y;lMZzP925N33IZsSssiTksgoNNC~O) zBqQR98J#`$6}aoiv98Eo=}?jy-& zvuB01n)y%13=K1NRA3gNA{RXfMZHFBi4pZi{9)-OOm=6~tpu}mxKT+M&$B&Y<4m68 z3$Y%@XB7H~E)CVJ^HBGy?oBL+6Sb(*P_JVSPGDNO>B>&?_IUo%*A{i3vd!gbo<$hTbDO8l3TN`YZRqV z(b0fLjxtsxIly3C>0X}$fiYp?R2_?O8etrJda2(C_?V3=>Ew9KAEf-_nBd?*<%pBaFlE^4u zj96dDXU6mRe)W=q=%O(M^AMes>jYir>Nro(rH;OQo~bEQUq zX#YaM${?SlW1kKKJ%ou7BN_^9HC7w3cqC{hmgTt{e`GLAQf?Hb`*irRiSZLrEEvMA zqiq>#-I$bVM?gov#K{DXlhwwSxIbd}nj*bX4Ix5x_Wd&)k0o=O16y=Nz)}`jmSIAs zSyp8?OB$(Mj8lTR;C(BhX{@v}pnf}lc4)Xj$A$P3C$r4V+dUCx%`t5PU+YeS?24>D z87WbJK3EPDB+Z#Y%y4n3c8QKlWyhM@@7ZiLhk{;DY>hwG$E|3LDMg*wh08QtE}PF4 z?Bg*oA#{GRO=l$0cj3!AzJjZ`Hd1EgO2(ZN4HYI|)o~53HFuTNDB-M;{4$(>!WaYQ zYdWr%L_dLZ`YKgDcmfhIGB!h`xk(KBb;bURm}-RNbBm5!aT~J_8ip_0vfXPa893;w za!x(#G~CXm+K?!!435OZF~gUlD5`uz$DO##G;C^suq0ixh|v>Z@}rl*8I;lRP4@E8 zZs}#D#v>7lfR^osH!iGFJRP`yhj6q%RA;JJ=T!x8oiQ|iFK?nt)ZkfhBeEm4Qp5ej z3NX_VPCS4IH9Vx_VeBOwms#rOfF~L?E95**kA!s@Tspdwv+RH=4hqNL7ISYBAnnt! zUqGh&qf3l%#NZeS5SC5LDajmY9BbtLc^O(*$HC<+Z#cyno+kgWS zng_YI9x)_`7OE=m86D5!Ij+5=%2Pw5Y+Pw;$57za;sufFJA@e{N|icAxj?u?T>i3- zSMVwe)@?*%XT*&tht2#k6fEiPD#PnK-jFko?v-v!HV3{-I8kA+*4iP;O|+K#`@?36 zSDq;k^^{+DdcYstTwc+Cc~PBHbb4FI_waqLEZA@zaXzS}4oieHKljdmg!vr!Axo`f zOiBG??%9&oBZb&cb^L=AVp{v^whgOb%0qI@%wT|E5w|unl z$`&4pd-+U}lCvgzn9JO4}@KRXWwKH$AN$26KAZ?AFWG=Uiqoz>90!af7kIR z*;F+)b2BrRt?)l}{1^Vr*4bY#0(Ny(7op1H2FOT%B1$2)WX-pYHX-)G1X z@_%*w6(8||nEhjac=`!t!w!pPuUL^EoQ`bxM8~K2jLRKQI2_oaiiNS>RW+t_=!pj{ zajiyUA%RFEn@)LT=W1{SX?Sbv_k;;2yGNxqvNpn}KqpNO5e2c39Glr;G)^Zc6`I>o zs@~#sdwP1hRVU=`z*rA0G+w77*&gLwEwz_}iV1VM6&EajM(dJ5>P`V_Xe?-KvxeQ3 zBB)EJNrEh?aXL6PD>%E5Mc;ok0_o>U1=fu?{_< zNQ=kY$EWR#E=Vn^>~hetgjp$7%6kNwqCYEWsamI=?n(#o#Mb#SB=u|6k$a?#kVp8Of3Q$+|SZ7zJQ?Jt;5yr*B$XJV+*wiH}4^Nk$ z6Lp#=j0*kHR5Uth0bxM~8Sd{c?G!r>P zRmp~_fvi=dLf1hH2`8C%1}PGCX4twb>p0%sTe{0@7ctFk$ym$ z(?rh#(@YL(;_$NQjU-2!1g&+dB4QtxP_s@;F)DY}_A9mHZJkc*>0HiA<~9AQcq|-` zWh-{A%Cw!%*J%T7G%s~H`LYE};ske&(WS_LbnE050|}Xy?J{imlJQ{Z)I+^&JX1h* z!$O!h#;bfw;vjX)&nBHVO9*O~Lmb50Q%N$jaej7JoWPl>Vs2}tOrK7$OD(_xw)%s{ z%6Nab5$Tlugrm4MWdUxFxPKaELO}Dkq-@iKBh5$ z72f72XZoPU#iJ2qQfw?frmLA`hW5*Y7o5qO`h!J3hA+5~xY#%^JQu+v$q) zw|T%Z=R1e)Bs98=16Aq6%$Ki9yF$=B$*aN9=qq?|g~F2wwPU*GBUI(STeH-Wv`gIl z9s7g;AVJ7r@fIlUnUSp|WELd`Qx~r3Q?1jalP)xqzhfR67%zlu| z6>>W0VTJ;kduuU0a@g0<3>WQI*Mxj}luvH7Pp5Cuy~XnP@ey{xt_cP7q|8m|bT{2o zOoN0uV>E`)IOwFO=^2fl)p03*Jx8b*V&xBR4Q)287&0%)mwK#3eOtOr)g7IYvK)QwXeQxEULFh@5tWM!P9K^TiW+8neT#zNwkGqRpva3 zP*l-zP>kYCOIllLo0{#Q?X&}V09`;AG9BR%;t^RnwW>M+t*xpif$`FuxU#C2n?nqz zuvoxRELtfFaSSHnSnivDmd1Q)iuqLf5?#d13owx`rc0Q`L=@1a+{&Y!JZ@+8cJZ?i zx~ZsbJyUnGV71kQm^_H1S%UIG9G~|rsuHNNKa06*^B%*zjs#9_`S_vjsd=tGIPTN}(IqC<$&Jz_ikQ*FXYyxOU6W-L7x(g!=}uCxJ(lRF4jzkf%JB zz!Pg~O7jyKOyFsMCOv`u^!z?HVV-i%#W;?CXRyg$nYk zgcaKS35OU}d8tVPVENZhCeY|Qju;2`?H8aO#EU?i3AvY6)(qk`*PD#Bsqp7{pL7i3X9@f=fe#uOZojgI1sVzb&hUwtTx$MRb zXu$UH?UD%*iARmO`K5L;lUg1Fgw#;Okby)k*{1g9kxTb3R=<`_i1=Q`K`-x z7f+BYSJOB41&oYw?5>S9X6&Zh|9sbfa||@tB|&+U=ImWlqXPGTxHvzEkAVX?WWxc< zS9Z03b8wS`!>DF>?A0VlA3$-beG*MjeKb+gDYYM@$xMTevLS8GF++2t?`Y|pW}<|9 z$~$Ce52i}@%)`35F)Klp1GvO&)g|bJ1})W|JAm<}ngZb-FunI+j&ycdo%z`Wud&jn-Atd=5=JV0%BE(Q zP$CzWa9j4tt-M8T;|*;)%5VW^(hE6bUc?#mVw{3YI1^sZA$0}Xa3u%URUAxLBZ6yx z5XH4T>pJYhjof<^Zo$pClYbw?ZFq#APvCYA=R5E+KVQdP_z?%!d$^l(={=<5UYdsc zX$~Hsd3cac!9%nJ57P?lrE~BI`S2(O`5D1J+REW~As(YE@i<+NC+Jo@N%tbb^}ti~ zBxluwjM`UlkiL(n=^Z>n@8dc8J)Wn3zu*P>6wll2c+oZmFWXA-ime*2+UoI|Z602? zorX7TOYxSi4R71d#t&>8@I#dbdN|DALqFZZfv&N%tLav5IhgJOx{WiqCQ+J94YrLc z_(}Ci;;^aK0Nu{1q0H7ocX0N%V}@;!%KrIiv6ay`=uR#P?&9T~nXR7P+#_j!qYg!% zA(G7NO*4<3^a*M>do=O?AEE1GG%fmscLcibBTTf_6fr-8B{nN2NlaYfU_QrBHOFCs z&Snf$D{QUZSFM0t#INU>Nq-+VJ#8|~@beAj*rPcW0YAZb-UdtXGp={u;qCMnsKGCl zfyzxLsq8pkDNZ~-j=m|`Q7K}7hpl8H3lw3XnBuit;)w`-!q=bVn_kwv&qn$njZaaE zPm$VGr9`y&@Sj^5Y$o+9ctqCMn)+7Wj`7vW;Lo@3HMN&UG9dHw_S*NRl%A$i{;x0@ zzvf8zEf;vd<4WQWTtEDgBjH0O&=iwFN}yU(aiwkn?32k+SnQMomL<}E8t#=9WmS_X zkgp<_xd*u#<83#lLU^dgEK=HD&voGq%;@?whGhywP%UMFVw;@!EGE6YPG-%P$~9a) zRGw95tj>na?NWv7PLBOuTrXXQFku2vn{jd(S2&|{j^8bTXs6YB0QjeAEM`Z zd@p^Q_N&%o^aMRcud4DkIG;D?GJ1_m=MCsm&t2HAp6}x4@}$@4O?nHZe*;iU0|XQR z00;;G001FaSbrjj!W{qrH%*fssylxNd>hsM|NfrjoFX5IBW2V{lPJUqa^jK2a!5$x zEGN#i6O*JsWch3>ktIcvv)FsDmSt#Jp@bG_fl^`8vPvms6zF2LP&S>6w*R&e#{c); zolcT1$8w>-2i?1O@BP;M?K|_tr~4iNFh~5+4FQJ+r;Z{FB2)h&UxHY0yt9gMaZ zZAPRz6iLRTiI@>ghN6+``OS%BJP=H-4<$m$Xq+(Us8A%7oJ-i?ukUYjL$KZG2+XeE z64)N7j_~T%P}rzm9Z0rk%>~;7@r04At~IZ-%h$W%!e9+<9mUWIhxCVLsS%DD@dUx$ z8jW`Zk}Crp2BD^Z`Wkn|jM;zXCMn@Sq^-Ik84pF;X1g&2Lp7A>7zPhvTrk>E9SiV^ zWGG%OSI44Eu-x6ipa^;}97rSu zBa*CM5p4;zhK%@NOoCrWIi|33uU@yHzOI%qvA*AZvnA6NIx11c8o-RCAFv{>^lvHv zK*5~2FpW{H=``ZItSNstW?-g#xf&g_rR22LYwOm}Z(QWY9L&{lw2pb0-zQ;mNEtqiC64HD#BOUGt_m&OraPpb^H@=4Fr`ySrCg{$jBiRA2Jtif6h4LTaJ zj$KRcVyHT zp5TnA>0A$H8NYvqK#uz{)j*0gsG|j}=)+i~4V@ehLTOG3%lg%<0hnSNAGb0^Ddmuk zEz(VEEM*DEjm-$_=m5LmLB_VuK$vAb!k@>BZFD1sZ5rY_5@2?H`3YEIB-^8wsd$3X z2uI`2APdk|y^20nF0(a@`t9tUL(wWTq*MjNA=Zj2Bhr5siWm$%QdP@8W@K?;Cp+k% zeecF@oS@-E9Vg*rg1=CtrgK~!h_iT-^7iyE+0OII;Z(MaWP2!4m0{2~$Cu0P={nB9 znI`=W$v{%!Bdqy+aVjr4XA^1_IobY|35pmyeA#~ApP{gdRc=W<5NlskU#pJMUtTV$ zK396L^EiKVC_pikGrB;>g}8`Agp6V85uv`XGs@aK0GqjlZ6+9wCJd?cm+AOAzQGwt zhk|9R$=m^ir948^a0S7efv{+2(1@jv@+uuyqsu%>`vGBLHp;ls8fMX^v%-QAOZeBy zJm8yz)t@Wy=T(jC2%~yvYL$|uzubiz*c*nUffj#L=N8AK9d!#`xQRfGZrqG-Yq&+n zUfg=XoNRp{9tt#v4Kq`4uwD&3hs}neB=TJyx8ZieP?mW10z%_~>^}$201x4NI=+uP z*%`-;WM@2*IDi`W>-h63*xk%dMk*>bl8M+oQp7(b%*vLTdRjnQrQzNKRv2bfxexm^ z{78S|`7vQ?{?)dN4ed5s(pw4J`jSD0hE4yil=7lvoqa&rdtmo&7ak%Asgpn9YpBJA zpR%W`XN~m*BD;K1rMAB2UA|B<;j3Hd>rAks`66sK{!mLzxo<}(+0H9{p-7k`l&>`& zXp^Q?<(B9DGg)q&>B7%hX_GP@@5U24o|JzsyQJCRkjBA`OQrDQaCCnD(#9 z0WBq7Ud2wmj#mUCLBqeTnSuS19ak`r47U6HwsZ0s%A5aM$8RJxhdbP#D#x3Y_1K_6UU!GA%=i!%KfVnkYU!2gbg zD9<6A0T*88^e<_hvkQM9tgDS192tFqoG|6%LOkmf6TxWABqiBy_}W6-jfhV|@of(U z@}8~>uMwtM@;%TIrD;*8BI}Vibi98l>yg2$*RE<@RNJ_SRqt)ik`{HulDm9a36vsn zS;MN8l?!|=MuJs15Dx7Qm;qxr{;1y;?eCPUQoxn`xj2?>K4vt_oU%(OyHKl z)<8TNsNRy`LcBQ?X{ioGBGIG*QoW2Pn^i2dV9ToQJ-n~s16dsXog;L9qndwj!!Xmn z`Gvcc+^tzbNN4V|cd;eV5S@mqo<>9C{KmRk zjfN4bdJ~Y@WD<}K#zkI2amG3fjYg!)IrFr(8}T?>2YV74B}K`l(44~6HD8dW(HstQ z$YhH(0v(FF)v`s%WU(*?#nf109>*mKjVJ8xGn@OL7Ze&zAj~|VbQgc-IvMF~*`>Q^ zB1f-aa;HX*lrf|w#G?j1!FOOQa zL|LO;#MPgl!%z?J?k{VoNH1bld?SS~s$ZE3nRz!`*t`!v9` zRVdS&rPHBw7-wr%g4lmgnLgkS2pyr*k+K6am_xH_q9ls3hGy$@6wP7%G1ZDNCZ~sC zDO-cO%=UojXr1QKd?|lJc0YVM%Ue4mW*c@j$Ndl+rZKyiYH6WHi*#B{OIUt=z-9_s zLt#}AAL&0Z#J=g1xaxFTCfOX6jLQCuM)ibA{dJ9%JlwRLR_cGWidJ)+VY#|s@NEjyO^b#vny!uI>s=IM4DvTf)Jn#j1c+)O9vbfSOkFBY?LM|T+U+CakaK+s?p zox(0jmPZ<$#$G9p16dad#CNTVnGIJLok193x$Cm}sz59j-sP8SS1#4)ES=7#b6Abo z4RDa<<2Fe()2V`v$4%!Uq|y28KKff<7h#jP-W<_+bfHccNgt$HK1j}8qSK|4aY@jk zopPgDU`l^?^L3rRL6@^oOb?=<^{BoJ=vbI8g_a{E{mNB3T}@q_2!=S*Z;bMRb85p- zrxfgGHcB~OB31O8I(>_-BMgfhiD-B`yE`d$Gse{w?zj3Uoo>+SM(O*dl-3&CI$1BJ zy(To#`rf+lJua26~*+1qSCs3%74?11&fd^> zu#+zEHl1##JIs9DZa%O}nwNd}>LmkLGz01%(f8?2jqcLvZkcMAMdNMNfmk5eZZPv5 z9h_<_*fTB7TF2zc7tX;zEReLv3%-}SHM)whTy{mSco;RZr;jqzuolP@*BoJJ}Eaf-pSP>QlbA zibXtuGxN@Hn6vF?IJDRvTAKGSb^4XuE#H5YNsV&RZ@7rG19DY5S7^3#hRicgx9KQohjKzTa$mA zX!X%xQuUCHhhe?RY3F|rt6b{C+d91?PjpBqAp`%a)^rqHOMlYo&oT;n*q6&^i%b)? zf*waHUh_Ad-j&;nIZCBpZ+b9DI-UiJ&UlD#>ET{=wQHXO7BVIX8U> ziF{y;okp-TX_(~8z3Qoi=c4~39BzNB_n4LrYg3~S+0HY%#raaOGcI$M>P3nwUSltI(SFWmd%y9_JpY94 z(JW~d3t6v?X3%Fk_6iU-4!Dn1@Mjh5h=h=Dr|#qf7EWCh$pao_>W53nu91Hw%f8Ma zOZxRzi`gXx6UO(o!CcIXidmDgnyiw5gLE-O65tIb%(Gn;w>3>ZFVV#?m5^GwnrZ|K zFB`H=r94&Zjbu*>80QEkEN5xHMWYB+{czobV|JIKR2QQq?RqG&(1>x0&c$7rV)>bBn= zvls@8>5|kLeAfBfB{m3`n5C*4+iSbTVJx?-+an0%c8eM@TN6j=Vvc{9+xPcQ%-e!+ zG`N*Zqb<=;LpOifm=Iomf_Xl3NLB31^#PsoBEmhsG=dmT^5TbCR;@nS6w+B2llSSgcU5 zAO=v=6B+qs2-p*mAzy2VKbrNPoSBmB-<{>lXUBd!%M#J zqPbKsA`FT}!dlw*SJE8i7@6$t7^YS&E%-2s#IuX}H;W2S><_d{nKOW~l8x zrxYHC*dxTVI#s^@Y?w*w2t-G7G>E{uWTB7KF$f;4bjec z&{!N&p*|;CS4n{Emli~$N%@ydG0V1H;=7!hQC0Tm4kd^T9VG6M$=mmIaWi$5P?t;G z#bJkJy;356(Bsc!@}#@g)X*aFLkTCPi<`ur648IHTAyFMTwG!wvro46mWUts@xA$a zX6*q<(u2CVLEKm(9wtm1AW7?8;t>u_WLEdge3 z>f)Q?TP5Nbs$2U7`vaEDGZM+Oy0}JMTOzJ?iC+^0x%t6w-Qqd%ye3}I$t_+alw~9v zifn(6ZZ#|?Wqy{kIAG;=S7#rAwa~=4ltX6NNR-t_qgy*;`Mg~p8xQy)OP~5^fv63H z!wsRNA$k3sTl`+Utch22@dxoL=Wo4M*DF(Fw1L(%L&Y80cTfHA*(>~^iSodE43IX|#4w8R}2@>sm*9a&7 z3%I6Ct*E>gbQh0>2=T95tsf2zBg7USO)=Mrts<=8xI~ADFf2KWikMpM65B)^4hE4B zNfU$C1Q%n2Gd#5$S~m`wnu50*rH=bCHihxd`!Q*Q<8B<%kit|?bvLGa4o}Y=+0cKD zqf%Iq!qOGItNuQ$CakQy4;(eRFop+TBRqu7HO`7sC+|$5xoPibuX|c~aeE3|yJ$l< zqMl9(bO+;`l)^60DJJ66JZD*h6wX;$QJKQ|O%>MCJouYD7kA@Q&*dpxnN9IEo^NHK zC^Aeq*Yo1sbvJfnPYU0$u6KCu>c)Q$ctvW1LrpQJZqNN`8XuHoJekM@-`+E+6yN_czeVU}td z-s=k9Th`i)EcpoD>7v>S&z~8n#i)cY8I|W$;rVMf-u3*$^MCwsEI<6We3*a2hberP zBGFXgr9n9hr1tD17tm{6F)LM(&E5v*-E@#tCNGuPGB#B&joeF~o)YoW@Dz=-ma$~K zRO+QMN)$BKOFl0hY~Pf5X|jFeFEYvB%!v7i#pISgXX(^h?Vx+_GqZ)t0KAH>MM+*f1EF~yYsBxA$En}#XBWVq^yr!t4v?xU@ zQnaSDsGFKH+MJ?|Y#&ccn=lQ{I%+Q+t8mlt+2_ZIdmx#SjYQ&T?t*s;W8=k!ygE~~ zRbCx@-TT=SUW$5YTQ?;nv-i;s!7y4ZS=2a{k<#vcbP~Wcd+FrfhjM?aeS)PuDU_8ExGX9;3aX1V<9Eq`* zgNfM0vj!%i9fu%^a_quXoX$S)0#x8~RN^XB;aXJVMoh!an1LT)I#M{44`LebXGjm? zaQqZU;Fk>XCDh<$%*KD~I0}El(fBLo;X};Fr&vIPP)kFwh$dqpO~n$LgQc_>b+ii0 z=or*f04r$=R#6AbK87`vz|ph=jkFu<=o~(V3(-WE;~2UL8|WTvr2F|m9>THo2(Nhz z$I%lA(DP`fR}rK)&_eIPpueG={)sL0Z)_EVu|*t&uqZ)?7>|F5n2e|>=le>;#B^*E zN260LM_jDLcCm)%)?tU(fSv5~cd=(bQJjdA#2Gk2oQ>V$LYyMHaH_Z-r-?l{U8Hb^ zcm!vPXK|Kz6K9JLaK6}&3mh6QbPT~oj*+<7F&gJO%5aI}5M1h*f|DJUxXdvPI~=oc zg(HM39bsJMNaBBL$7wu13)eW##r2MhaD(Fp-00YYn;d(w$8iU~?RXluIG)2^$1Awi z@iuOAyo=i%AK-h=LHNFNDDHHQ#a+$`xZ7F93N#Bpbk^Y>=SrlU$Kqb+W^_A){JahO zRAk%DF>w>zqEl?=7rY$Fe`J^TcIJdzdnr`vIVx;R6e zsp9^0#lPT{gT-0QwaJYGG?6peZ_#xYZI{ab{{vrVDPpd$iCLkD>82~YFobW|mPAKM^1<6A6`Tr-H* z3`)^=o5prwPK9!ghm|@NRQldN`hkjEUb;tmux?6u=|`sXk}KFX-=CtNr0CHm-jbr9 z+X;WtqY@=c>hUft;gwfjgL&CUd<~|TI=%E%HYhyaBouLfvhzzr`Ir3qfDmM{2#LR^4igI?llV`pRq!?7o+6#8$G5?e%|WB z>E`H~cg@Z~t(!U4nMNz3%mT^mohkYxc}Ptr?d+^Dj=< zy!0RS`;nJEW}-KD)2AsxoW(d5O5y0j(Y#g+vdOe)DhLW5s89;5K)BLEkvHzFn*)P5 z!6ZcQ!aNf@i(rB|H?&5Rusk`u?nRxP9G)HfcygAU9GNrO59*4P_(~Uia^D!veiVP% zW7A>~;~S**O_ge+;;O6+oYE9&0ao+)w`^R`VI-dC2>Jp?&KKDnUt(YOJC1&@a7U!bd()NfX53bg-C9 z6UA~W6UWdbaV$+1LH6Y_@{8S6F3zN>;$n^#S5c+-4poWUsaiZtGq{qUDPDh|S>hEs zRQ!bw6Mv^8#fMbm5H#B{lIA*mbhL6=t#H%H9AnRB_oPu5)`@d?-`uN8aB`8|31Qo{aCag3#JdBCOvwH6BFe##YpzW zZU*swws(_>V*X5=v%8cyIPEsYq#kawh~1``+?2J3ospQ5vr6MtT1r%AR_znha#py! zVkWz9uQ)WF!KeLODL*>ATO64ZM|;JBta@!Q105_?yR^tF7Mh8CrJaAYTaI%)n?RA3 zjo)QQqtiLwfa)-W>QO=~Fp*Zm&q-W4t;P|w26JdVCwNVq&~3m9+Q^C7CQitjISC6Q zP6iTOj-N_xIE$jVkQ2R2XdAAeIBw#E@4J-5z0}Dm|8_jc$=_qN6TjvJ@MSsyukrXT zIuY;DNof_5=U_*@_tSq$!gH3A;C+>TI4}gSi?6E`$B&xE#gWZxi}e^E@p9>GFYjQjIieVtL*hx+v~gFDsjtZmKF6oWs8BYGrPrNuc$NK z%9L)gydfo4dPRdN?kSv|u&k~5bt%!55*zNcdl2KK4NwRNvFd+~qcDqiD=IiQuIBJP zgF;FKx`{?*B`6WHX=jnJ)#`0{E2mUWO^L6`(yKBhHg}6a2E|C3tkJP>((!Q7X2?Hy z7)r-xr9NI;x?U@%kRvHcp+*w1gArfHiLi+{#JtOFWXzNb6R%j_v!~FLfCjJ+!*ITi zqIDQe4OvOe7@L29F*X5X6k5R|zd_t+p>1NcLZS5v!<6q-Q@&G`d^sXy+B-uO=#Nm$ zE_Nh7Qs_pS+m$PUjf9xDn>fKYk;wLr-09EoW@R)LQnV=DC)(IbR6Qwknhc|6g84FE zU*Tfw4_s)yoW-QyX3}pn>9?7@ncbs_e+EBMO{XblUUYxE4d2waE3%ZrCBDs&q|M%< zR_ztH^7U&d7QbO=-w`kIliS4Y;`{3UPIZ5`xJTTp?(b9gKN9zght&N~tmi)!k6EL~ z#Z%VkY4J<7=2zl1#!$m*>%$Q7Iv1X`n4_+{+0o1EM|e+O=>fV|Ud1X=qpoj=x5PUr z{eMtP0|c_ldkY1BPeAm+lYrGzf6>%xQ)|5yM3PiGZ&K(5Bp@oGijlmQ+0C$>xS1(4 zlS2QMf+G0PAJ89FdUg{nL?4!A=UnD|-?_}Wy8e9$;1Jt&lu!;(X`+f6!|Ftw3!Vy{ z@Q=|%#-5>eptSNw4CPLD6k-KofO-=RG#Oqi!^a{?q~)<0%S39fw715ce-Ts}&4=-+ zoQfB7(Kp%)rKKfOmwOmGgJKl>44a>4+N-G?DW{@TzR}uvk%c&hSA&W8Dy$cL;s_Hd zJ?7$X0RKSF=#w>P()tUcs-BB0VxA+)BcMIGpJihLTp*}P-8_TyU>!2~e*v%fC z25~H%e`lNz@RZ?YF;a%+drjT+Q{kMXakQ4S!Y-P)p$Uf58v2xd0zg zbXIDuxAPOhZR&dNeMFe8oS{9BS}Z=1QY-O z2nYZG03lfPqi%s23;+OtACsTgH-BXn|G&+#$>wXa-Si+mXd2S8o3veeA87+gn$WaK znl?viODU7xNixZ1ciEjSlvBl9!2?mi3q`>L6)Kb_C;}oPg5V7vcpwVi_bvLqnaS*C zvYTxE>F?KOzIpS$|GVG&zJ2`Vi3b4epp*wL6u40+P=sQFcSJd+1S3jxD1W$jU_=e+ zgyQX5RMQ&?1^z&P8A?#T0;Kf`wd;*o2N2oNuYq4N^G@jC#MODTKf*;|gNTm6= zt|keE{=nhpQY^qiH_i~K!ha&da${qZcZPeEt$ zXWTKl>Yr>a^RqPXK)(+aNJ>F(6L>prnB3LAvuPkA`(~xzneu^tInr(vcqiT^TbB|^ zse1=!cdjMU$K&~Cfm`ryF5NL@RAt|AXb))mu=Em-SQ)x4Gf$dH^{10Oh~vEi@5B39 zH%`wzoPX$qRe1~=nTwK}8}0}2K{sv__z*r!@aMact8{Pk?$(xG9&GtblM3G<@DY5J z4?yum?3fl-lkH*S1a(NN@^OJXr5=T?U45OB@JWGBNkVCRS8wb7wx*U=NxfU(grti0 zuI|3xqoyV$xQE@)9E(L%C0c@$tfJ|s5}e}3-hUQ}DS8PeSXg_M7oXv2zmvpS@qD)UOVeC@IRA;BLA}$quRN#wvjI|G$$D5=h z-6zEu4t-qU%hFTDJuOWgP5VpmB!P~~q<%`^X?&IAz-gG!lQJiB)XgCezK)08_y!@6 z_kS3cBEBW?ZK-K7w9w9ejuj1fO`-|^rF z_@Ns=68JHmC#;wa($IrY!ROL$C80+3$pd~MUv>J~pdM`~s_3Z%hi;1_!rSwMX>8!c zZ{hE(O9vlYyHaBVYNA)+<|GeGv5*q!=YPY5e7A^&`mn~~&wB`SwB?mWQzF{synUY) z?Wq@rti3010WM9p->kTt-2;<1p6c1|J6xtb(&f3vI^23%FO%n17M675Pnma}z4jOG z2%J~@(&xt2dGx~c$&t4wr)*y{yJyq8WfqjM_+Il6zHQn8>NL`zBx&O;m>PH{T7SWB za89ed@uj97*3=%CMaEH^6DiNp!5S7Bqm!`iw9gFva*$5W?cizdCd@HUhicgJPl+Bg zvy`hQqKplMm0AP6cI`Q*QNf3}EwVK$^EH1C-;YD1ol4vaM)?%cTb+b0PQqp^%2yI`+g; ziICc+NoSUsF<&Foo8P+ls?n*+UMTOjJm7Wi3`)FH)rVu@q?f9548Z=1Vyf{VNM{LJ zfaksP-<1_bc*skBS)ZphYckuR7mrmGQN4s-C1@U%da0CU1tsAeL6yeaI)C}LUXagt z+bG{Q37X4qrL=_)p|n-dHaVZljA-_7kt+%2+%7qH2=W@7=St4=1T{%ck9k~`EtMHt zB;!s&6$WFQWISKcF3DJKF>;*@Dj}7bcT46yg31l%PRZOQXm47+=``ltCpq^EnqzSG zO3pq({gSiP;B1Yi#+dH{$$xiHPzjZm&|$(7GiX=XxAq;hs@nKLVh3Ht{6{4JMS>R6 z8D6?r{vMKdLhHqHri~aD-cO<7t@;D!jstMzlHQCuK9KYhZiLco!@>1SS zN#i%znOOO3VKQSmf^Y$RH+RY3sCea)KS3GKbyyF=PmJ{on9u7XzA+Pi%9J2KiJwJv z_kr#0$C-S1@zso@wg3;J<|OL26;>7AgEbS_0E}ac3sr^n_oA_?@Ge{MA{GrSW8i93 zq6Pt+g)RKI5p{+#8%%-t6@CpED`_nc4eY>g@LNWy#2WmLX@3R0>kRy!l`g~|n6r?% z{>cCTjK55?{T?*m<*Lfyred$dwF> z6#RD_vHX~+$Qq|2JF+NZ#_}Ib`zLO&K!%Li154}b$I(WZz;56ay2jD#VsG>5;9jh` z+eneLo=sSS=6?)5m)d$RwbJHaws1*#!A_)?dG&8RlEsycP77MIxsr^3#^yuJEx%Pu z^9|kWCUB8VkpZbW-(^o?-0dTi@%YqsQ?EUQM#TCDW8~yw!ztv=6b%fNplwe$=9eOQe@GydN#N09`nU zKIRDFLPR+yQ|$X|aWSrkf*Ub_+Ysi9{UDyiFrI>jXEB23FpB5-{Y69!r&pVfwX^3X zDx@NIdw&(4ree+`mz_r@T{6Z$~+=Zoe7sLT*?EciK)d;YKpx7Ltd`I8%g?sjm6w5hKsBky|Zf zuZjGz+jBsMGa$WFAY+-Avz?Uecdx zxG$-H{bE}v6n}DQdO6#Qe61DJ9b-S8$ZgyfF?os8vwxm7Yv*nBe>A(?>QY^~+w654 zxYss4Gu~EzWzu?fu?PQpm@1wHI^Rs+vAFKK)UKE70`fxMa_!hSsqt?xSK_|4M%&M` z_MC5zD3HZFJ@(LJr=iiYq<29t46nS-tq=C`(|&+JgZ~2_Uf{&x3}=kzLtBr zD(4pCGT!3TRw=bRC-en{9Gmub&BJI%o~0toSzfOA#lJcGDX$!F+p1gf%YjEH@NzRS z*i855V3e6&$Hgc+eIGL;M}1)SZ3BV2@9RGtiqloiO;onfYR+4HVMAYD^7>V4c6jA_ zdK67E`gXs3;+rF$Z_FAG{CRe{w)}p%`rIjT>rW}g6(qM#ntYIhm)&r>PpZ?i$p_EN z$4}~S&K6pFYlUUH9$S8Fsa{3I29uqLJmk|7J(SGdKbl?f7X^pR#9D z5_7cP^o+`vjN*qMa{Qln&(@)3lA2SLyXxcI9iqvv!{3W6K6kmsxc1KTjhiA3)b8;g z{NL~+C3B@w+#jx~3U#JOt+#3X$jsVNl5*JKZ11Wsxo#~^l4pzag6_=N=6A)yaPgT& zLkm^2YCoqnBHP2ZPuQiM(xK<}LhI<+`UUlk(sF_RH5p3h9Wy4Z?$#3jWFUMZ@w~{i zqD`-NY;||vaB@eg+l$ib6D6|he1(r@8hz|lZ)xeWzkC0f>3WgvhJnIv#WN4GiQL(y zwb5{YPOtUS_G@Ob-?Xf(HpDO8cB>`p;$4>hO+J}^4>)vOj%^lc|F3I%=ji*AODohK zPtkgFvYu;6$T@lb-~Xq|_}leN-E3SpUFD;1w*JrDA6EnZe)+5CHz{~)VHij5Pr*%b zE4HqBJmc+4!>J{Uo}_0Vm}BSc*7eNp=WLzZ@BUW*mr1#>c*4!j9-C)>w#?9)xQw;+ zl(flm?p31Y?t$4c=T9yxO=*aD+5dKSNY;54mnYMoukV|*v;N`n4>LqXV{BhvKE31Z zO!Ib^C^7qc%^x=kxk`&Yn|aq;Zq4pv`>M9JXU?Bh>zXLzD7Z!A!MiuRb;}?4oSb<= z+xhM@Ihzj~R~hw8{$9_tQjL-8+kOLvCTH_ofs>2NJNU{RuJg6u`JnyeLEinkxbafF%fytEt z0>EA~1T1Mh<^htOUeCv9$F##2%(mla3XvwrF63qV1 z&uGoGA`Zl!+?~b^Hhy}k0HYL>fBN)^0*sMNC(FSK`2-nlnPeM4>`5Jh(;EaCwV0-~ zK)9@vYkGL6KM-UTkU=?j0_H>@y`*t^57->wF%)Vth^74u48|y`Qs#qHO?Fu&JbkY) z(5GCdkbPRdclt#k#%Lz~{a~{Kgc;4491p?RY|}RgGs?<<2ZMJe-XgwkpeU^u2-hY8p+9l8Nzt4%i%1&VKY1Y{dP!flx- zFx-5ffhAvvGMX}7d;w;=iZL29F@6NHm8Q=TV^onrDYj7TP5nAOfP+yQUW&0zml6kt SblMLj;W}|fSvIbpAbkKW0eLk5 diff --git a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/AbstractVisitor.java b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/AbstractVisitor.java index 2ee24c2..fb63ee1 100644 --- a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/AbstractVisitor.java +++ b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/AbstractVisitor.java @@ -4,6 +4,13 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; abstract class AbstractVisitor extends CodeWriter { @@ -23,5 +30,56 @@ public Schema getSchema() { return schema; } + /** + * Whether a non-null object field has to be generated as {@code Optional} anyway, because a type + * it shares an {@code implements} relation with declares the same field nullable. + * + *

    GraphQL lets an implementation narrow a nullable field to non-null, and lets an object + * implement several interfaces that disagree on that. Java has one return type per method: an + * implementation must satisfy every interface it implements, so {@code Optional} is + * all-or-nothing across a whole {@code implements} component — otherwise the generated class + * cannot satisfy its own {@code implements} clause. + */ + boolean requiresOptionalObjectField(Field field) { + if (!getSchema().supportsNullableObjects() || !field.getTypeRef().isObjectOrInterface()) { + return false; + } + return implementsComponent(field.getParentObject()).stream() + .filter(related -> related.getFields() != null) + .flatMap(related -> related.getFields().stream()) + .anyMatch( + declared -> + declared.getName().equals(field.getName()) + && declared.getTypeRef().isOptional() + && declared.getTypeRef().isObjectOrInterface()); + } + + /** Every type reachable from this one through {@code implements}, in either direction. */ + private Set implementsComponent(Type type) { + Set component = new LinkedHashSet<>(); + Deque pending = new ArrayDeque<>(List.of(type)); + while (!pending.isEmpty()) { + Type current = pending.poll(); + if (!component.add(current)) { + continue; + } + Stream.concat( + current.getImplementedInterfaceNames().stream().map(this::typeNamed), + getSchema().getTypes().stream() + .filter( + other -> other.getImplementedInterfaceNames().contains(current.getName()))) + .filter(Objects::nonNull) + .forEach(pending::add); + } + return component; + } + + private Type typeNamed(String name) { + return getSchema().getTypes().stream() + .filter(type -> name.equals(type.getName())) + .findFirst() + .orElse(null); + } + abstract TypeSpec generateType(Type type); } diff --git a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/InterfaceVisitor.java b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/InterfaceVisitor.java index db1ef07..07f167a 100644 --- a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/InterfaceVisitor.java +++ b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/InterfaceVisitor.java @@ -5,6 +5,7 @@ import java.nio.charset.Charset; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import javax.lang.model.element.Modifier; @@ -43,6 +44,16 @@ TypeSpec generateType(Type type) { .addModifiers(Modifier.PUBLIC, Modifier.ABSTRACT); TypeName returnType = resolveReturnType(field); + if (isNullableObject(field)) { + if (field.getTypeRef().getKind() == TypeKind.INTERFACE) { + returnType = WildcardTypeName.subtypeOf(returnType); + } + returnType = ParameterizedTypeName.get(ClassName.get(Optional.class), returnType); + } else if (requiresOptionalObjectField(field)) { + // An interface may narrow a nullable field of the interface it implements to non-null. + // It still has to return Optional, or it does not satisfy that declaration. + returnType = ParameterizedTypeName.get(ClassName.get(Optional.class), returnType); + } methodBuilder.returns(returnType); // Add parameters for required args @@ -81,7 +92,7 @@ TypeSpec generateType(Type type) { } /** Generates the FooClient class that implements the Foo interface via query building. */ - private TypeSpec generateClientType(Type type) { + TypeSpec generateClientType(Type type) { String clientName = Helpers.formatName(type) + "Client"; ClassName interfaceName = ClassName.bestGuess(Helpers.formatName(type)); @@ -120,6 +131,12 @@ private void buildFieldMethod( .addAnnotation(Override.class); TypeName returnType = resolveReturnType(field); + TypeName objectReturnType = returnType; + boolean nullableObject = isNullableObject(field); + boolean presentObject = !nullableObject && requiresOptionalObjectField(field); + if (nullableObject || presentObject) { + returnType = ParameterizedTypeName.get(ClassName.get(Optional.class), returnType); + } fieldMethodBuilder.returns(returnType); List mandatoryParams = @@ -184,14 +201,32 @@ private void buildFieldMethod( .addException(InterruptedException.class) .addException(ExecutionException.class) .addException(ClassName.get("io.dagger.client.exception", "DaggerQueryException")); + } else if (nullableObject) { + String graphqlTypeName = field.getTypeRef().getTypeName(); + String clientClassName = + field.getTypeRef().isInterface() + ? graphqlTypeName + "Client" + : objectReturnType.toString(); + fieldMethodBuilder.addStatement( + "QueryBuilder objectQueryBuilder = nextQueryBuilder.executeNullableObjectQuery($S)", + graphqlTypeName); + fieldMethodBuilder.addStatement( + "return Optional.ofNullable(objectQueryBuilder).map(qb -> new $L(qb))", + ClassName.bestGuess(clientClassName)); + fieldMethodBuilder + .addException(InterruptedException.class) + .addException(ExecutionException.class) + .addException(ClassName.get("io.dagger.client.exception", "DaggerQueryException")); } else if (field.getTypeRef().isObjectOrInterface()) { - TypeName objectType = resolveReturnType(field); // For interface return types, instantiate the client class - if (field.getTypeRef().isInterface()) { - fieldMethodBuilder.addStatement( - "return new $LClient(nextQueryBuilder)", field.getTypeRef().getTypeName()); + CodeBlock instantiation = + field.getTypeRef().isInterface() + ? CodeBlock.of("new $LClient(nextQueryBuilder)", field.getTypeRef().getTypeName()) + : CodeBlock.of("new $L(nextQueryBuilder)", objectReturnType); + if (presentObject) { + fieldMethodBuilder.addStatement("return $T.of($L)", Optional.class, instantiation); } else { - fieldMethodBuilder.addStatement("return new $L(nextQueryBuilder)", objectType); + fieldMethodBuilder.addStatement("return $L", instantiation); } } else { fieldMethodBuilder.addStatement("return nextQueryBuilder.executeQuery($L.class)", returnType); @@ -225,6 +260,12 @@ private TypeName resolveArgType(InputObject arg) { return arg.getType().formatInput(expectedType); } + private boolean isNullableObject(Field field) { + return getSchema().supportsNullableObjects() + && field.getTypeRef().isOptional() + && field.getTypeRef().isObjectOrInterface(); + } + private boolean needsExceptions(Field field) { if (field.getTypeRef().isListOfObject() || field.getTypeRef().isList()) { return true; @@ -233,7 +274,9 @@ private boolean needsExceptions(Field field) { return true; } if (field.getTypeRef().isObjectOrInterface()) { - return false; + // A field coerced to Optional because an implemented interface declares it nullable stays + // lazy: it cannot be absent, so nothing is resolved and nothing can fail. + return isNullableObject(field); } return true; // scalar fields need exceptions } diff --git a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/ObjectVisitor.java b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/ObjectVisitor.java index 783dc58..965a68e 100644 --- a/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/ObjectVisitor.java +++ b/sdk/dagger-codegen-maven-plugin/src/main/java/io/dagger/codegen/introspection/ObjectVisitor.java @@ -11,6 +11,7 @@ import java.nio.charset.Charset; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.function.UnaryOperator; import javax.lang.model.element.Modifier; @@ -213,6 +214,18 @@ private void buildFieldMethod( MethodSpec.Builder fieldMethodBuilder = MethodSpec.methodBuilder(Helpers.formatName(field)).addModifiers(Modifier.PUBLIC); TypeName returnType = resolveReturnType(field); + TypeName objectReturnType = returnType; + boolean nullableObject = + getSchema().supportsNullableObjects() + && field.getTypeRef().isOptional() + && field.getTypeRef().isObjectOrInterface(); + // A non-null field still has to return Optional when an interface it shares an `implements` + // relation with declares the field nullable, or the class does not satisfy its own `implements` + // clause. It stays lazy: the value cannot be absent, so there is nothing to resolve. + boolean presentObject = !nullableObject && requiresOptionalObjectField(field); + if (nullableObject || presentObject) { + returnType = ParameterizedTypeName.get(ClassName.get(Optional.class), returnType); + } fieldMethodBuilder.returns(returnType); List mandatoryParams = field.getRequiredArgs().stream() @@ -294,13 +307,32 @@ private void buildFieldMethod( .addException(InterruptedException.class) .addException(ExecutionException.class) .addException(ClassName.get("io.dagger.client.exception", "DaggerQueryException")); + } else if (nullableObject) { + String graphqlTypeName = field.getTypeRef().getTypeName(); + String clientClassName = + field.getTypeRef().isInterface() + ? graphqlTypeName + "Client" + : objectReturnType.toString(); + fieldMethodBuilder.addStatement( + "QueryBuilder objectQueryBuilder = nextQueryBuilder.executeNullableObjectQuery($S)", + graphqlTypeName); + fieldMethodBuilder.addStatement( + "return Optional.ofNullable(objectQueryBuilder).map(qb -> new $L(qb))", + ClassName.bestGuess(clientClassName)); + fieldMethodBuilder + .addException(InterruptedException.class) + .addException(ExecutionException.class) + .addException(ClassName.get("io.dagger.client.exception", "DaggerQueryException")); } else if (field.getTypeRef().isObjectOrInterface()) { // For interface return types, instantiate the client class - if (field.getTypeRef().isInterface()) { - String ifaceName = field.getTypeRef().getTypeName(); - fieldMethodBuilder.addStatement("return new $LClient(nextQueryBuilder)", ifaceName); + CodeBlock instantiation = + field.getTypeRef().isInterface() + ? CodeBlock.of("new $LClient(nextQueryBuilder)", field.getTypeRef().getTypeName()) + : CodeBlock.of("new $L(nextQueryBuilder)", objectReturnType); + if (presentObject) { + fieldMethodBuilder.addStatement("return $T.of($L)", Optional.class, instantiation); } else { - fieldMethodBuilder.addStatement("return new $L(nextQueryBuilder)", returnType); + fieldMethodBuilder.addStatement("return $L", instantiation); } } else { fieldMethodBuilder.addStatement("return nextQueryBuilder.executeQuery($L.class)", returnType); diff --git a/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java new file mode 100644 index 0000000..234d8f3 --- /dev/null +++ b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java @@ -0,0 +1,273 @@ +package io.dagger.codegen.introspection; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.palantir.javapoet.JavaFile; +import com.palantir.javapoet.TypeSpec; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class NullableObjectCodegenTest { + + @TempDir Path compilationOutputDirectory; + + @Test + void belowTheGateNullableObjectFieldsKeepTheLazyShape() throws Exception { + String generated = generateInterface(interfaceWithOptionalObjectField(), "v1.0.0-beta.9"); + + assertThat(generated).contains("Directory child();").doesNotContain("DaggerQueryException"); + } + + @Test + void atTheGateNullableObjectFieldsReturnOptionalAndCanFail() throws Exception { + String generated = generateInterface(interfaceWithOptionalObjectField(), "v1.0.0-beta.10"); + + assertThat(generated).contains("Optional child()").contains("DaggerQueryException"); + } + + @Test + void narrowingAnInterfacesNullableObjectFieldCompiles() throws Exception { + Type owner = type("Owner", TypeKind.INTERFACE); + owner.setFields(List.of(field("pet", typeRef(TypeKind.INTERFACE, "Animal"), owner))); + + Type kennel = type("Kennel", TypeKind.OBJECT); + kennel.setInterfaces(List.of(typeRef(TypeKind.INTERFACE, "Owner"))); + kennel.setFields(List.of(field("pet", typeRef(TypeKind.OBJECT, "Dog"), kennel))); + + assertCompiles(sources(owner, kennel)); + } + + /** + * An object may declare a field non-null where the interface it implements declares it nullable. + * The interface then returns Optional while the object stays lazy, and the two have to remain + * compatible. + */ + @Test + void implementingANullableInterfaceFieldWithANonNullOneCompiles() throws Exception { + Type owner = type("Owner", TypeKind.INTERFACE); + owner.setFields(List.of(field("pet", typeRef(TypeKind.INTERFACE, "Animal"), owner))); + + Type kennel = type("Kennel", TypeKind.OBJECT); + kennel.setInterfaces(List.of(typeRef(TypeKind.INTERFACE, "Owner"))); + kennel.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.OBJECT, "Dog")), kennel))); + + assertCompiles(sources(owner, kennel)); + } + + /** + * An interface may itself narrow the nullable field of the interface it implements, and GraphQL + * requires the object to declare the whole hierarchy. Every generated method in the chain — the + * two interfaces, their clients and the object — has to agree on Optional. + */ + @Test + void narrowingThroughAnInterfaceHierarchyCompiles() throws Exception { + Type parent = type("Parent", TypeKind.INTERFACE); + parent.setFields(List.of(field("pet", typeRef(TypeKind.INTERFACE, "Pet"), parent))); + + Type child = type("Child", TypeKind.INTERFACE); + child.setInterfaces(List.of(typeRef(TypeKind.INTERFACE, "Parent"))); + child.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.INTERFACE, "Pet")), child))); + + Type kennel = type("Kennel", TypeKind.OBJECT); + kennel.setInterfaces( + List.of(typeRef(TypeKind.INTERFACE, "Child"), typeRef(TypeKind.INTERFACE, "Parent"))); + kennel.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.INTERFACE, "Pet")), kennel))); + + assertCompiles(sources(parent, child, kennel)); + } + + /** + * Two unrelated interfaces may disagree on the nullability of the same field. An object + * implementing both has a single method to satisfy both declarations, so they all have to return + * Optional. + */ + @Test + void disagreeingUnrelatedInterfacesCompile() throws Exception { + Type shelter = type("Shelter", TypeKind.INTERFACE); + shelter.setFields(List.of(field("pet", typeRef(TypeKind.INTERFACE, "Pet"), shelter))); + + Type home = type("Home", TypeKind.INTERFACE); + home.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.INTERFACE, "Pet")), home))); + + Type kennel = type("Kennel", TypeKind.OBJECT); + kennel.setInterfaces( + List.of(typeRef(TypeKind.INTERFACE, "Shelter"), typeRef(TypeKind.INTERFACE, "Home"))); + kennel.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.INTERFACE, "Pet")), kennel))); + + assertCompiles(sources(shelter, home, kennel)); + } + + /** + * The generated sources for the given types, plus the handwritten ones they are compiled against. + */ + private Map sources(Type... types) throws Exception { + Schema schema = schemaAtVersion("v1.0.0-beta.10"); + // The generators resolve the interfaces a type implements through the schema. + schema.setTypes(List.of(types)); + + Map sources = new HashMap<>(supportSources()); + for (Type type : types) { + String qualifiedName = "io.dagger.client." + type.getName(); + if (type.getKind() == TypeKind.INTERFACE) { + InterfaceVisitor visitor = + new InterfaceVisitor(schema, Path.of("."), StandardCharsets.UTF_8); + sources.put(qualifiedName, javaFile(visitor.generateType(type))); + sources.put(qualifiedName + "Client", javaFile(visitor.generateClientType(type))); + } else { + sources.put( + qualifiedName, + javaFile( + new ObjectVisitor(schema, Path.of("."), StandardCharsets.UTF_8) + .generateType(type))); + } + } + return sources; + } + + /** + * The client stub mirrors the real QueryBuilder, checked exceptions included: a generated method + * that resolves a nullable object must declare them, and only a faithful stub can catch a missing + * throws clause. + */ + private static Map supportSources() { + return Map.of( + "io.dagger.client.Animal", + "package io.dagger.client; public interface Animal {}", + "io.dagger.client.AnimalClient", + "package io.dagger.client; public class AnimalClient implements Animal {" + + " AnimalClient(QueryBuilder queryBuilder) {} }", + "io.dagger.client.Dog", + "package io.dagger.client; public class Dog implements Animal {" + + " Dog(QueryBuilder queryBuilder) {} }", + "io.dagger.client.Pet", + "package io.dagger.client; public interface Pet {}", + "io.dagger.client.PetClient", + "package io.dagger.client; public class PetClient implements Pet {" + + " PetClient(QueryBuilder queryBuilder) {} }", + "io.dagger.client.QueryBuilder", + "package io.dagger.client; public class QueryBuilder {" + + " QueryBuilder chain(String field) { return this; }" + + " QueryBuilder executeNullableObjectQuery(String typeName)" + + " throws InterruptedException, java.util.concurrent.ExecutionException," + + " io.dagger.client.exception.DaggerQueryException { return this; }" + + " }", + "io.dagger.client.exception.DaggerQueryException", + "package io.dagger.client.exception;" + + " public class DaggerQueryException extends Exception {}"); + } + + private static String javaFile(TypeSpec typeSpec) { + return JavaFile.builder("io.dagger.client", typeSpec).build().toString(); + } + + private static String generateInterface(Type type, String version) throws Exception { + return new InterfaceVisitor(schemaAtVersion(version), Path.of("."), StandardCharsets.UTF_8) + .generateType(type) + .toString(); + } + + private static Schema schemaAtVersion(String version) throws Exception { + byte[] introspection = "{\"__schema\":{\"types\":[]}}".getBytes(StandardCharsets.UTF_8); + return Schema.initialize(new ByteArrayInputStream(introspection), version); + } + + private static Type interfaceWithOptionalObjectField() { + Type parent = type("Parent", TypeKind.INTERFACE); + parent.setFields(List.of(field("child", typeRef(TypeKind.OBJECT, "Directory"), parent))); + return parent; + } + + private static Type type(String name, TypeKind kind) { + Type type = new Type(); + type.setKind(kind); + type.setName(name); + type.setDescription(""); + type.setInterfaces(List.of()); + return type; + } + + private static Field field(String name, TypeRef typeRef, Type parent) { + Field field = new Field(); + field.setName(name); + field.setDescription(""); + field.setTypeRef(typeRef); + field.setArgs(List.of()); + field.setDirectives(List.of()); + field.setParentObject(parent); + return field; + } + + private static TypeRef typeRef(TypeKind kind, String name) { + TypeRef ref = new TypeRef(); + ref.setKind(kind); + ref.setName(name); + return ref; + } + + private static TypeRef nonNull(TypeRef inner) { + TypeRef ref = new TypeRef(); + ref.setKind(TypeKind.NON_NULL); + ref.setOfType(inner); + return ref; + } + + private void assertCompiles(Map sources) { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + assertThat(compiler).isNotNull(); + + DiagnosticCollector diagnostics = new DiagnosticCollector<>(); + List compilationUnits = + sources.entrySet().stream() + .map(entry -> new SourceFile(entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + boolean compiled = + compiler + .getTask( + null, + null, + diagnostics, + List.of( + "--release", "17", "-proc:none", "-d", compilationOutputDirectory.toString()), + null, + compilationUnits) + .call(); + + assertThat(compiled) + .withFailMessage( + "Generated sources did not compile:%n%s", + diagnostics.getDiagnostics().stream() + .map(Object::toString) + .collect(Collectors.joining("\n"))) + .isTrue(); + } + + private static final class SourceFile extends SimpleJavaFileObject { + private final String source; + + private SourceFile(String className, String source) { + super( + URI.create( + "string:///" + className.replace('.', '/') + JavaFileObject.Kind.SOURCE.extension), + JavaFileObject.Kind.SOURCE); + this.source = source; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return source; + } + } +} From 03aaa79d8ff7db5f4e35abbef8bd82e3d320ddb3 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 06/10] module: support Optional object returns from module functions A function declared to return Optional 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 --- .../DaggerModuleAnnotationProcessor.java | 6 ++- .../annotation/processor/DaggerType.java | 29 +++++++++- .../annotation/processor/DaggerTypeTest.java | 54 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeTest.java diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java index bb97669..83fd5fe 100644 --- a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java +++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerModuleAnnotationProcessor.java @@ -605,7 +605,8 @@ static JavaFile generate(ModuleInfo moduleInfo) { private static CodeBlock functionInvoke(ObjectInfo objectInfo, FunctionInfo fnInfo) { CodeBlock.Builder code = CodeBlock.builder(); - CodeBlock fnReturnType = DaggerType.of(fnInfo.returnType()).toJavaType(); + DaggerType returnType = DaggerType.of(fnInfo.returnType()); + CodeBlock fnReturnType = returnType.toJavaType(); CodeBlock startAsList = CodeBlock.of("$T.asList(", Arrays.class); CodeBlock endAsList = CodeBlock.of(")"); @@ -694,7 +695,8 @@ private static CodeBlock functionInvoke(ObjectInfo objectInfo, FunctionInfo fnIn if (returnsVoid && !isConstructor) { code.addStatement("return $T.toJSON(null)", JsonConverter.class); } else { - code.addStatement("return $T.toJSON(res)", JsonConverter.class); + code.addStatement( + "return $T.toJSON($L)", JsonConverter.class, returnType.valueForSerialization("res")); } return code.build(); diff --git a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java index e9e3063..95ae000 100644 --- a/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java +++ b/sdk/dagger-java-annotation-processor/src/main/java/io/dagger/annotation/processor/DaggerType.java @@ -28,6 +28,10 @@ boolean isList() { return false; } + CodeBlock valueForSerialization(String name) { + return CodeBlock.of("$L", name); + } + public static DaggerType of(TypeInfo ti) { String name = ti.typeName(); String kindName = ti.kindName(); @@ -61,7 +65,7 @@ public static DaggerType of(TypeInfo ti) { } if (name.startsWith("java.util.Optional<")) { - return of(name.substring("java.util.Optional<".length(), name.length() - 1)); + return new Optional(of(name.substring("java.util.Optional<".length(), name.length() - 1))); } try { @@ -207,6 +211,29 @@ CodeBlock toJavaType() { } } + public static class Optional extends DaggerType { + private final DaggerType inner; + + public Optional(DaggerType inner) { + this.inner = inner; + } + + @Override + CodeBlock toDaggerTypeDef() { + return CodeBlock.builder().add(inner.toDaggerTypeDef()).add(".withOptional(true)").build(); + } + + @Override + CodeBlock toJavaType() { + return CodeBlock.of("$T<$L>", java.util.Optional.class, inner.toJavaType()); + } + + @Override + CodeBlock valueForSerialization(String name) { + return CodeBlock.of("$L.orElse(null)", name); + } + } + public static class Object extends DaggerType { private final String qualifiedName; private final String simpleName; diff --git a/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeTest.java b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeTest.java new file mode 100644 index 0000000..2911725 --- /dev/null +++ b/sdk/dagger-java-annotation-processor/src/test/java/io/dagger/annotation/processor/DaggerTypeTest.java @@ -0,0 +1,54 @@ +package io.dagger.annotation.processor; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.dagger.module.info.FieldInfo; +import io.dagger.module.info.TypeInfo; +import javax.lang.model.type.TypeKind; +import org.junit.jupiter.api.Test; + +class DaggerTypeTest { + + @Test + void optionalObjectReturnsAreRegisteredAsOptionalAndUnwrappedForSerialization() { + DaggerType type = declared("java.util.Optional"); + + assertThat(type.toDaggerTypeDef().toString()) + .isEqualTo( + "io.dagger.client.Dagger.dag().typeDef().withObject(\"Container\").withOptional(true)"); + assertThat(type.toJavaType().toString()) + .isEqualTo("java.util.Optional"); + assertThat(type.valueForSerialization("result").toString()).isEqualTo("result.orElse(null)"); + } + + @Test + void nonOptionalReturnsSerializeAsThemselves() { + DaggerType type = declared("io.dagger.client.Container"); + + assertThat(type.toDaggerTypeDef().toString()) + .isEqualTo("io.dagger.client.Dagger.dag().typeDef().withObject(\"Container\")"); + assertThat(type.valueForSerialization("result").toString()).isEqualTo("result"); + } + + /** + * A field keeps its declared type — unlike an argument, whose optionality the processor records + * separately — so a public {@code Optional} field is now registered as an optional field. + */ + @Test + void optionalObjectFieldsAreRegisteredAsOptional() { + FieldInfo field = + new FieldInfo( + "maybeContainer", + "", + new TypeInfo( + "java.util.Optional", TypeKind.DECLARED.name())); + + assertThat(DaggerType.of(field.type()).toDaggerTypeDef().toString()) + .isEqualTo( + "io.dagger.client.Dagger.dag().typeDef().withObject(\"Container\").withOptional(true)"); + } + + private static DaggerType declared(String typeName) { + return DaggerType.of(new TypeInfo(typeName, TypeKind.DECLARED.name())); + } +} From 3042d8a3eee5d62cfe3ae78462079a1e93a1ab63 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 07/10] java-sdk: generate against the engine's own version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + suffix changes on every engine build and would rebuild every module's SDK for nothing. Signed-off-by: Yves Brissaud --- mod.dang | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mod.dang b/mod.dang index 04a1966..6bf3f19 100644 --- a/mod.dang +++ b/mod.dang @@ -140,9 +140,22 @@ type Mod { let sdkBuilt(introspectionJSON: File!, name: String!): Container! { codegenBase(introspectionJSON) .withExec(["mvn", "versions:set", "-DnewVersion=" + name, "-DgenerateBackupPoms=false", "--no-transfer-progress"]) - .withExec(["mvn", "--projects", "dagger-java-sdk,dagger-java-annotation-processor", "--also-make", "install", "-Ddaggerengine.schema=/schema.json", "-Dmaven.test.skip=true", "-Dfmt.skip=true", "--no-transfer-progress"]) + .withExec(["mvn", "--projects", "dagger-java-sdk,dagger-java-annotation-processor", "--also-make", "install", "-Ddaggerengine.schema=/schema.json", "-Ddaggerengine.version=" + engineVersion, "-Dmaven.test.skip=true", "-Dfmt.skip=true", "--no-transfer-progress"]) } + """ + The live engine version, without build metadata. + + Codegen only reads the engine version off the CLI when it has to query the + schema itself. Here the schema is handed to it, so without this the version + stays whatever the pom happens to say, and generation cannot tell which shapes + the engine on the other end actually supports. + + The `+` suffix is dropped: it changes on every engine build and would + make every module's SDK rebuild for no reason. + """ + let engineVersion: String! { version.split("+")[0] ?? version } + let vendoredSdk(introspectionJSON: File!, name: String!): Directory! { let built = sdkBuilt(introspectionJSON, name) directory From 28fece739b141310593b5b4c4350c85561059ac5 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:50:45 +0200 Subject: [PATCH 08/10] e2e: check a module function returning Optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dagger/modules/e2e/main.dang | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 93b6528..4402746 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -257,4 +257,67 @@ type E2e { null } + + """ + A module function returning Optional should register an optional + return type — and, since generation compiles the entrypoint it produces, + generating at all proves the entrypoint the annotation processor writes for an + Optional return is valid Java. + + Reuses the generate fixture rather than adding a managed module, so the module + inventory the discovery checks assert stays as it is. + """ + nullableReturnCheck(ws: Workspace!): Void @check { + let modPath = generateModulePath + let initialized = javaSdk.initModule(ws, name: "generate-app", path: modPath) + let root = testWS(ws) + .directory("/", exclude: [fixtureRoot + "/.dagger-java-sdk-skip-generate"]) + .withDirectory(".", initialized.layer) + .withNewFile( + modPath + "/src/main/java/io/dagger/modules/generateapp/GenerateApp.java", + nullableReturnSource, + ) + + let changes = javaSdk.generateAll(root.asWorkspace(cwd: modPath)) + let entrypoint = changes + .layer + .file("src/generated/java/io/dagger/gen/entrypoint/Entrypoint.java") + .contents + + assertContains( + entrypoint, + "withObject(\"Directory\").withOptional(true)", + "an Optional return should register an optional Directory return type", + ) + assertContains( + entrypoint, + "res.orElse(null)", + "the Optional return should be unwrapped before serialization", + ) + + null + } + + """A module whose only function returns a nullable object.""" + let nullableReturnSource: String! { + "package io.dagger.modules.generateapp;\n" + + "\n" + + "import static io.dagger.client.Dagger.dag;\n" + + "\n" + + "import io.dagger.client.Directory;\n" + + "import io.dagger.module.annotation.Function;\n" + + "import io.dagger.module.annotation.Object;\n" + + "import java.util.Optional;\n" + + "\n" + + "@Object\n" + + "public class GenerateApp {\n" + + " @Function\n" + + " public Optional maybeDirectory(boolean found) {\n" + + " if (!found) {\n" + + " return Optional.empty();\n" + + " }\n" + + " return Optional.of(dag().directory().withNewFile(\"found\", \"\"));\n" + + " }\n" + + "}\n" + } } From 4f5cf481a2943f5403ca8032582f228f2a31f482 Mon Sep 17 00:00:00 2001 From: Yves Brissaud Date: Mon, 17 Aug 2026 07:51:17 +0200 Subject: [PATCH 09/10] sdk: document nullable object results Signed-off-by: Yves Brissaud --- sdk/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/sdk/README.md b/sdk/README.md index 9df0ed6..92a66d3 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -171,6 +171,34 @@ public class GetDaggerWebsite { } ``` +### Nullable object results + +Some API fields may resolve to nothing — `TypeDef.asObject()` on a type definition +that is not an object, for instance. From engine `v1.0.0-beta.10` those fields +return `Optional`, and because the result has to be resolved to know whether it +is there, they throw the same exceptions as any other query: + +```java +Optional asObject = typeDef.asObject(); +if (asObject.isPresent()) { + System.out.println(asObject.get().name()); +} +``` + +Fields that cannot resolve to nothing are unaffected: they stay lazy and throw +nothing. Against an engine older than `v1.0.0-beta.10` the generated client keeps +the previous shape, so nullable fields return the object type directly. + +A module function can return a nullable object the same way, by declaring +`Optional`: + +```java +@Function +public Optional maybeDirectory(boolean found) { + return found ? Optional.of(dag().directory()) : Optional.empty(); +} +``` + ### Run sample code snippets The `dagger-java-samples` module contains code samples. From e3cdb4f2519d5ec4aafd23c0128c3906669dff92 Mon Sep 17 00:00:00 2001 From: Tibor Vass Date: Tue, 18 Aug 2026 04:28:52 -0700 Subject: [PATCH 10/10] test: cover nullable interface coercion edge cases Signed-off-by: Tibor Vass --- .../NullableObjectCodegenTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java index 234d8f3..4631f75 100644 --- a/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java +++ b/sdk/dagger-codegen-maven-plugin/src/test/java/io/dagger/codegen/introspection/NullableObjectCodegenTest.java @@ -110,6 +110,76 @@ void disagreeingUnrelatedInterfacesCompile() throws Exception { assertCompiles(sources(shelter, home, kennel)); } + /** + * Coercing a non-null interface field to Optional must preserve GraphQL's covariant return type. + * Otherwise the object's Optional<Dog> method cannot implement the interface's + * Optional<Animal> method because Optional is invariant. + */ + @Test + void coercedNonNullInterfaceFieldPreservesCovariantReturn() throws Exception { + Type shelter = type("Shelter", TypeKind.INTERFACE); + shelter.setFields(List.of(field("pet", typeRef(TypeKind.INTERFACE, "Animal"), shelter))); + + Type home = type("Home", TypeKind.INTERFACE); + home.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.INTERFACE, "Animal")), home))); + + Type kennel = type("Kennel", TypeKind.OBJECT); + kennel.setInterfaces( + List.of(typeRef(TypeKind.INTERFACE, "Shelter"), typeRef(TypeKind.INTERFACE, "Home"))); + kennel.setFields(List.of(field("pet", nonNull(typeRef(TypeKind.OBJECT, "Dog")), kennel))); + + assertCompiles(sources(shelter, home, kennel)); + } + + /** + * Interfaces that merely share an ancestor do not impose an override obligation on one another. + * A nullable field on one sibling must not make a same-named non-null field on another sibling + * Optional when their common ancestor does not declare that field. + */ + @Test + void nullableFieldDoesNotPropagateBetweenSiblingInterfaces() throws Exception { + Type root = type("Root", TypeKind.INTERFACE); + root.setFields(List.of(field("id", nonNull(typeRef(TypeKind.SCALAR, "ID")), root))); + + Type nullableSibling = type("NullableSibling", TypeKind.INTERFACE); + nullableSibling.setInterfaces(List.of(typeRef(TypeKind.INTERFACE, "Root"))); + nullableSibling.setFields( + List.of(field("child", typeRef(TypeKind.OBJECT, "Foo"), nullableSibling))); + + Type nonNullSibling = type("NonNullSibling", TypeKind.INTERFACE); + nonNullSibling.setInterfaces(List.of(typeRef(TypeKind.INTERFACE, "Root"))); + nonNullSibling.setFields( + List.of(field("child", nonNull(typeRef(TypeKind.OBJECT, "Foo")), nonNullSibling))); + + Type nullableImplementation = type("NullableImplementation", TypeKind.OBJECT); + nullableImplementation.setInterfaces( + List.of( + typeRef(TypeKind.INTERFACE, "NullableSibling"), + typeRef(TypeKind.INTERFACE, "Root"))); + nullableImplementation.setFields( + List.of(field("child", typeRef(TypeKind.OBJECT, "Foo"), nullableImplementation))); + + Type implementation = type("NonNullImplementation", TypeKind.OBJECT); + implementation.setInterfaces( + List.of( + typeRef(TypeKind.INTERFACE, "NonNullSibling"), + typeRef(TypeKind.INTERFACE, "Root"))); + implementation.setFields( + List.of(field("child", nonNull(typeRef(TypeKind.OBJECT, "Foo")), implementation))); + + Map generated = + sources(root, nullableSibling, nonNullSibling, nullableImplementation, implementation); + + assertThat(generated.get("io.dagger.client.NullableSibling")) + .contains("Optional child()"); + assertThat(generated.get("io.dagger.client.NonNullSibling")) + .contains("Foo child();") + .doesNotContain("Optional child()"); + assertThat(generated.get("io.dagger.client.NonNullImplementation")) + .contains("Foo child()") + .doesNotContain("Optional child()"); + } + /** * The generated sources for the given types, plus the handwritten ones they are compiled against. */