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 24 commits into
base: main
Choose a base branch
from
Open

RFC: OneOf Input Objects #825

wants to merge 24 commits into from

Conversation

benjie
Copy link
Member

@benjie benjie commented Feb 19, 2021

First came the @oneField directive.

Then there was the Tagged type.

Introducing: OneOf Input Objects and OneOf Fields.

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 represented in introspection with the __Type.oneField: Boolean field, and in SDL via the @oneOf directive on the input object.

OneOf Fields are a special variant of Object Type fields where the type system asserts that exactly one of the field's arguments must be set and non-null, all others being omitted. This is represented in introspection with the __Field.oneArgument: Boolean! field, and in SDL via the @oneOf directive on the field.

(Why a directive? See the FAQ below.)

This variant introduces a form of input polymorphism to GraphQL. For example, the following PetInput input object lets you choose between a number of potential input types:

input PetInput @oneOf {
  cat: CatInput
  dog: DogInput
  fish: FishInput
}

input CatInput { name: String!, numberOfLives: Int }
input DogInput { name: String!, wagsTail: Boolean }
input FishInput { name: String!, bodyLengthInMm: Int }

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

Previously you may have had a situation where you had multiple ways to locate a user:

type Query {
  user(id: ID!): User
  userByEmail(email: String!): User
  userByUsername(username: String!): User
  userByRegistrationNumber(registrationNumber: Int!): User
}

with OneOf Input Objects you can now express this via a single field without loss of type safety:

input UserBy @oneOf {
  id: ID
  email: String
  username: String
  registrationNumber: Int
}
type Query {
  user(by: UserBy!): User
}

FAQ

Why is this a directive?

It's not. Well, not really - its an internal property of the type that's exposed through introspection - much in the same way that deprecation is. It just happens to be that after I analysed a number of potential syntaxes (including keywords and alternative syntax) I've found that the directive approach is the least invasive (all current GraphQL parsers can already parse it!) and none of the alternative syntaxes sufficiently justified the increased complexity they would introduce.

Why is this a good approach?

This approach, as a small change to existing types, is the easiest to adopt of any of the solutions we came up with to the InputUnion problem. It's also more powerful in that it allows additional types to be part of the "input union" - in fact any valid input type is allowed: input objects, scalars, enums, and lists of the same. Further it can be used on top of existing GraphQL tooling, so it can be adopted much sooner. Finally it's very explicit, so doesn't suffer the issues that "duck typed" input unions could face.

Why did you go full circle via the tagged type?

When the @oneField directive was proposed some members of the community felt that augmenting the behaviour of existing types might not be the best approach, so the Tagged type was born. (We also researched a lot of other approaches too.) However, the Tagged type brought with it a lot of complexity and controversy, and the Input Unions Working Group decided that we should revisit the simpler approach again. This time around I'm a lot better versed in writing spec edits 😁

Why are all the fields nullable? Shouldn't they be non-nullable?

