Skip to content

feat(compiler): implement @oneOf input objects#1030

Open
abernix wants to merge 6 commits intomainfrom
abernix/oneof-us
Open

feat(compiler): implement @oneOf input objects#1030
abernix wants to merge 6 commits intomainfrom
abernix/oneof-us

Conversation

@abernix
Copy link
Copy Markdown
Member

@abernix abernix commented Mar 27, 2026

Summary

Implements the @oneOf input objects directive, which became part of the stable GraphQL spec in September 2025. @oneOf lets a schema author say "exactly one of these fields must be provided" — it's a discriminated-union pattern that the type system enforces rather than leaving to resolver logic.

Closes #882.

How it works — three validation layers

The interesting design question is when each constraint should fire. We ended up with three complementary layers:

  1. Schema validation (validation/input_object.rs) — when a developer writes a @oneOf type, every field must already be nullable and carry no default. These aren't runtime concerns; they're structural invariants the schema has to uphold before any query is written.

  2. Static document validation (validation/value.rs) — when a query is compiled, a variable used to satisfy a @oneOf field must itself be declared non-null. A nullable variable is unsafe because it could legally be omitted at runtime, which would make the field-count invariant impossible to enforce without executing the query. This is the rule that most other implementations miss or leave to runtime. We validated against Apollo Kotlin, Hot Chocolate, Strawberry, graphql-java, and juniper — our static check is stricter than juniper and graphql-java, but strictly correct per spec.

  3. Runtime coercion (request/coerce_variable_values) — when variables arrive at execution time, exactly one field must be provided and it must be non-null. This is the canonical spec rule and the last line of defense for literal values or dynamic inputs the static analysis can't see.

