Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/apollo-compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,31 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## Maintenance
## Documentation-->

# [1.32.0](https://crates.io/crates/apollo-compiler/1.32.0) - 2026-03-25

## Features

- **Implement `@oneOf` input objects - [issue/882].**
Adds full support for the [`@oneOf` RFC](https://github.com/graphql/graphql-spec/pull/825)
as defined in the GraphQL draft specification (§3.10.1 OneOf Input Objects).

- `directive @oneOf on INPUT_OBJECT` is now a built-in directive definition.
- `__Type.isOneOf: Boolean!` introspection field is now exposed for all types
(returns `true` only for `@oneOf` input objects).
- New schema validation rules (enforced in `Schema::parse_and_validate`):
- All fields of a `@oneOf` input object must be nullable.
- No field of a `@oneOf` input object may have a default value.
- New executable-document validation rules (enforced in `ExecutableDocument::parse_and_validate`):
- A literal `@oneOf` input object value must supply exactly one field,
and that field's value must be non-null.
- A variable used as the sole value of a `@oneOf` field must be declared
with a non-null type (e.g. `String!`, not `String`).
- Runtime input coercion (`coerce_variable_values`) now also enforces the
"exactly one non-null field" invariant for `@oneOf` types.
- `InputObjectType::is_one_of() -> bool` convenience method added.

[issue/882]: https://github.com/apollographql/apollo-rs/issues/882

# [1.31.1](https://crates.io/crates/apollo-compiler/1.31.1) - 2026-02-20

## Fixes
Expand Down
2 changes: 1 addition & 1 deletion crates/apollo-compiler/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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

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.

Yeah, I can revert that. I had this for local testing of something.

version = "1.32.0" # When bumping, also update README.md
authors = ["Irina Shestak <shestak.irina@gmail.com>"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/apollographql/apollo-rs"
Expand Down
5 changes: 5 additions & 0 deletions crates/apollo-compiler/src/built_in_types.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type __Type {
ofType: __Type
# may be non-null for custom SCALAR, otherwise null.
specifiedByURL: String
# always non-null; true only for @oneOf INPUT_OBJECT types.
isOneOf: Boolean!
}

"An enum describing what kind of type a given `__Type` is."
Expand Down Expand Up @@ -154,6 +156,9 @@ directive @deprecated(
reason: String = "No longer supported"
) on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE

"Indicates an Input Object is a OneOf Input Object."
directive @oneOf on INPUT_OBJECT

"Exposes a URL that specifies the behavior of this scalar."
directive @specifiedBy(
"The URL that specifies the behavior of this scalar."
Expand Down
4 changes: 4 additions & 0 deletions crates/apollo-compiler/src/introspection/resolvers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ impl ObjectValue for TypeDefResolver<'_> {
.and_then(|arg| arg.as_str()),
))
}
"isOneOf" => Ok(ResolvedValue::leaf(
matches!(self.def, schema::ExtendedType::InputObject(def) if def.is_one_of()),
)),
_ => Err(self.unknown_field_error(info)),
}
}
Expand Down Expand Up @@ -261,6 +264,7 @@ impl ObjectValue for TypeResolver<'_> {
"enumValues" => Ok(ResolvedValue::null()),
"inputFields" => Ok(ResolvedValue::null()),
"specifiedByURL" => Ok(ResolvedValue::null()),
"isOneOf" => Ok(ResolvedValue::leaf(false)),
_ => Err(self.unknown_field_error(info)),
}
}
Expand Down
160 changes: 160 additions & 0 deletions crates/apollo-compiler/src/resolvers/input_coercion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,36 @@ fn coerce_variable_value(
location: None,
});
}
// @oneOf: exactly one non-null field must be provided at runtime.
// https://spec.graphql.org/draft/#sec-OneOf-Input-Objects
if ty_def.is_one_of() {
let provided_count = object
.keys()
.filter(|k| ty_def.fields.contains_key(k.as_str()))
.count();
if provided_count != 1 {
return Err(InputCoercionError::ValueError {
message: format!(
"@oneOf input object '{ty_name}' must specify exactly one key, \
but {provided_count} {} given",
if provided_count == 1 { "was" } else { "were" }
),
location: None,
});
}
if let Some((field_name, field_value)) = object.iter().next() {
if field_value.is_null() {
return Err(InputCoercionError::ValueError {
message: format!(
"@oneOf input object '{ty_name}' field '{}' \
must be non-null",
field_name.as_str()
),
location: None,
});
}
}
}
let mut object = object.clone();
for (field_name, field_def) in &ty_def.fields {
if let Some(field_value) = object.get_mut(field_name.as_str()) {
Expand Down Expand Up @@ -407,6 +437,41 @@ fn coerce_argument_value(
));
return Err(PropagateNull);
}
// @oneOf: exactly one non-null field must be provided at runtime.
// https://spec.graphql.org/draft/#sec-OneOf-Input-Objects
if ty_def.is_one_of() {
let provided_count = object
.iter()
.filter(|(k, _)| ty_def.fields.contains_key(k))
.count();
if provided_count != 1 {
ctx.errors.push(GraphQLError::field_error(
format!(
"@oneOf input object '{ty_name}' must specify exactly one key, \
but {provided_count} {} given",
if provided_count == 1 { "was" } else { "were" }
),
path,
value.location(),
&ctx.document.sources,
));
return Err(PropagateNull);
}
if let Some((field_name, field_value)) = object.iter().next() {
if field_value.is_null() {
ctx.errors.push(GraphQLError::field_error(
format!(
"@oneOf input object '{ty_name}' field '{field_name}' \
must be non-null"
),
path,
value.location(),
&ctx.document.sources,
));
return Err(PropagateNull);
}
}
}
#[allow(clippy::map_identity)] // `map` converts `&(k, v)` to `(&k, &v)`
let object: HashMap<_, _> = object.iter().map(|(k, v)| (k, v)).collect();
let mut coerced_object = JsonMap::new();
Expand Down Expand Up @@ -597,4 +662,99 @@ mod tests {
)
.unwrap_err();
}

