Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: OneOf Input Objects #825

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c385058
Renumber list items
benjie Feb 19, 2021
f6bd659
@oneOf input objects
benjie Feb 19, 2021
39e593c
@oneOf fields
benjie Feb 19, 2021
b6741c3
Fix typos (thanks @eapache!)
benjie Feb 26, 2021
d17d5ec
Much stricter validation for oneof literals (with examples)
benjie Mar 6, 2021
dca3826
Add missing coercion rule
benjie Mar 6, 2021
7e02f5a
Clearer wording of oneof coercion rule
benjie Mar 6, 2021
4111476
Add more examples for clarity
benjie Mar 6, 2021
6754e0a
Rename introspection fields to oneOf
benjie Mar 6, 2021
7c4c1a2
Oneof's now require exactly one field/argument, and non-nullable vari…
benjie Mar 6, 2021
bb225f7
Remove extraneous newline
benjie Mar 6, 2021
05fde06
graphgl -> graphql
benjie Apr 8, 2021
e8f6145
Apply suggestions from @eapache's review
benjie Apr 8, 2021
08abf49
Apply suggestions from code review
benjie Dec 23, 2021
59cb12d
Update spec/Section 3 -- Type System.md
benjie Jan 4, 2022
c470afb
Merge branch 'main' into oneof-v2
benjie Mar 22, 2022
99aa5d9
Remove Oneof Fields from spec
benjie Mar 22, 2022
691087d
Oneof -> OneOf
benjie Mar 22, 2022
7109dbc
Spellings
benjie Mar 22, 2022
05ab541
Remove out of date example
benjie May 6, 2022
6a6be52
Rename __Type.oneOf to __Type.isOneOf
benjie May 25, 2022
de87d2f
Add a:null example
benjie May 25, 2022
57e2388
Rewrite to avoid ambiguity of language
benjie May 25, 2022
5a966f2
Forbid 'extend input' from introducing the @oneOf directive
benjie May 26, 2022
e78d2b5
Merge branch 'main' into oneof-v2
benjie Nov 13, 2023
c6cd857
Merge branch 'main' into oneof-v2
benjie Mar 27, 2024
d106233
Add yet more examples to the example coercion table
benjie Mar 27, 2024
87d0b22
Indicate `@oneOf` is a built-in directive
benjie Jun 4, 2024
d88d62a
Update spec/Section 3 -- Type System.md
benjie Jun 5, 2024
a810aef
Merge branch 'main' into oneof-v2
benjie Jul 19, 2024
a1563a9
remove OneOf-specific rule in favor of update to VariablesInAllowedPo…
yaacovCR Sep 21, 2024
b45c0e4
Clarify IsNonNullPosition algorithm
benjie Oct 17, 2024
0c9830e
Clarify OneOf examples
benjie Oct 17, 2024
c4d0b50
Add more examples
benjie Oct 17, 2024
340594e
Remove new validation rule in favour of updates to existing rules
benjie Oct 17, 2024
dbccf84
Null literal is separate
benjie Oct 17, 2024
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
2 changes: 2 additions & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ words:
- tatooine
- zuck
- zuckerberg
- brontie
- oneOf
# Forbid Alternative spellings
flagWords:
- implementor
Expand Down
106 changes: 106 additions & 0 deletions spec/Section 3 -- Type System.md
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,28 @@ define arguments or contain references to interfaces and unions, neither of
which is appropriate for use as an input argument. For this reason, input
objects have a separate type in the system.

**OneOf Input Objects**

OneOf Input Objects are a special variant of Input Objects where the type system
asserts that exactly one of the fields must be set and non-null, all others
being omitted. This is useful for representing situations where an input may be
one of many different options.

When using the type system definition language, the `@oneOf` directive is used
to indicate that an Input Object is a OneOf Input Object (and thus requires
exactly one of its field be provided):

```graphql
input UserUniqueCondition @oneOf {
id: ID
username: String
organizationAndEmail: OrganizationAndEmailInput
}
```

In schema introspection, the `__Type.isOneOf` field will return {true} for OneOf
benjie marked this conversation as resolved.
Show resolved Hide resolved
Input Objects, and {false} for all other Input Objects.

**Circular References**

