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
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>,
},
#[error(
"@oneOf input object `{name}` must specify exactly one key, but {provided} {} given",
if *provided == 1 { "was" } else { "were" }
)]
OneOfInputObjectWrongNumberOfFields { name: Name, provided: usize },
#[error("`{name}.{field}` value for @oneOf input object must be non-null")]
OneOfInputObjectNullField { name: Name, field: Name },
#[error("`{coordinate}` field of a @oneOf input object must not have a default value")]
OneOfInputObjectFieldHasDefault {
coordinate: TypeAttributeCoordinate,
default_location: Option<SourceSpan>,
},
#[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>,
},
}

/// 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