What's new in the public API

  • directive @oneOf on INPUT_OBJECT is now a built-in.
  • InputObjectType::is_one_of() -> bool — clean check without digging into directives.
  • __Type.isOneOf: Boolean! in the introspection schema — returns true for @oneOf types, null for everything else (matching the spec's introspection definition).
  • Five new diagnostic variants covering both schema- and document-level violations.

Fuzz coverage

Two complementary fuzz targets:

  • one_of — structure-aware, using apollo-smith. We extended smith so it generates @oneOf input types naturally (with all invariants pre-satisfied at generation time), which means the fuzzer spends its budget on schema shape diversity rather than discarding malformed SDL. Asserts that is_one_of() always agrees with directive presence, and all field invariants hold.

  • one_of_exec — the schema-invariant target can't reach document-level rules, because valid schema SDL and valid executable SDL are different constraint domains. This second target fixes a rich @oneOf schema and fuzzes only the executable document. It confirmed coverage of all five @oneOf-specific code paths in both validation/value.rs and coerce_variable_values.

Version

apollo-compiler bumps to 1.32.0 — minor because is_one_of() and the introspection field are new public API. apollo-smith bumps to 0.15.3 — minor because Ty::as_nullable() is new public API.

Notes

The production federation path (Rover + JS composition) preserves @oneOf through the supergraph correctly. Rust in-process composition currently strips all directives from input types during merge — that's a separate pre-existing gap and is tracked as a follow-up, not a blocker for this PR.

abernix added 5 commits March 27, 2026 18:42
Full implementation of the `@oneOf` RFC per GraphQL spec §3.10.1.

Schema:
- Add `directive `@oneOf` on INPUT_OBJECT` as a built-in
- Add `isOneOf: Boolean!` to `__Type` introspection
- Validate: `@oneOf` fields must be nullable, must not have default values
- InputObjectType::is_one_of() convenience method

Executable documents:
- Validate: exactly one non-null field must be provided as a literal value
- Validate: a variable used in a `@oneOf` field position must be non-null type
- Runtime coerce_variable_values and coerce_argument_values enforce the
  same "exactly one non-null field" invariant at request time

Diagnostics (with graphql-js unstable_compat_message parity):
- OneOfInputObjectFieldNonNull
- OneOfInputObjectFieldHasDefault
- OneOfInputObjectWrongNumberOfFields
- OneOfInputObjectNullField
- OneOfInputObjectNullableVariable

Tests:
- 15 validation tests in tests/validation/one_of.rs covering all rules,
  graphql-js test parity, and actual introspection response assertions
- 4 runtime coercion unit tests in resolvers/input_coercion.rs

Fuzz:
- fuzz/fuzz_targets/one_of.rs — semantic-invariant target covering both
  schema invariants and the executable coercion path (value.rs coverage
  lifted from 0% to 67%)
- fuzz/corpus/one_of/ seed corpus (7 seeds) for rapid convergence
cargo fuzz coverage writes generated profdata and llvm binaries to
fuzz/coverage/ — same category as /corpus and /artifacts, not
something to check in.

In the next commit, we add a second fuzz target that exercises the
`@oneOf` executable-document path directly — coverage output will be
generated and inspected there.
The schema-invariant fuzz target (one_of.rs) cannot reach the
document-level `@oneOf` rules in validation/value.rs because a single
text input rarely satisfies both schema and document validity
simultaneously.

This target fixes a rich `@oneOf` schema covering query, mutation,
subscription, list, and nested positions, and fuzzes only the
executable document string — directly exercising:

  - validation/value.rs (0% → 70% fuzz coverage, 100% function coverage)
  - All three `@oneOf` diagnostic paths confirmed hit by the fuzzer:
      OneOfInputObjectWrongNumberOfFields  1,660 hits
      OneOfInputObjectNullField               19 hits
      OneOfInputObjectNullableVariable        11 hits
  - resolvers/input_coercion.rs coercion path

The 30% uncovered regions in value.rs are all pre-existing non-`@oneOf`
value validation paths (Float/Boolean/Enum arguments, RequiredField)
that require a different schema to exercise and are out of scope for
`@oneOf` testing.
Fills in the CHANGELOG entry with the release date (2026-03-25) and
bumps the version to 1.32.0 — the `@oneOf` feature warrants a minor bump
because it adds public API (InputObjectType::is_one_of, new diagnostics,
new __Type.isOneOf introspection field).

Also adds five schema-extension tests that were identified as a gap in
the original `@oneOf` implementation:

  - extending_oneof_type_with_nullable_field_is_valid
  - extending_oneof_type_with_nonnull_field_is_invalid
  - extending_oneof_type_with_default_value_is_invalid
  - adding_oneof_via_extension_with_valid_base_type_is_valid
  - adding_oneof_via_extension_with_nonnull_field_in_base_is_invalid
…neration

apollo-smith
  - Add Ty::as_nullable() — strips the outermost NonNull wrapper.
  - DocumentBuilder::input_object_type_definition gains a ~1-in-5 chance
    of applying `@oneOf`.  When it does, every field is forced nullable and
    stripped of default values, satisfying all spec invariants before the
    directive is inserted.
  - Update snapshot for the deterministic seed (three input types now carry
    `@oneOf`).
  - Bump version to 0.15.3.

fuzz/one_of
  - Completely rewritten from a plain-text target to a structure-aware
    target using generate_valid_document.  The fuzzer now spends its
    budget exploring interesting schema shapes rather than discarding
    malformed SDL, giving far better coverage of the `@oneOf` validation
    logic.  Three invariants are asserted on every valid document:
    is_one_of() agrees with directive presence, all fields are nullable,
    and no field carries a default.

fuzz/one_of_exec
  - Add env_logger::try_init() + debug!("{data}") for parity with every
    other fuzz target in the suite.
@abernix abernix force-pushed the abernix/oneof-us branch 3 times, most recently from 4b0387d to e15a08d Compare March 27, 2026 17:45
clippy::needless_borrow fired on `&schema` at 9 call sites in
tests/validation/one_of.rs — the compiler auto-derefs, so the
explicit borrow is redundant.
@abernix abernix marked this pull request as ready for review March 27, 2026 18:52
@abernix abernix requested a review from a team as a code owner March 27, 2026 18:52
Copy link
Copy Markdown

@martinbonnin martinbonnin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One comment from graphql/graphql-spec#1211 but looks good otherwise!

Nice test suite!

);
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to implement graphql/graphql-spec#1211 for schemas like this:

type Query {
  foo(arg: A): Int
}