// -----------------------------------------------------------------------
// @oneOf runtime coercion tests
// https://spec.graphql.org/draft/#sec-OneOf-Input-Objects
// -----------------------------------------------------------------------

fn one_of_schema_and_doc() -> (Valid<Schema>, Valid<ExecutableDocument>) {
let schema = Schema::parse_and_validate(
r#"
type Query {
search(filter: SearchFilter): String
}
input SearchFilter @oneOf {
byName: String
byId: Int
}
"#,
"schema.graphql",
)
.unwrap();
let doc = ExecutableDocument::parse_and_validate(
&schema,
"query ($filter: SearchFilter) { search(filter: $filter) }",
"op.graphql",
)
.unwrap();
(schema, doc)
}

#[test]
fn one_of_coercion_valid_single_field() {
let (schema, doc) = one_of_schema_and_doc();
let variables = serde_json_bytes::json!({ "filter": { "byName": "alice" } });
coerce_variable_values(
&schema,
doc.operations.anonymous.as_ref().unwrap(),
variables.as_object().unwrap(),
)
.expect("single non-null field should be accepted");
}

fn one_of_error_message(err: InputCoercionError) -> String {
match err {
InputCoercionError::ValueError { message, .. } => message,
InputCoercionError::SuspectedValidationBug(b) => b.message,
}
}

#[test]
fn one_of_coercion_rejects_zero_fields() {
let (schema, doc) = one_of_schema_and_doc();
let variables = serde_json_bytes::json!({ "filter": {} });
let err = coerce_variable_values(
&schema,
doc.operations.anonymous.as_ref().unwrap(),
variables.as_object().unwrap(),
)
.unwrap_err();
let msg = one_of_error_message(err);
assert!(
msg.contains("must specify exactly one key"),
"unexpected error: {msg}"
);
}

#[test]
fn one_of_coercion_rejects_multiple_fields() {
let (schema, doc) = one_of_schema_and_doc();
let variables = serde_json_bytes::json!({ "filter": { "byName": "alice", "byId": 1 } });
let err = coerce_variable_values(
&schema,
doc.operations.anonymous.as_ref().unwrap(),
variables.as_object().unwrap(),
)
.unwrap_err();
let msg = one_of_error_message(err);
assert!(
msg.contains("must specify exactly one key"),
"unexpected error: {msg}"
);
}

#[test]
fn one_of_coercion_rejects_null_field_value() {
let (schema, doc) = one_of_schema_and_doc();
let variables = serde_json_bytes::json!({ "filter": { "byName": null } });
let err = coerce_variable_values(
&schema,
doc.operations.anonymous.as_ref().unwrap(),
variables.as_object().unwrap(),
)
.unwrap_err();
let msg = one_of_error_message(err);
assert!(msg.contains("must be non-null"), "unexpected error: {msg}");
}
}
5 changes: 5 additions & 0 deletions crates/apollo-compiler/src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,11 @@ impl EnumType {
}

impl InputObjectType {
/// Returns true if this is a OneOf Input Object (has the `@oneOf` directive).
pub fn is_one_of(&self) -> bool {
self.directives.get("oneOf").is_some()
}

/// Iterate over the `origins` of all components
///
/// The order of the returned set is unspecified but deterministic
Expand Down
91 changes: 91 additions & 0 deletions crates/apollo-compiler/src/validation/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,39 @@ pub(crate) enum DiagnosticData {
"{describe} cannot be named `{name}` as names starting with two underscores are reserved"
)]
ReservedName { name: Name, describe: &'static str },
#[error("`{coordinate}` field of a @oneOf input object must be nullable")]
OneOfInputObjectFieldNonNull {
coordinate: TypeAttributeCoordinate,
definition_location: Option<SourceSpan>,
},
Comment on lines +295 to +299
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.

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.

I don't think UnsupportedValueType quite fits here since its fields are {value, ty, definition_location} and scoped to value coercion. This variant here fires at schema validation, where there's no value only a type definition. It appears that schema-validation diagnostics in this codebase keep their own variants (e.g., RecursiveInputObjectDefinition, EmptyInputValueSet, etc.) for the same reason?

If the underlying ask is "make the name less @oneOf-specific," happy to rename to InputObjectFieldNonNull and let the help text (which is already using the shared ONE_OF_FIELD_REQUIREMENTS constant) carry the @oneOf context.

Does that get at the root or is it something else??

#[error(
"@oneOf input object `{name}` must specify exactly one key, but {provided} {} given",
if *provided == 1 { "was" } else { "were" }
)]
OneOfInputObjectWrongNumberOfFields { name: Name, provided: usize },
Comment on lines +300 to +304
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?

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.

I'm not sure the Unique* parallel quite holds.

All the existing Unique* variants (UniqueVariable, UniqueArgument, UniqueDirective, UniqueInputValue) mean "this thing was provided more than once". i.e. at most one. The @oneOf rule is exactly one, and this diagnostic also fires for zero fields, which doesn't fit the uniqueness framing.

Two alternatives worth considering:

  1. Split into OneOfInputObjectMissingField (zero case) and OneOfInputObjectMultipleFields (2+ case). Seems cleanly defined, but the con is it doubles the variant count for what's a single spec rule.
  2. Keep the single variant pragmatic given "exactly one of N" is, I think, currently @oneOf-"only" in GraphQL.

what's your take? same as before?

#[error("`{name}.{field}` value for @oneOf input object must be non-null")]
OneOfInputObjectNullField { name: Name, field: Name },
Comment on lines +305 to +306
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 also sounds like UnsupportedValueType rule.

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.

There's a clean middle ground here, I think.

Your underlying point that this shouldn't be @oneOf-specific is fair. However folding directly into UnsupportedValueType seems awkward because its message reads "expected value of type {ty}, found {value}", plus the declared ty is nullable, so "expected String, found null" reads as kinda misleading? (String does allow null at most positions; @oneOf is the special case).