To make this change minimally invasive I wanted:

  • to make it so that existing GraphQL clients could still validate queries against a oneOf-enabled GraphQL schema (if the fields were non-nullable the clients would think the query was invalid because it didn't supply enough data)
  • to allow existing GraphQL implementations to change as little code as possible

To accomplish this, we add the "exactly one value, and that value is non-null" as a validation rule that runs after all the existing validation rules - it's an additive change.

Can this allow a field to accept both a scalar and an object?

Yes!

input FindUserBy @oneOf {
  id: ID
  organizationAndRegistrationNumber: OrganizationAndRegistrationNumberInput
}

input OrganizationAndRegistrationNumberInput {
  organizationId: ID!
  registrationNumber: Int!
}

type Query {
  findUser(by: FindUserBy!): User
}

Can I use existing GraphQL clients to issue requests to OneOf-enabled schemas?

Yes - so long as you stick to the rules of one field / one argument manually - note that GraphQL already differentiates between a field not being supplied and a field being supplied with the value null.

Without explicit client support you may lose a little type safety, but all major GraphQL clients can already speak this language. Given this nonsense schema:

input FooBy @oneOf {
  id: ID
  str1: String
  str2: String
}
type Query {
  foo(by: FooBy!): String
}

the following are valid queries that you could issue from existing GraphQL clients:

  • {foo(by:{id: "..."})}
  • {foo(by:{str1: "..."})}
  • {foo(by:{str2: "..."})}
  • query Foo($by: FooBy!) {foo(by: $by)}

If my input object has only one field, should I use @oneOf?

Doing so would preserve your option value - making a OneOf Input Object into a regular Input Object is a non-breaking change (the reverse is a breaking change). In the case of having one field on your type changing it from oneOf (and nullable) to regular and non-null is a non-breaking change (the reverse is also true in this degenerate case). The two Example types below are effectively equivalent - both require that value is supplied with a non-null int:

input Example @oneOf {
  value: Int
}

input Example {
  value: Int!
}

Can we expand @oneOf to output types to allow for unions of objects, interfaces, scalars, enums and lists; potentially replacing the union type?

🤫 👀 😉

@benjie benjie changed the title RFC: Oneof Input Objects, Oneof Fields RFC: Oneof Input Objects and Oneof Fields Feb 19, 2021
@benjie benjie marked this pull request as ready for review Feb 19, 2021
@wyfo
Copy link

wyfo commented Feb 19, 2021

Is @oneOf missing in the example of section Can this allow a field to accept both a scalar and an object?

@wyattjoh
Copy link

wyattjoh commented Feb 19, 2021

I’d worry that statements around type safety are a little hard to apply in practice.

It’s not the case typically that a directive would change a types underlying type yet @oneOf seems to imply that “when the server gets this, it expect one of these to be non-null”. Off the bat, a standard GraphQL to Typescript conversion could do something like

type PetInput = { cat?: CatInput; dog?: DogInput; fish?: FishInput; }

When instead I’d expect it to do something like:

type PetInput = { cat: CatInput; } | { dog: DogInput; } | { fish: FishInput; };

I totally understand the motivation around the change to make it as low impact as possible, but I'd worry about the adverse side affects introduced by this subtle change to the ways that the null/non-null properties are determined.

Maybe I’m just applying my understanding incorrectly, but I’d hope that any adoption doesn’t in fact mutate the type system of GraphQL using directives like this.

@benjie
Copy link
Member Author

benjie commented Feb 20, 2021

@wyfo Thanks, fixed!

@wyattjoh It’s not a directive, it’s a new type system constraint that DOES model the type of the input differently and would have different types generated. Have a look at the alternative syntaxes document for other ways this could be exposed via SDL and let us know your preference, perhaps you would prefer the oneof keyword to make it clearer (in SDL only, this would not affect introspection) the change in behaviour.

@cometkim
Copy link

cometkim commented Feb 22, 2021

It looks like an existing syntax, but the semantics are different? I am worried that if it will end up asking for dirty exception handling for every directive code path.

Have a look at the alternative syntaxes document for other ways this could be exposed via SDL and let us know your preference

Could we consider a new syntax that hasn't been mentioned?

type  Query {
   user(id: ID!): User
   user(email: String!): User
   user(username: String!): User 
   user(registrationNumber: Int!): User
}

pros?:

  • it might be easy to apply because it just releases the existing constraints (that field names cannot duplicate on SDL)
  • it makes the schema can look intuitive for the possible input type.

cons:

  • Conversely, it makes it look like a variant is possible for the output.

@benjie
Copy link
Member Author

benjie commented Feb 22, 2021

@cometkim Can you show how that syntax would be expanded to input objects too, please? And yes we can absolutely consider alternative syntaxes.

@wyfo
Copy link

wyfo commented Feb 23, 2021

It’s not a directive

Why should it be something else than a directive?

Actually, it's already (almost) possible to implement @oneOf as a directive in a few lines of code.
I've made a Gist to show a possible implementation using Python and graphql-core (quite the reference translation of graphql-js in Python).
In fact, if the field directive is trivial, the input type directive requires in my example a graphql-core specific feature. However, the proposal of input object validation (still opened) could bring the material needed to implement it with graphql-js.

By the way, GraphQL schema is kind of poor in validation stuff (compared to JSON schema for example), so part of the validation is already done by the resolvers/scalar parsing methods. In a schema-first approach, you can also defines directives for repetitive checks, maybe with JSON schema-like annotations, but your code/library will have to translate and inject them into your resolvers/scalar types(/input types when the mentioned proposal will pass).
IMO, @oneOf should not be different as a directive, it could just be a validation marker used to add validation code in the resolvers/input type; no need of the type system validation. Also, in a code-first approach (no directives), it's already possible to support tagged unions, I do it in my own library; there is no need of the SDL.

In fact, I don't really see the interest of making @oneOf something else than a validation directive. And I'm wondering then if a validation directive would be appropriate in the GraphQL specification … Maybe it could be a kind of convention for schema-first libraries. Yet, having it in the specifications could help tooling (linters, code generators) and lighten GraphQL libraries. Anyway, night thoughts.

spec/Section 3 -- Type System.md Outdated Show resolved Hide resolved
spec/Section 3 -- Type System.md Outdated Show resolved Hide resolved
@sungam3r
Copy link
Contributor

sungam3r commented Feb 26, 2021

Can we expand @OneOf to output types to allow for unions of objects, interfaces, scalars, enums and lists; potentially replacing the union type?

For input types @oneOf implies one more nesting level. What do you think @oneOf will look like for unions?

@@ -156,6 +159,7 @@ type __Field {
type: __Type!
isDeprecated: Boolean!
deprecationReason: String
oneArgument: Boolean!
Copy link
Contributor

@sungam3r sungam3r Feb 26, 2021

Choose a reason for hiding this comment

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

Or oneArg to inline with args ?

spec/Section 5 -- Validation.md Outdated Show resolved Hide resolved
* {arguments} must contain exactly one entry.
* For the sole {argument} in {arguments}:
* Let {value} be the value of {argument}.
* {value} must not be the {null} literal.
Copy link
Contributor

@sungam3r sungam3r Feb 26, 2021

Choose a reason for hiding this comment

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

Is the word literal appropriate here in case of using variables? The same question about Oneof for Input Object.

Copy link
Member Author

@benjie benjie Feb 26, 2021

Choose a reason for hiding this comment

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

I believe so; I've modeled it on the language already used in this section, namely: https://spec.graphql.org/draft/#sel-LALTHHDHFFFJDAAACDJ-3S

spec/Section 3 -- Type System.md Outdated Show resolved Hide resolved
spec/Section 3 -- Type System.md Outdated Show resolved Hide resolved
@sungam3r
Copy link
Contributor

sungam3r commented Feb 26, 2021

It’s not a directive, it’s a new type system constraint that DOES model...

@benjie I don't understand. You wrote about @oneOf as a directive in the spec and at the same type talk here that it's not a directive... 😕

@benjie
Copy link
Member Author

benjie commented Feb 26, 2021

For input types @OneOf implies one more nesting level. What do you think @OneOf will look like for unions?

Another nesting level; i.e. instead of querying like:

{
  allEntities {
    ... on User { username }
    ... on Pet { name }
    ... on Car { registrationNumber }
    ... on Building { numberOfFloors }
  }
}

it'd look like:

{
  allEntities {
    user { username }
    pet { name }
    car { registrationNumber }
    building { numberOfFloors }
  }
}

@benjie
Copy link
Member Author

benjie commented Feb 26, 2021

@benjie I don't understand. You wrote about @OneOf as a directive in the spec and at the same type talk here that it's not a directive... confused

The input union working group have not decided what syntax to use for oneOf yet. It might end up as being presented as a directive, or it might be a keyword or any other combination of things. Check out this document for alternatives: https://gist.github.com/benjie/5e7324c64f42dd818b9c3ac2a91b6b12 and note that whichever alternative you pick only affects the IDL, it does not affect the functionality or appearance of GraphQL operations, validation, execution, etc. Please see the FAQ above.

TL;DR: do not judge the functionality of this RFC by its current IDL syntax. We can change the IDL syntax.

@sungam3r
Copy link
Contributor

sungam3r commented Feb 26, 2021

It might end up as being presented as a directive

OK. In my opinion if something is presented as a directive than ... it is just a directive.

@benjie
Copy link
Member Author

benjie commented Feb 26, 2021

Thanks for the review @sungam3r; good to have additional scrutiny! I don't think any modifications to the RFC are required to address your concerns (other than perhaps writing an alternative IDL syntax, but I don't plan to invest time in that until there's general concensus on what the syntax should be, for now the directive syntax can act as a placeholder). I think all the conversations in your review can be closed except for the oneArg suggestion; that one might require some more bike-shedding 😉

@leebyron leebyron added the 💡 Proposal (RFC 1) RFC Stage 1 (See CONTRIBUTING.md) label Mar 4, 2021
`$var` | `{ var: { a: "abc" } }` | `{ a: "abc" }`
`{ a: "abc", b: null }` | `{}` | Error: Exactly one key must be specified
`{ b: $var }` | `{ var: null }` | Error: Value for member field {b} must be non-null
`{ b: 123, c: "xyz" }` | `{}` | Error: Exactly one key must be specified
Copy link
Member

@eapache eapache Mar 4, 2021

Choose a reason for hiding this comment

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

Missing { a: $varA, b: $varB } with various combinations of values for varA and varB.

Copy link
Collaborator

@leebyron leebyron Mar 4, 2021

Choose a reason for hiding this comment

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

My in meeting proposal was that this case could just be invalid at start.

This L1441 in Validation file in this PR sounds like it would do just that:
https://github.com/graphql/graphql-spec/pull/825/files#diff-607ee7e6b71821eecadde7d92451b978e8a75e23d596150950799dc5f8afa43eR1441

Copy link
Member Author

@benjie benjie Mar 6, 2021

Choose a reason for hiding this comment

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

These are exactly the same as for input objects (which also don't specify what happens if you have multiple variables); but I'll add some for clarity.

Copy link
Member Author

@benjie benjie Mar 6, 2021

Choose a reason for hiding this comment

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

@leebyron Good catch; that was not my intent. I have updated the PR with better validation and more examples.

Copy link
Member Author

@benjie benjie Mar 6, 2021

Choose a reason for hiding this comment

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

I've since revisited my thoughts on this and for the sake of defining types of variables on the client I've adopted the suggestion: #825 (comment)

spec/Section 4 -- Introspection.md Outdated Show resolved Hide resolved
@moshest
Copy link

moshest commented May 26, 2022

Thank you for all the effort of making this feature happen!

I'm sorry if this is not the right place to write this feedback, but I have a major issue with this suggestion.

The following RFC forces me to define all properties as optional, when in fact, one of them must be required. I feel like this approach is not accurate enough.

Is it possible to support this feature using multiple signatures for the same input?

For example, instead of having:

input PetInput @oneOf {
  cat: CatInput
  dog: DogInput
  fish: FishInput
}

I rather to have:

input PetInput {
  cat: CatInput!
}

input PetInput {
  dog: DogInput!
}

input PetInput {
  fish: FishInput!
}

This of course adds more work for the schema validator, but it makes the schema much more readable and accurate.

In the future, we can use the same concept to support multiple signatures for arguments and allow users to fully customize their inputs as they wish:

type Mutation {
  createPet(cat: CatInput!): Cat!
  createPet(dog: DogInput!): Dog!
  createPet(fish: FishInput!): Fish!
}

Any thoughts or feedback?

@benjie
Copy link
Member Author

benjie commented May 26, 2022

@moshest Thanks for sharing your concerns; your suggestion is not dissimilar to one of the existing syntaxes we explored: https://gist.github.com/benjie/e45540ad25ce9c33c2a1552da38adb91#solution-a (The more union-style approach ({ cat: CatInput! } | { dog: DogInput! } | ...) doesn't have the same readability issues as your suggested syntax in that the type is defined in one location rather than many, but is broadly similar in goals.)

In the end, we determined that the directive approach was the way to go - it's more minimal, consistent, backwards and forwards compatible, and has various other advantages.

@benjie
Copy link
Member Author

benjie commented May 26, 2022

Hi everyone, following this RFC's advancement to RFC2 there have been two additional somewhat significant changes made:

  1. __Type.oneOf has been renamed to __Type.isOneOf for consistency with @deprecated / __Type.isDeprecated. Thanks to @IvanGoncharov for spotting.
  2. It is now forbidden to add the @oneOf directive to an input object extension (i.e. extend input FooInput @oneOf is forbidden); this is because it would be hard to validate this extension due to needing to retroactively apply to all other definitions of this type. Thanks to @n1ru4l for spotting.

Point 2 may require further discussion; I'll add it to the next WG.

@Shane32
Copy link

Shane32 commented Jun 9, 2022

@benjie In response to the working group meeting on 6/2/22, I have a few comments:

Allowing a oneof type's field's value to be null may complicate a C# implementation, because of there is no inherit ability to differentiate undefined from null such as is available in javascript. Within GraphQL.NET, we have made workarounds as needed to meet the spec, but often such workarounds become "kludgy". For instance, determining missing entries from input objects within GraphQL.NET requires notably excessive effort, as normally those missing members are simply marked as null. So from a C# perspective, allowing a nullable field within a oneof type would likely not be easy to use, even if supported.

I also cannot think of a practical scenario where a oneof type's field's value would need to be nullable, so I feel that the current recommendation is good -- that in a oneof input type, either the entire type is null, or a single field exists and is non-null. 👍

I also agree with the current design of the sdl/introspection representation, which seems to provide the most compatibility with older tooling. 👍

As for conflicts with existing uses of @oneOf, I would guess that many of these implementations are based on this pending RFC, and as such, users should expect it to change once approved as part of the spec. Such I would expect to be same for directive/metadata introspection -- we at GraphQL.NET support a beta/experimental implementation, which will change once the proper RFC is approved. Similarly, if we supported a draft @oneOf implementation, it would likely be locked behind an experimental server-side flag.

Finally, since directives cannot be returned through introspection queries (official introspection queries, anyway), it seems that adding @oneOf here would not necessarily change a client contract for the GraphQL schema, even if the server had used @oneOf in their sdl and needed to rename their directive. It may, of course, depending on if directives or the sdl were exposed in another manner.

In short, I'm happy with the RFC as currently proposed 👍

@n1ru4l
Copy link

n1ru4l commented Jun 9, 2022

btw we just merged @oneOf support in GraphQL Code Conerator dotansimha/graphql-code-generator#7886

@ethanresnick
Copy link

ethanresnick commented Jul 13, 2022

Does this proposal handle the case where one of the constituents in the oneOf "union" doesn't need any data? Would it make sense, for these cases, to allow input objects to be empty?

I'm thinking about use cases like:

input OperationInput @oneOf {
  checkAndSet: CheckAndSetOperationInput
  delete: ???? # if delete is to be invoked, it doesn't need any extra fields, so what goes here
}

input CheckAndSetOperationInput { 
  check: Int! 
  set: Int! 
}

or, similarly:

input ReferralSource @oneOf {
  friend: FriendReferralInput
  google: ??? # similarly, a google referral doesn't support extra fields, so what goes here
}

input FriendReferralInput { friendsEmail: String! }

How should these cases be handled? My intuition is to allow input types to be empty, as in:

input OperationInput @oneOf {
  checkAndSet: CheckAndSetOperationInput
  delete: DeleteOperationInput
}

# this line isn't currently allowed
input DeleteOperationInput

I guess another option would be to put a dummy, nullable field on the input (input DeleteOperationInput { dummy: Int }), but that seems ugly.

Similarly, the dummy value could go in the @oneOf input itself, i.e.:

input OperationInput @oneOf {
  checkAndSet: CheckAndSetOperationInput
  delete: Bool # dummy bool, unused
}

But that seems at least as ugly, and less extensible by the API creator.

Alternatively, maybe some reserved value could be defined by GraphQL that could be used in the input to indicate "I'm selecting the delete case, but providing no extra data". The obvious candidate for such a value is null, since its an already-existing, JSON-serializable scalar that'd be available for this purpose. However, inputting { "delete": null } seems like quite a confusing way for the caller to indicate that they'd like the delete operation.

Are there other options here? Something in the proposal already (or in the past discussions) that I'm missing?

@n1ru4l
Copy link

n1ru4l commented Jul 13, 2022

@ethanresnick This is a bit related to #568 (comment)

Maybe in the future, you might wanna add sub-options for OperationInput.delete, in that case using an empty object is the best bet.

type OperationInputDelete {
  # noop field
  _: Boolean
}

The following values are legit:

{
  "variables": {
    "operationInput": {
      "delete": null
   }
  }
}
{
  "variables": {
    "operationInput": {
      "delete": {}
    }
  }
}
{
  "variables": {
    "operationInput": {
      "delete": {
        "_": true
      }
    }
  }
}

On the other hand, you could also. later introduce an additional field to OperationInput later on.

input OperationInput @oneOf {
  checkAndSet: CheckAndSetOperationInput
  delete: Boolean
  deleteNew: OperationDeleteInput
}

In graphql-scalars we also have a Void type. https://github.com/Urigo/graphql-scalars/blob/b4f4ffdb2ab154b91c9a2615549707c26d2d0636/src/scalars/Void.ts#L3-L23

@ethanresnick
Copy link

ethanresnick commented Jul 13, 2022

@n1ru4l Yes, this is very similar to #568; thanks for that reference! I guess just consider my comment as a vote in favor of #568 then, which doesn't necessarily have to be resolved with this RFC. This RFC and #568 both seem to address ADT-like use cases, so I was kinda surprised to see no one mentioning support for the empty object cases here.

For now, I'll stick with the dummy nullable boolean; I agree that's the best option today (it just still feels hacky).

@benjie
Copy link
Member Author

benjie commented Jul 13, 2022

Minor correction:

{
  "variables": {
    "operationInput": {
      "delete": null
    }
  }
}

^ This is not a valid value for a oneOf; null is explicitly forbidden in both the validation section and in the input coercion section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📄 Draft (RFC 2) RFC Stage 2 (See CONTRIBUTING.md)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet