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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: OneOf Input Objects #825

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open

RFC: OneOf Input Objects #825

wants to merge 27 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 February 19, 2021 16:54
@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

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

It鈥檚 not the case typically that a directive would change a types underlying type yet @oneOf seems to imply that 鈥渨hen 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鈥檇 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鈥檓 just applying my understanding incorrectly, but I鈥檇 hope that any adoption doesn鈥檛 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鈥檚 not a directive, it鈥檚 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

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鈥檚 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

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

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 ?

* {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

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

@sungam3r
Copy link
Contributor

sungam3r commented Feb 26, 2021

It鈥檚 not a directive, it鈥檚 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

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

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

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

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

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)

@david-kubecka
Copy link

Regarding the chosen syntax: I might be late to the party (or rather missing for a longer time as I used to follow the input union type topic 2 years ago) but I wonder whether the issues we are dealing with don't have deeper roots, namely that this input union proposal isn't symmetric with the current output union.

My main issue when I first started working with GraphQl was that given a schema

type Pet = Cat | Dog

type Query {
  getPet(filter: ...): Pet
}

I can call getPet obtaining {"name": ..., "puppies": [...]} but I'm unable to create a corresponding addPet(pet: ...) mutation which would accept as its input exactly the output of getPet (possibly except for null). The use case for this behaviour is quite clear, e.g. transferring a pet to a different owner. With the current schema without input unions, one would have to non-trivially (i.e. non-identically) transform the output type to an input type, and this seems to be true even for the oneOf proposal presented here.

So I wonder why we just can't have

input PetInput = CatInput | DogInput

type Mutation {
  addPet(pet: PetInput)
}

I'm sure this must have been discussed somewhere but since I don't see it in the FAQ section, or the linked documents, I just want to revisit it (again). Even if there are good arguments against the "symmetric" input union I would still like to stress the importance of input/output types symmetry.

@aleksandarsusnjar
Copy link

aleksandarsusnjar commented Dec 3, 2023

@david-kubecka:

So I wonder why we just can't have

input PetInput = CatInput | DogInput

type Mutation {
  addPet(pet: PetInput)
}

I can answer that. In short: it's not that we can't, really, it is what that would entail.

Think of that output "flow":

  1. The server "knows" which type it has.
  2. The server outputs data requested by the client, matching any/all type-specific fragments as specified.
  3. The client gets the data. It can determine the exact type of the data received by having requested __typename or similar.

Now flip the roles:

  1. The client "knows" which type it has.
  2. The client includes the data it wants, while honoring the schema.
  3. The server gets the data. It can determine the exact type of the data received by .... hmm... how?

Some choices:

  1. Have a required, non-null, input field or argument literally name the type. But you're at the mercy of the client being "truthful": addPet(petType: "DogInput" pet: { meowsALot: true ... })
  2. Introduce an explicit type spec, like some languages have or use type casts for, say addPet(pet: (CatInput){...}).... This can work for nulls too.
  3. Move the type reference from (1) and/or (2) into a field name: addPet(pet: {CatInput: { meowsALot: true ... }}) .... This is a true type union - each type can appear at most once in that union.
  4. Extend/modify (3) so that the field refers to an independently named option/variant, rather than the type, thus allowing multiple different options to share the same type: addPet(pet: {kitten: { meowsALot: true ...}}) .... This is what @oneOf is.

See the issue? The symmetry is possible but GraphQL is already asymmetric. It (presently) has no means to introspect the input. We could try to add it, but it would add complexity to the client. @oneOf may not be the input introspection solver but it "scratches the itch" and offers additional benefits beyond simple input unions while not preventing that introspection to be added later, if ever.

@benjie
Copy link
Member Author

benjie commented Dec 4, 2023

I should point out again that though @oneOf can be used for input polymorphism, and so is included under the "input union RFC" and some people look at it through that lens only, it actually has wider uses. One of the main uses I want is to reduce the number of "finder" fields for a particular type:

type Query {
  userById(id: ID!): User
  userByEmail(email: String!): User
  userByUsername(username: String!): User
  userByOrganizationIdAndRegistrationNumber(organizationId: String!, registrationNumber: String): User
  # etc
}

could just use a single user(by:) field instead:

type Query {
  user(by: UserByInput!): User
}
input UserByInput @oneOf {
  id: ID
  email: String
  username: String
  orgReg: OrgRegInput
}
input OrgRegInput {
  organizationId: String
  registrationNumber: String
}

Note that with this it's perfectly reasonable for the same type (e.g. String) to be present more than once, and it also allows scalars (e.g. String) to take part in the union - no need for "boxed types".

@jasonkuhrt
Copy link

jasonkuhrt commented Dec 4, 2023

@benjie that looks like a specialization of a union type to me: type By = { method: 'id', id: string} | { method: 'email', email: string } | .... In other words a kind of sugar for a lower level concept. I would imagine starting with the sugar, instead of the building blocks, could lead to missed opportunities or subpar long term design results, but, I also suspect in the wider context of considerations there's a reason its at this point now and not somewhere else. I haven't personally gone deep or read all the thoughtful considerations to date.

TypeScript found a way to do tagged unions in an existing language via the heuristic of a common scalar field among all union members. I wonder if such a pragmatic approach was ever considered for GraphQL.

@aleksandarsusnjar
Copy link

@jasonkuhrt:

... I would imagine starting with the sugar, instead of the building blocks, could lead to missed opportunities or subpar long term design results, but, I also suspect in the wider context of considerations there's a reason its at this point now and not somewhere else.
...
... I wonder if such a pragmatic approach was ever considered for GraphQL.

Look at the very "opening" of this thread from @benjie:

First came #586.
Then there was #733.
Introducing: OneOf Input Objects and OneOf Fields.

Then note that @oneOf does not preclude anything else and has benefits (as in extra functionality, such fields with different names that nevertheless have the same type) that both @benjie and I seem to like. It solves this and other problems as well. As it does not preclude actual input unions, the only missed opportunity that I see presently is not releasing @oneOf on time. Some of us already use it, though it isn't a part of the spec. I have gone and considered it in a greater scope of other things.

The only extra consideration is whether to use a schema directive or introduce a new, dedicated, keyword for stricter control but reduced compatibility. I am fine with either approach. Interestingly enough, these don't exclude one another either.

@aleksandarsusnjar
Copy link

aleksandarsusnjar commented Dec 5, 2023

I fear that this may prolong the discussion... but I will note it anyway - an example of what a dedicated syntax could do, and some disadvantages of it. I will "imagine" a dedicated syntax here, say something like this:

Option 1: "Simplest"

oneOf PetInput {
  cat: CatInput
  dog: DogInput
  ...
}

Option 2: "Advanced (A)"

typeReference PetSpecies = Cat | Dog | ... # New, see why below, similar to enum

input PetInput { # No @oneOf, no new keyword here
  name: String!
  oneOf { # Notice no ':'
    massInKg: Float
    weightInLbOnEarth: Float
  }
  oneOf {
    height: Float
    length: Float
  }
  species: PetSpecies with { # Notice 'with {'
    Cat: {
      purringDb: Float
      whiskersLength: Float
    }
    Dog: {
      barkDb: Float
   }
  }
}

Option 2b: "Advanced (B)"

typeReference PetSpecies = Cat | Dog | ... # New, see why below, similar to enum

input PetInput { # No @oneOf, no new keyword here
  name: String!
  oneOf { # Notice no ':'
    massInKg: Float
    weightInLbOnEarth: Float
  }
  oneOf {
    height: Float
    length: Float
  }
  species: PetSpecies
  switch (species) {
    Cat: {
      purringDb: Float
      whiskersLength: Float
    }
    Dog: {
      barkDb: Float
   }
  }
}

Pros:

  1. Schema designers can enforce the framework's support for this feature. If the framework does not support it, it won't as easily ignore this as it could a directive.
  2. More concise, direct.
  3. Can more cleanly support "one-of groups".
  4. Could express relationships between some properties to some extent. For example, here we have a "standard" species field but depending on the type it references we require different sets of properties.
  5. It does not use (presently non-introspectable) schema directives for something that clearly must have an effect on introspection. However, GraphQL already opened that Pandora's box with some of standard/spec directives (e.g. @deprecated). That is a separate topic with separate RFCs. I have my own proposal.

Cons

  1. Same as "Pro (1)". That can be a detriment to some who want to be able to work with multiple frameworks with varying support levels. This feature could be explicitly implemented with the @oneOf directive in their code, rather than relying on the framework.
  2. More complexity is added to the spec that both servers and clients have to support to claim full GraphQL support. I don't think this may be a problem but there is a price to pay for this.
  3. New keywords appearing where field names are expected could be conflicted with existing field names in someone's schemas(s). I resolved it here by omitting the expected : and having nested {...} blocks. There are other options too.
  4. We need waaay more discussions to get this in the spec. Meanwhile we could be using @oneOf to solve most of the itch. ... @oneOf directive is equivalent to the "Option 1". We could introduce the keyword at any time (now or later) and claim that the directive and the keyword are non-conflicting and equivalent.

@benjie
Copy link
Member Author

benjie commented Dec 6, 2023

We discussed an approach like you describe above, @aleksandarsusnjar, and decided that moving things down a level would achieve the same goal without requiring the added complexity - this is also partly why "oneof arguments" was dropped (that, and because it limited extensibility of the field). You might find it discussed in one of the times the OneOf proposal was discussed at the WG: https://benjie.dev/rfcs/825 or maybe in the InputUnions WG: https://www.youtube.com/watch?v=rLkfz8Snw7M&list=PLP1igyLx8foH4M0YAbVqpSo2fS1ElvNVD

It does not use (presently non-introspectable) schema directives for something that clearly must have an effect on introspection.

Just to be clear and so no-one thinks that @oneOf support cannot be introspected, the fact that a type is a OneOf Input Object is indicated through introspection (via __Type.isOneOf), and represented via directive in SDL in the same way that a deprecated field is indicated through introspection (via __Field.isDeprecated/deprecationReason) and represented via directive in SDL.

As for #300, I also have an RFC for that: https://github.com/graphql/graphql-wg/blob/main/rfcs/AnnotationStructs.md

Regarding syntaxes/keywords, a lot of ideas were discussed (here's some I mooted) but the directive was seen as the simplest.

@tobiasdiez
Copy link
Contributor

I read some of the discussion above and in the linked old proposal, but it's getting rather long so I don't know if the following point was already made. I hope I don't add unnecessary noise.

Translating the proposed syntax

input Example @oneOf {
  a: A
  b: B
  c: C
  ...
}

to a hypothetical input union

input Example = { a: A! } | { b: B! } | { c: C! } | ...

one sees that the @oneOf proposal is (logically) equivalent to an input union with the constraints

  1. Every type involved in the union has to have exactly one field.
  2. These fields have to have different names.

As explained above in #825 (comment), some form of uniqueness is needed so that the server can correctly identify the correct type that is send by the user. But, if this is the only requirement, then the above constraints can be considerably be weakened to:

  1. The names of the required fields have to uniquely identify one of the types involved in the union.

From a server perspective, there is not much gained by the flexibility. But, I think, it makes the client side code easier. Continuing the example from #825 (comment), we have the two equivalent ways

type Query {
  user(by: UserByInput!): User
}
input UserByInput @oneOf {
  id: ID
  email: String
  username: String
  orgReg: OrgRegInput
}
input OrgRegInput {
  organizationId: String!
  registrationNumber: String
}
// or
input UserByInput = 
  { id: ID! } |
  { email: String! } |
  { username: String! } |
  { organizationId: String!, registrationNumber: String }

(Note that we inline OrgRegInput here, but don't have to) So if you want to run on the client-side you have (in pseudo-code)

// with @oneOf
client.user({ id: 'xyz' })
client.user({ orgReg: { organizationId: 'abc' })
// with input unions
client.user({ id: 'xyz' })
client.user({ organizationId: 'abc' })

What do you guys think?

@aleksandarsusnjar
Copy link

@benjie

We discussed an approach like you describe above, @aleksandarsusnjar, and decided that moving things down a level would achieve the same goal without requiring the added complexity ...
...
... but the directive was seen as the simplest.

Yep, I agree. I was just trying to illustrate, not propose an alternative, at least not yet.

@tobiasdiez:

one sees that the @OneOf proposal is (logically) equivalent to an input union with the constraints

  1. Every type involved in the union has to have exactly one field.
  2. These fields have to have different names.

Not sure what you're trying to point out here. (1) isn't a constraint of @oneOf but that of a traditional type union, mapped to fields. (2) is a standard constraint of pretty much anything, in any language.

The names of the required fields have to uniquely identify one of the types involved in the union.

I disagree with that. I illustrated it to help readers connect the dots but @oneOf does not require this and benefits from that. It can accomplish more without that. Note that @oneOf is not a type union and, I'd argue, it should not aspire to be one. It just addresses many present pain points for which one might think of type unions but in a simpler and different way.

@tobiasdiez
Copy link
Contributor

tobiasdiez commented Dec 6, 2023

@tobiasdiez:

one sees that the @OneOf proposal is (logically) equivalent to an input union with the constraints

  1. Every type involved in the union has to have exactly one field.
  2. These fields have to have different names.

Not sure what you're trying to point out here. (1) isn't a constraint of @oneOf but that of a traditional type union, mapped to fields.

It's a constraint that comes from the mapping to fields.
For example, { kitten: Kitten, dog: Dog } | { human: Human } is a valid type union, but mapped to fields it would read something like { animal: { kitten: Kitten, dog: Dog }, human: Human }. I argue above that there is no intrinsic need for the introduction of the additional nested animal (since both types involved in the union can be distinguished).

(2) is a standard constraint of pretty much anything, in any language.

The constraint is across all the fields in the union types. For example, it is not possible to have the type union { kitten: Kitten, age: int} | {dog: Dog, age: int} being mapped (via the above mapping) to a @oneOf type. You could write it as something like { kitten: { kitten: Kitten, age: int}, dog: {dog: Dog, age: int}.

It can accomplish more without that. Note that @oneOf is not a type union and, I'd argue, it should not aspire to be one.

I tried to show above that there is a 1-1-correspondence between @oneOf and certain type unions. In some ways it's just a different syntax, but with the added constraints 1 + 2. I think these constraints can be relaxed, but not easily so in the @oneOf syntax (you actually tried to do exactly that in #825 (comment)), but more easily in the input type syntax. So, almost by definition, the oneof can only accomplish less than the more flexible type union.

@benjie
Copy link
Member Author

benjie commented Dec 6, 2023

The constraint is across all the fields in the union types. For example, it is not possible to have the type union { kitten: Kitten, age: int} | {dog: Dog, age: int} being mapped (via the above mapping) to a @OneOf type. You could write it as something like { kitten: { kitten: Kitten, age: int}, dog: {dog: Dog, age: int}.

Indeed, if all fields are nullable then there's no way to discern what {age:3} means. I think you'd write it something like this? (I changed age which seems like it would be a property of Kitten/Dog to a duration of ownership instead, which makes sense to live outside of the animal model.)

input AnimalOwnership {
  animal: Animal!
  durationInDays: Int
  # ...
}
input Animal @oneOf {
  cat: Cat
  dog: Dog
}

@tobiasdiez
Copy link
Contributor

The constraint is across all the fields in the union types. For example, it is not possible to have the type union { kitten: Kitten, age: int} | {dog: Dog, age: int} being mapped (via the above mapping) to a @OneOf type. You could write it as something like { kitten: { kitten: Kitten, age: int}, dog: {dog: Dog, age: int}.

Indeed, if all fields are nullable then there's no way to discern what {age:3} means. I think you'd write it something like this? (I changed age which seems like it would be a property of Kitten/Dog to a duration of ownership instead, which makes sense to live outside of the animal model.)

input AnimalOwnership {
  animal: Animal!
  durationInDays: Int
  # ...
}
input Animal @oneOf {
  cat: Cat
  dog: Dog
}

Yes, that would work but doesn't let you reuse CatOwnership { cat: Cat!, durationInDays: Int} (and a similar DogOwnership) in case you use these in the rest of the schema.

All I wanted was to point out that typed unions are essentially equivalent, in the sense that everything you can model using on of the construction can be achieved in the other construction as well:

  • Every @oneOf construction can be mapped to a typed union:
@oneOf { a: A, b: B, ...} -> {a: A!} | {b: B!} | ...
  • Every typed union can be mapped to a @oneOf construction by introducing dummy fields
    { a_1: A_1, a_2: A_2, ...} | { b_1: B_1, b_2: B_2, ...} | ... -> @oneOf { a: { a_1: A_1, a_2: A_2, ...}, b: { b_1: B_1, b_2: B_2, ...}
    虁 ``
    
    

Although equivalent, I think the typed union yields easier to understand code and less nesting.

- Let {variableDefinition} be the {VariableDefinition} named
{variableName} defined within {operation}.
- Let {variableType} be the expected type of {variableDefinition}.
- {variableType} must be a non-null type.
Copy link
Contributor

@yaacovCR yaacovCR Mar 26, 2024

Choose a reason for hiding this comment

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

I wonder if these lines relating to variable usage pre-empt the discussion around #1059 and should be pulled from this spec change (simplifying it).

Variables must be only of the allowed type, but it seems that we should specify what that entails for all variables and types only in one place, i.e. the separate rule.

So if we currently allow variables of nullable types to be used in non-null positions and throw a field error at runtime -- which we do -we should continue to do so irrespective of isOneOf, and if/when we make the change there, that should be done in a way that covers isOneOf as well.

Encountered this while attempting to rebase graphql/graphql-js#3813

Copy link
Contributor

@yaacovCR yaacovCR Mar 26, 2024

Choose a reason for hiding this comment

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

There are two arguments against this:

  1. The benefit of the current relaxed version of VariablesInAllowedPositions is that you can use a variable for an argument, not supply the value, and get the default. But with OneOf, default values are not allowed for any fields, so treating this as the same as the general case would only be sensical if (a) we adopt the strict version of the general rule (b) we can convince ourselves that there is a real value in consistency almost for consistency's case.
  2. We can only consider this to be a specific case of the general rule if the @oneof directive is held to transform all of the input object's field types into non-nullable (but still not required) types. Then, these become non-nullable positions. There is a certain ambiguity as to whether the field types themselves are nullable or not. By syntax, we want to make sure older clients can leave them out, and so we define them to be nullable. But for clients aware of @oneof, presumably we are ok to define them as non-nullable, with the caveat that there would have to be a change to the IsRequiredInputField algorithm. Currently, an input object field is required if is of a non-null type and does not have a default value. This would have to be changed to have an additional condition, that the parent input object does not have isOneOf to be true. Note that IsRequiredInputField is part of graphql-js as a utility, and referenced many times in the spec, but does not form a formal agorithm.

Copy link
Member Author

Choose a reason for hiding this comment

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

I wonder if these lines relating to variable usage pre-empt the discussion around #1059 and should be pulled from this spec change (simplifying it).

I don't think so? Technically all fields on a oneof are nullable, but you must specify one and it must be non-null, so this seems a very straightforward way to require that when it comes to a variable? #1059 handles non-null positions, but this is a nullable position according to the type system.

Copy link
Contributor

@yaacovCR yaacovCR Mar 27, 2024

Choose a reason for hiding this comment

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

and it must be non-null

So it's a nullable type only because we want introspection to say that it's nullable because currently that's the only way of making something optional. But a null cannot be supplied, so in a sense it's by definition "a non-nullable position."

So we would then have to introduce the concept of non-nullable positions that occur when (1) the type for the position is non-nullable or (2) the containing type is oneOf, and then the general rule about matching nullable and non-nullable would have to depend on this new "position" concept rather than the type itself.

As I type this, I can see that this additional layer is a bad idea, and I appreciate the compromise that you have ended up with.

On the other hand, in GraphQL 2.0 / TreeQL, we should definitely separate optionality and nullability, and remember to change oneOf to be better defined. (It really shouldn't be the case that you cannot use null at a nullable position.)

@yaacovCR
Copy link
Contributor

In the working group, Lee suggested that when writing Oneof Input Object literals we should always only allow one field to be present (whether or not variables were involved). I did not want to do this because it makes oneofs more distinct from regular input objects, and it's frustrating that a user cannot do something like:

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

input CatInput { name: String!, nickname: String, meowVolume: Int }
input DogInput { name: String!, nickname: String, barkVolume: Int }

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

#----

mutation AddPet ($cat: CatInput, $dog: DogInput) {
  addPet(pet: {cat: $cat, dog: $dog}) { name }
}

This feels like a natural thing to do.

Until... (and this is where I've come around to Lee's suggestion) until you want to apply strong typing to the variables on the client side. Now you have to analyse every usage of the variables in the client in order to determine that the type of the variables is something like (TypeScript syntax): { cat: CatInput! } | { dog: DogInput! }. It's technically feasible, but it's a tonne of effort for the client, when previously you could just look at the variable definitions and you were done.

For that reason, I think I'm going to adopt Lee's suggested approach. One additional benefit of Lee's approach is it's easier to implement the validation rules for it. Further we can open it up in future should we wish without breaking any previously valid queries - so it's a decision that we can revisit later.

If you want stronger typing, can't you do:

mutation AddPet ($pet: PetInput) {
  addPet(pet: $petInput) { name }
}

Although I agree that the stricter initial approach is best, and we could always relax later!

@benjie
Copy link
Member Author

benjie commented Mar 27, 2024

If you want stronger typing, can't you do:

Isn't that exactly what Lee's proposing? Requiring people to pass oneOf types directly, or single concrete pre-resolved types ({ cat: $nonNullableCat }). It just forbids the loose typing of { cat: $nullableCat, dog: $nullableDog }.

@yaacovCR
Copy link
Contributor

If you want stronger typing, can't you do:

Isn't that exactly what Lee's proposing? Requiring people to pass oneOf types directly, or single concrete pre-resolved types ({ cat: $nonNullableCat }). It just forbids the loose typing of { cat: $nullableCat, dog: $nullableDog }.

What I鈥檓 arguing is that it doesn鈥檛 make sense to adopt @leebyron suggestion forbidding weaker typing in order to allow for stronger typing if stronger typing is possible regardless with better variable definitions. I mean, I see why someone might disagree with the above , but just to clarify what I鈥檓 saying.

@benjie
Copy link
Member Author

benjie commented Mar 27, 2024

Oh right; I see what you're saying. I agree with Lee though, let's guide people to the pit of success 馃憤

Copy link

@YutaUra YutaUra left a comment

Choose a reason for hiding this comment

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

LGTM

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