Would you be in favor of a new generic variant like NullValueNotAllowed (or NullValueAtRequiredPosition?) that describes the actual invariant ("this position is treated as required even though the declared type allows null"). It'd be reusable for any future feature with the same shape; and carries the @oneOf specifics in help text.

Want me to take a swing at that?

#[error("`{coordinate}` field of a @oneOf input object must not have a default value")]
OneOfInputObjectFieldHasDefault {
coordinate: TypeAttributeCoordinate,
default_location: Option<SourceSpan>,
},
Comment on lines +307 to +311
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?

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.

Fair. will swap to UnsupportedDefault and move the @oneOf specifics to help text.

#[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>,
},
Comment on lines +312 to +321
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.

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.

Tried this but the field shape doesn't quite line up. Notes below!

DisallowedVariableUsage carries argument: Name and argument_type: Type but in this case here the variable's location is an input object field, not an argument. Folding into the other variant would mean either repurposing those fields with mismatched names, or renaming them to something neutral. The variant's string identifier is externally observable via mod.rs publicity so the ripple touches consumers matching on diagnostic kind too, right?

Three(?+) paths:

a. Keep OneOfInputObjectNullableVariable as a distinct variant which is most accurate semantically; consumers could match on it cleanly.
b. Rename DisallowedVariableUsage's fields to location_name / location_type and fold both cases. It can be an internal-only field rename, but the variant's meaning widens (which arguably has ripple risk for any consumer matching on it).
c. New generic InvalidVariableUsage variant covering both 👯 same field shape as today, just less @oneOf-specific in the naming of.

Thoughts? Mildly lean toward "a" since the location semantics seem genuinely different but happy to follow b/c if you'd rather see consolidation. Which do you prefer?

}

/// Shared help text for the two @oneOf schema-definition errors.
const ONE_OF_FIELD_REQUIREMENTS: &str =
"Fields of a @oneOf input object must all be nullable and must not have default values.";

impl DiagnosticData {
pub(crate) fn report(&self, main_location: Option<SourceSpan>, report: &mut CliReport) {
match self {
Expand Down Expand Up @@ -721,6 +752,66 @@ impl DiagnosticData {
DiagnosticData::ReservedName { name, .. } => {
report.with_label_opt(name.location(), "Pick a different name here");
}
DiagnosticData::OneOfInputObjectFieldNonNull {
coordinate,
definition_location,
} => {
report.with_label_opt(
*definition_location,
format_args!("field `{coordinate}` defined here"),
);
report.with_label_opt(main_location, "remove the `!` to make this field nullable");
report.with_help(ONE_OF_FIELD_REQUIREMENTS);
}
DiagnosticData::OneOfInputObjectWrongNumberOfFields { name, provided } => {
report.with_label_opt(
main_location,
format_args!(
"{provided} {} provided",
if *provided == 1 {
"field was"
} else {
"fields were"
}
),
);
report.with_help(format_args!(
"@oneOf input object `{name}` requires exactly one non-null field."
));
}
DiagnosticData::OneOfInputObjectNullField { name, field } => {
report.with_label_opt(main_location, "this value is null");
report.with_help(format_args!(
"@oneOf input object `{name}` field `{field}` must be non-null."
));
}
DiagnosticData::OneOfInputObjectFieldHasDefault {
coordinate,
default_location,
} => {
report.with_label_opt(
*default_location,
format_args!("default value for `{coordinate}` defined here"),
);
report.with_label_opt(main_location, "remove the default value");
report.with_help(ONE_OF_FIELD_REQUIREMENTS);
}
DiagnosticData::OneOfInputObjectNullableVariable {
name,
field,
variable,
variable_type,
} => {
report.with_label_opt(
main_location,
format_args!(
"variable `${variable}` has type `{variable_type}`, which is nullable"
),
);
report.with_help(format_args!(
"use `{variable_type}!` to make this variable non-nullable for @oneOf input object `{name}` field `{field}`."
));
}
}
}

Expand Down
Loading