input A @oneOf {
  # oh no, we can't create an instance of A
  a: A
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably following this better than I am — do you think that's something we should do now? Or ... later? Is there any risk of it not landing?

Copy link
Copy Markdown

@martinbonnin martinbonnin Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

graphql-js is still missing it. You're safe to add it down the road.

I'd merge this as is and we can revisit later.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's on the agenda for the May wg.

Comment on lines +38 to +45
let has_directive = input_obj.directives.get("oneOf").is_some();
let is_one_of = input_obj.is_one_of();

// Invariant: is_one_of() must agree with directive presence.
assert_eq!(
has_directive, is_one_of,
"is_one_of() disagrees with @oneOf directive presence for type `{type_name}`"
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a strange thing to check for: input_obj.is_one_of() on line 39 literlally does the same thing as the line above it. So you are asserting that input_obj.directives.get("oneOf").is_some() is equal to input_obj.directives.get("oneOf").is_some(). This would work if you are going getting the directive in a different way, by walking the entire tree for example, but not for as is.

Comment on lines +81 to +90
fuzz_target!(|data: &str| {
let _ = env_logger::try_init();
debug!("{data}");

let schema = schema();

// Parse and validate the fuzz input as an executable document.
// We intentionally discard validation errors — we are looking for panics
// and for coverage of the @oneOf validation code paths.
let result = ExecutableDocument::parse_and_validate(schema, data, "fuzz.graphql");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be generating an executable document in fuzz/src/lib.rs in a new function, something like generate_valid_executable_document. That function should use Unstructured to create the executable document, and you can indeed pass it the schema you have defined above. The router repo sort of does something similar in its fuzz src, albeit using the parser rather than the compiler.

[package]
name = "apollo-smith"
version = "0.15.2" # When bumping, also update README.md
version = "0.15.3" # When bumping, also update README.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is usually bumped when a release go through and in a separate PR. So let's leave it on 0.15.2 until a release happens.

Comment on lines +312 to +321
#[error(
"variable `${variable}` is of type `{variable_type}` \
but must be non-nullable to be used for @oneOf input object `{name}` field `{field}`"
)]
OneOfInputObjectNullableVariable {
name: Name,
field: Name,
variable: Name,
variable_type: Node<ast::Type>,
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be an existing DisallowedVariableUsage diagnostic?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If must be non-nullable to be used for @OneOf input object is a useful addition, it can be added as a help text.

Comment on lines +295 to +299
#[error("`{coordinate}` field of a @oneOf input object must be nullable")]
OneOfInputObjectFieldNonNull {
coordinate: TypeAttributeCoordinate,
definition_location: Option<SourceSpan>,
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like an existing UnsupportedValueType rule, which can have a help text specifying this field needs to be nullable.

Comment on lines +234 to +241
if field_val.is_null() {
diagnostics.push(
field_val.location(),
DiagnosticData::OneOfInputObjectNullField {
name: input_obj.name.clone(),
field: field_name.clone(),
},
);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this diagnostic not being caught already by an UnsupportedValueType? Because if something is declared NonNull in the schema, it should be caught. It doesn't matter if it's a oneOf input object or not.

Comment on lines +242 to +262
} else if let ast::Value::Variable(var_name) = &**field_val {
// The field value is a variable — the variable must be declared
// non-null. An undefined variable is left to the UndefinedVariable
// rule; we only flag nullable variables that are definitely known.
// https://spec.graphql.org/draft/#sec-All-Variable-Usages-are-Allowed
if let Some(var_def) = var_defs.iter().find(|v| v.name == *var_name) {
if !var_def.ty.is_non_null() {
diagnostics.push(
field_val.location(),
DiagnosticData::OneOfInputObjectNullableVariable {
name: input_obj.name.clone(),
field: field_name.clone(),
variable: var_name.clone(),
variable_type: var_def.ty.clone(),
},
);
}
}
}
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this not get validated in is_variable_usage_allowed?

Comment on lines +300 to +304
#[error(
"@oneOf input object `{name}` must specify exactly one key, but {provided} {} given",
if *provided == 1 { "was" } else { "were" }
)]
OneOfInputObjectWrongNumberOfFields { name: Name, provided: usize },
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have these be non-specific to oneOf. Perhaps this can be UniqueField or UniqueKey, similar to UniqueDirective?

Comment on lines +307 to +311
#[error("`{coordinate}` field of a @oneOf input object must not have a default value")]
OneOfInputObjectFieldHasDefault {
coordinate: TypeAttributeCoordinate,
default_location: Option<SourceSpan>,
},
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like one of the comments above, this should also be named independantly of oneOf. Perhaps, just UnsupportedDefault?

@@ -1,6 +1,6 @@
[package]
name = "apollo-compiler"
version = "1.31.1" # When bumping, also update README.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment as with apollo-smith: we usually bump these in a release PR

@lrlna
Copy link
Copy Markdown
Member

lrlna commented Apr 28, 2026

The main things to sort out are the diagnostics and whether some of them already run, but otherwise should be good to land.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement @oneOf input unions

3 participants