Input Objects are allowed to reference other Input Objects as field types. A
Expand Down Expand Up @@ -1659,6 +1681,21 @@ is constructed with the following rules:
does not provide a default value, the input object field definition's default
value should be used.

Further, if the input object is a OneOf Input Object, the following additional
rules apply:

- If the input object literal or unordered map does not contain exactly one
entry an error must be thrown.

- Within the input object literal or unordered map, if the single entry is
{null} or the {null} literal an error must be thrown.

- If the coerced unordered map does not contain exactly one entry an error must
be thrown.

- If the value of the single entry in the coerced unordered map is {null} an
error must be thrown.

Following are examples of input coercion for an input object type with a
`String` field `a` and a required (non-null) `Int!` field `b`:

Expand Down Expand Up @@ -1688,6 +1725,46 @@ input ExampleInputObject {
| `{ b: $var }` | `{ var: null }` | Error: {b} must be non-null. |
| `{ b: 123, c: "xyz" }` | `{}` | Error: Unexpected field {c} |

Following are examples of input coercion for a oneOf input object type with a
`String` member field `a` and an `Int` member field `b`:

```graphql example
input ExampleInputTagged @oneOf {
a: String
b: Int
}
```

| Literal Value | Variables | Coerced Value |
| ------------------------ | -------------------------------- | --------------------------------------------------- |
| `{ a: "abc", b: 123 }` | `{}` | Error: Exactly one key must be specified |
| `{ a: null, b: 123 }` | `{}` | Error: Exactly one key must be specified |
| `{ a: null, b: null }` | `{}` | Error: Exactly one key must be specified |
| `{ a: null }` | `{}` | Error: Value for member field {a} must be non-null |
| `{ b: 123 }` | `{}` | `{ b: 123 }` |
| `{}` | `{}` | Error: Exactly one key must be specified |
| `{ a: $a, b: 123 }` | `{ a: null }` | Error: Exactly one key must be specified |
| `{ a: $a, b: 123 }` | `{}` | Error: Exactly one key must be specified |
| `{ a: $a, b: $b }` | `{ a: "abc" }` | Error: Exactly one key must be specified |
| `{ b: $b }` | `{ b: 123 }` | `{ b: 123 }` |
| `$var` | `{ var: { b: 123 } }` | `{ b: 123 }` |
| `$var` | `{ var: { a: "abc", b: 123 } }` | Error: Exactly one key must be specified |
| `$var` | `{ var: { a: "abc", b: null } }` | Error: Exactly one key must be specified |
| `$var` | `{ var: { a: null } }` | Error: Value for member field {a} must be non-null |
| `$var` | `{ var: {} }` | Error: Exactly one key must be specified |
| `"abc123"` | `{}` | Error: Incorrect value |
| `$var` | `{ var: "abc123" } }` | Error: Incorrect value |
| `{ a: "abc", b: "123" }` | `{}` | Error: Exactly one key must be specified |
| `{ b: "123" }` | `{}` | Error: Incorrect value for member field {b} |
| `$var` | `{ var: { b: "abc" } }` | Error: Incorrect value for member field {b} |
| `{ a: "abc" }` | `{}` | `{ a: "abc" }` |
| `{ b: $b }` | `{}` | Error: Value for member field {b} must be specified |
| `$var` | `{ var: { a: "abc" } }` | `{ a: "abc" }` |
| `{ a: "abc", b: null }` | `{}` | Error: Exactly one key must be specified |
| `{ b: $b }` | `{ b: null }` | Error: Value for member field {b} must be non-null |
| `{ b: 123, c: "xyz" }` | `{}` | Error: Unexpected field {c} |
| `$var` | `{ var: { b: 123, c: "xyz" } }` | Error: Unexpected field {c} |

benjie marked this conversation as resolved.
Show resolved Hide resolved
**Type Validation**

1. An Input Object type must define one or more input fields.
michaelstaib marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -1700,6 +1777,9 @@ input ExampleInputObject {
returns {true}.
4. If input field type is Non-Null and a default value is not defined:
1. The `@deprecated` directive must not be applied to this input field.
5. If the Input Object is a OneOf Input Object then:
1. The type of the input field must be nullable.
2. The input field must not have a default value.
3. If an Input Object references itself either directly or through referenced
Input Objects, at least one of the fields in the chain of references must be
either a nullable or a List type.
Expand Down Expand Up @@ -1727,6 +1807,12 @@ defined.
the original Input Object.
4. Any non-repeatable directives provided must not already apply to the original
Input Object type.
5. The `@oneOf` directive must not be provided by an Input Object type
extension.
Copy link

@bbakerman bbakerman Jul 30, 2023

Choose a reason for hiding this comment

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

The @oneOf directive must not be provided by an Input Object type
extension.

Why is this the case? extend is useful for tooling to "create types" in a peicemeal fashion. Not everyone needs to know all the details but it needs to be validated at the end at actual schema creation time

#fileA.graphqls
input Foo {
   a : String
}

and

#fileB.graphqls
extend input Foo @oneOf {
   b : String
}

The following once combined is valid surely?

if we had an invalid combination say

#fileA.graphqls
input Foo {
   a : String!
}

and

#fileB.graphqls
extend input Foo @oneOf {
   b : String
}

then this can be validated at the time the actual runtime schema object is created.

Copy link
Member Author

Choose a reason for hiding this comment

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

Twofold: it’s because a ”OneOf Input Object” may be constructed in a different way to traditional input objects (might use a different class or similar), and because if this was not the case then when the directive was added you’d have to go back and validate all previously added fields since they have different “potential to be invalid” text.

Copy link

@bbakerman bbakerman Jul 31, 2023

Choose a reason for hiding this comment

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

Hmm kinda feels like this is projecting an implementation into the spec situation.

it’s because a ”OneOf Input Object” may be constructed in a different way to traditional input objects (might use a different class or similar)

I would argue that the spec should not care how its implemented - sure it could be a new OneOfInputType class at implementation time (I toyed with this in graphql-java but rejected it as too much impact) but the spec shouldn't care.

When the directive was added you’d have to go back and validate all previously added fields

Again an implementation detail - not sure that the spec should care.

Even merging them seems straightforward since they are a non repeating directive

#fileA.graphqls
input Foo {
   a : String!
}
#fileB.graphqls
extend input Foo @oneOf {
   b : String
}
#fileC.graphqls
extend input Foo @oneOf {
   c : String
}

And implementation can merge these easily enough.

Right now the spec says about input types

extend input [Name] [Directives]opt [InputFieldsDefinition]
extend input [Name] [Directives]opt 

in other words it allows you to put extra directives on the input types during extension

I just feel like this is an unnecessary restriction

Copy link
Member Author

@benjie benjie Jul 31, 2023

Choose a reason for hiding this comment

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

I would argue that the spec should not care how its implemented - sure it could be a new OneOfInputType class at implementation time (I toyed with this in graphql-java but rejected it as too much impact) but the spec shouldn't care.

I agree, I'd argue that implementing Input Objects and OneOf Input Objects as the same class in the implementation is an implementation detail that the spec doesn't need to worry about 😉

At one point, oneOf was going to be its own top-level type, like oneof Foo { a: Int, b: Float } rather than input Foo @oneof { a: Int, b: Float }. Would you feel the same if we had chosen the alternative syntax?

To me, a OneOf Input Object is a distinct type in GraphQL that happens to share a lot with Input Objects, but still has it's own distinct behaviors in many ways. As such, changing an Input Object into a OneOf Input Object would be like changing an Input Object into an Object for me - and that's not something I think the SDL should allow.

This is one of the arguments against using the directive syntactical representation in my opinion, and encouraging using a keyword instead - to make the separation clearer.

Again an implementation detail - not sure that the spec should care.

This second point is not an implementation detail, it's a spec detail - specifically it relates to lines 1724-1726 in spec/Section 3 -- Type System.md - that's the validation rules for the definition of an input - if it's defined as input Foo @oneOf then additional rules apply. Then similar rules are applied on lines 1756-1759 for fields added via an input type extension. If we allow the directive to be added during an input type extension then the previously performed rules in the input type definition would now be invalidated and we'd need to re-check them; I can't think of a place where we've done this kind of back reference and re-validation in a similar part of the spec.

Copy link

@Shane32 Shane32 Jul 31, 2023

Choose a reason for hiding this comment

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

Along the same thoughts that @benjie is pointing out, I personally would say a different syntactical representation in the SDL would make it appear as a "distinct type", whereas since it is a directive, it is simply additional validation rules on top of the existing input object type. Keep in mind that directives are described as such:

A GraphQL schema describes directives which are used to annotate various parts of a GraphQL document as an indicator that they should be evaluated differently by a validator, executor, or client tool such as a code generator.

I think this definition fits @oneOf precisely - "evaluated differently by a validator". This PR also describes oneOf input objects as such:

OneOf Input Objects are a special variant of Input Objects

So the way this PR is written, I see @oneOf as a predefined directive, just like @skip or @include or @deprecated, that converts the input object it is decorating into the 'special variant'. As such, I would personally agree with @bbakerman in that the directive should be able to be applied anywhere it has always been, and hence the extra validation checks applied if the schema requires it after being merged.

Copy link

Choose a reason for hiding this comment

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

If directives were intended to merely annotate types, and not to modify their behavior, then perhaps @oneOf should not be allowed on extension types. But the spec clearly indicates that directives' purpose is to modify behavior, and that they are allowed on extension types.

Copy link
Member Author

Choose a reason for hiding this comment

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

OneOf Input Objects are a special variant of Input Objects

Indeed, they are a special variant. In my opinion you wouldn't take a broad thing and then turn it into a special variant at a later stage - it needs to be a special variant from the start. But this is my opinion, I'm happy to test it against the broader working group.


Imagine for a second that we didn't have input objects. You could imagine taking an object type and tagging it @input:

type UserInput @input {
  id: ID!
  name: String!
  bio: String
}

This syntactic change could indicate:

  1. that the type cannot have arguments on any fields
  2. that the unwrapped type of each of the fields can only use scalars, enums, or other @input types
  3. that the type cannot be used as the type of any field, other than a field on an @input type
  4. that the type can now be used as the type of arguments

This is essentially the input object type at this point. But all we've done as annotated the existing object type with different validation logic and execution behaviors. Is the input object type really not distinct from the object type? Would turning an object type into an @input object type be a reasonable thing to do later down in the SDL? I, personally, don't think so. I think doing so would be extremely confusing for people reading the SDL - where something suddenly changes from one type of thing to another.

This, again, is one of the big issues that I have with using a directive to represent this distinct type. (I'm also in favour of using the directive because it's the smallest change, and allows it to be compatible with existing clients.) This constraint: that you can't add the directive in an extension, is my compromise - to allow us to leverage the benefits of using a directive without the footguns in terms of changing one thing into another at a later stage.

6. If the original Input Object is a OneOf Input Object then:
1. All fields of the Input Object type extension must be nullable.
2. All fields of the Input Object type extension must not have default
values.

## List

Expand Down Expand Up @@ -1949,6 +2035,9 @@ schema.
GraphQL implementations that support the type system definition language should
provide the `@specifiedBy` directive if representing custom scalar definitions.

GraphQL implementations that support the type system definition language should
provide the `@oneOf` directive if representing OneOf Input Objects.

When representing a GraphQL schema using the type system definition language any
_built-in directive_ may be omitted for brevity.

Expand Down Expand Up @@ -2162,3 +2251,20 @@ to the relevant IETF specification.
```graphql example
scalar UUID @specifiedBy(url: "https://tools.ietf.org/html/rfc4122")
```

### @oneOf

```graphql
directive @oneOf on INPUT_OBJECT
```

The `@oneOf` _built-in directive_ is used within the type system definition
language to indicate an Input Object is a OneOf Input Object.

```graphql example
input UserUniqueCondition @oneOf {
id: ID
username: String
organizationAndEmail: OrganizationAndEmailInput
}
```
4 changes: 4 additions & 0 deletions spec/Section 4 -- Introspection.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ type __Type {
ofType: __Type
# may be non-null for custom SCALAR, otherwise null.
specifiedByURL: String
# should be non-null for INPUT_OBJECT only
benjie marked this conversation as resolved.
Show resolved Hide resolved
isOneOf: Boolean
}

enum __TypeKind {
Expand Down Expand Up @@ -373,6 +375,8 @@ Fields\:
- `inputFields` must return the set of input fields as a list of `__InputValue`.
- Accepts the argument `includeDeprecated` which defaults to {false}. If
{true}, deprecated input fields are also returned.
- `isOneOf` must return {true} when representing a OneOf Input Object, {false}
otherwise.
- All other fields must return {null}.

**List**
Expand Down
87 changes: 85 additions & 2 deletions spec/Section 5 -- Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ type Query {
findDog(searchBy: FindDogInput): Dog
}

type Mutation {
addPet(pet: PetInput!): Pet
addPets(pet: [PetInput!]!): [Pet]
}

enum DogCommand {
SIT
DOWN
Expand Down Expand Up @@ -93,6 +98,23 @@ input FindDogInput {
name: String
owner: String
}

input CatInput {
name: String!
nickname: String
meowVolume: Int
}

input DogInput {
name: String!
nickname: String
barkVolume: Int
}

input PetInput @oneOf {
cat: CatInput
dog: DogInput
}
```

## Documents
Expand Down Expand Up @@ -1334,6 +1356,12 @@ query goodComplexDefaultValue($search: FindDogInput = { name: "Fido" }) {
name
}
}

mutation addPet($pet: PetInput! = { cat: { name: "Brontie" } }) {
addPet(pet: $pet) {
name
}
}
```

Non-coercible values (such as a String into an Int) are invalid. The following
Expand All @@ -1349,6 +1377,24 @@ query badComplexValue {
name
}
}

mutation oneOfWithNoFields {
addPet(pet: {}) {
name
}
}

mutation oneOfWithTwoFields($dog: DogInput) {
addPet(pet: { cat: { name: "Brontie" }, dog: $dog }) {
name
}
}

mutation listOfOneOfWithNullableVariable($dog: DogInput) {
addPets(pets: [{ dog: $dog }]) {
name
}
}
```

### Input Object Field Names
Expand Down Expand Up @@ -1875,8 +1921,8 @@ IsVariableUsageAllowed(variableDefinition, variableUsage):
- Let {variableType} be the expected type of {variableDefinition}.
- Let {locationType} be the expected type of the {Argument}, {ObjectField}, or
{ListValue} entry where {variableUsage} is located.
- If {locationType} is a non-null type AND {variableType} is NOT a non-null
type:
- If {IsNonNullPosition(locationType, variableUsage)} AND {variableType} is NOT
a non-null type:
- Let {hasNonNullVariableDefaultValue} be {true} if a default value exists for
{variableDefinition} and is not the value {null}.
- Let {hasLocationDefaultValue} be {true} if a default value exists for the
Expand All @@ -1887,6 +1933,15 @@ IsVariableUsageAllowed(variableDefinition, variableUsage):
- Return {AreTypesCompatible(variableType, nullableLocationType)}.
- Return {AreTypesCompatible(variableType, locationType)}.

IsNonNullPosition(locationType, variableUsage):

- If {locationType} is a non-null type, return {true}.
- If the location of {variableUsage} is an {ObjectField}:
- Let {parentLocationType} be the expected type of {ObjectField}'s parent
{ObjectValue}.
- If {parentLocationType} is a OneOf Input Object type, return {true}.
- Return {false}.

AreTypesCompatible(variableType, locationType):

- If {locationType} is a non-null type:
Expand Down Expand Up @@ -1975,6 +2030,34 @@ query listToNonNullList($booleanList: [Boolean]) {
This would fail validation because a `[T]` cannot be passed to a `[T]!`.
Similarly a `[T]` cannot be passed to a `[T!]`.

Variables used for OneOf Input Object fields must be non-nullable.

```graphql example
mutation addCat($cat: CatInput!) {
addPet(pet: { cat: $cat }) {
name
}
}
mutation addCatWithDefault($cat: CatInput! = { name: "Brontie" }) {
addPet(pet: { cat: $cat }) {
name
}
}
```

```graphql counter-example
mutation addNullableCat($cat: CatInput) {
addPet(pet: { cat: $cat }) {
name
}
}
mutation addNullableCatWithDefault($cat: CatInput = { name: "Brontie" }) {
addPet(pet: { cat: $cat }) {
name
}
}
```

**Allowing Optional Variables When Default Values Exist**

A notable exception to typical variable type compatibility is allowing a
Expand Down
Loading