Skip to content

Proposal: Simplifying enum merging via @inputOnly and @outputOnly #202

@sachindshinde

Description

@sachindshinde

Problem

Enum merging rules lead to complicated errors and workflows. In Apollo's case, the behavior depends on whether the enum is used in an input position (argument type or input field type) in at least one subgraph, and whether it's used in an output position (field type) in at least one subgraph.

To summarize the merging behavior:

  1. If the enum type is @inaccessible in at least one subgraph, the composed schema enum is @inaccessible.
  2. Enum values that are @inaccessible in at least one subgraph are always added to the composed schema enum (but with @inaccessible).
  3. If the enum is only used in the input position, an intersection of the subgraph's non-@inaccessible enum values is added to the composed schema enum.
  4. If the enum is only used in the output position, a union of the subgraph's non-@inaccessible enum values is added to the composed schema enum.
  5. If the enum is used in both positions, the subgraphs must use the same non-@inaccessible enum values, which are added to the composed schema enum.
  6. If the enum is used nowhere, then (to maintain preexisting behavior) a union of the subgraph's non-@inaccessible enum values is added to the composed schema enum.
  7. The composed schema enum must contain at least one enum value.
  8. If the composed schema enum is not @inaccessible, it must contain at least one non-@inaccessible enum value.

The case of an enum used in both positions is painful, because it means:

  1. Trying to add/remove enum values from a subgraph naively will cause a composition error. The user must instead go through a multi-step flow where they add @inaccessible, gradually make the change across all subgraphs, and then remove @inaccessible.
  2. Trying to use an enum type in both positions when previously it was just used in the input position (or just used in the output position) will typically result in a composition error, and resolving it involves matching the enum type across all subgraphs.

Existing Proposal Concerns

It's been proposed in the working group that we always intersect the enum values, and that if an enum value gets returned that's not in the intersection, then it should be fine since client code must be able to handle unknown enum values. I have at least two concerns with this proposal.

The first concern is that the client experience is confusing. Suppose I'm an app dev, and my app telemetry tells me a GraphQL API is delivering an enum value my app doesn't handle. I go to check the GraphQL schema to see what the new enum value means, so I can figure out how to handle it, but it's not in the schema. I might assume it was a temporary addition that got reverted, but my telemetry keeps alerting me. At that point, I'm confused about whether this is a client error (and I should update my app based on the schema), or if it's a server error (which this telemetry should ideally ignore). Given intersection/absence from the schema is being proposed, it seems we should make this be a server error to make it clear that the client isn't expected to handle the enum value (and signal the same to telemetry as well).

The second concern is that some users may not be satisfied with the intersection behavior, and updating the enum across all their subgraphs may not be a viable workaround. (We know at least a few that lean more toward the union side of the fence.)

New Proposal

There's two parts to this proposal, one for the GraphQL spec and the other for the composite schemas spec.

@inputOnly and @outputOnly

The GraphQL spec should support two new directives:

directive @inputOnly on ENUM_VALUE
directive @outputOnly on ENUM_VALUE

where:

  1. Schema validation must reject an enum value having both @inputOnly and @outputOnly.
  2. Enum input coercion must reject an enum value marked @outputOnly.
  3. Enum result coercion must reject an enum value marked @inputOnly.
  4. The __EnumValue introspection type must contain two new boolean fields, isInputOnly and isOutputOnly, indicating whether the enum value has @inputOnly and @outputOnly respectively.

This can lead to better codegen/validation for clients, stopping users from erroneously sending @outputOnly enum values in requests and avoiding unneeded logic for processing @inputOnly enum values in responses. This also obviates the need for the workaround for this, which is creating input and output versions of an enum type that largely contain the same thing (the workaround around also loses the association between types, so e.g. client codegen can't automatically create conversion functions between them).

If we don't want to amend the GraphQL spec, @inputOnly and @outputOnly could instead be part of the composite schemas spec:

  1. @inputOnly and @outputOnly could be subgraph schema and composite schema directives.
  2. Composition would uphold the proposed GraphQL spec rules 1-3 on subgraph schemas and composite schemas.
  3. Executors would uphold the proposed GraphQL spec rules 1-3 on composite schemas.

We'd need to omit the proposed GraphQL spec rule 4 around introspection changes. @inputOnly and @outputOnly metadata would have to be shared with clients through some other means (e.g. sharing the composite schema out-of-band).

Enum merging rules

The merging rules change to the following (rules 1, 2, 7, and 8 stay the same):

  1. If the enum type is @inaccessible in at least one subgraph, the composed schema enum is @inaccessible.
  2. Enum values that are @inaccessible in at least one subgraph are always added to the composed schema enum (but with @inaccessible).
  3. For subgraphs where the enum is used in (at least) the output position, let U be the union of their non-@inaccessible non-@inputOnly enum values. For subgraphs where the enum is used in (at least) the input position, let I be the intersection of their non-@inaccessible non-@outputOnly enum values. If the enum is used nowhere, then skip the remaining steps and omit the enum from the composed schema.
  4. Add enum values from the intersection U ∩ I to the composed schema enum.
  5. Add enum values from the set difference U - I to the composed schema enum, but with @outputOnly.
  6. Add enum values from the set difference I - U to the composed schema enum, but with @inputOnly.
  7. The composed schema enum must contain at least one enum value.
  8. If the composed schema enum is not @inaccessible, it must contain at least one non-@inaccessible enum value.

This alleviates the two pain points mentioned above:

  1. It is no longer required for non-@inaccessible enum values to exactly match, eliminating the composition error and need for @inaccessible for enum migrations entirely. The user only has to update multiple subgraphs if they want to use the enum value in the input position, in which case they have to add the enum value to the subgraphs that use the enum in the input position.
  2. Similarly, starting to use the enum in both positions also no longer results in a composition error; the enum no longer needs to be suddenly aligned across subgraphs.

Examples

Example 1

Consider these 4 subgraphs:

Subgraph A

type Query {
  foo(t: T): T
}

enum T {
  A1
  A2 @inputOnly
  A3 @outputOnly
}

Subgraph B

type Query {
  bar(t: Int): T
}

enum T {
  B1
  B2 @inputOnly
  B3 @outputOnly
}

Subgraph C

type Query {
  baz(t: T): Int
}

enum T {
  A1
  A2
  C1
  C2 @inputOnly
  C3 @outputOnly
}

Subgraph D

type Query {
  qux(t: Int): Int
}

enum T {
  D1
  D2 @inputOnly
  D3 @outputOnly
}

Following the enum merge rules gives:

U = { A1, A3 } ∪ { B1, B3 } = { A1, A3, B1, B3 }
I = { A1, A2 } ∩ { A1, A2, C1, C2 } = { A1, A2 }
U ∩ I = { A1 }
U - I = { A3, B1, B3 }
I - U = { A2 }

so the merged schema is:

type Query {
  foo(t: T): T
  bar(t: Int): T
  baz(t: T): Int
  qux(t: Int): Int
}

enum T {
  A1
  A2 @inputOnly
  A3 @outputOnly
  B1 @outputOnly
  B3 @outputOnly
}

Example 2

Consider these 2 subgraphs:

Subgraph A

type Query {
  foo(t: Int): T
  bar(t: T): Int
}

enum T {
  A1
}

Subgraph B

type Query {
  baz(t: T): Int
}

enum T {
  B1
}

Following the enum merge rules gives:

U = { A1 }
I = { A1 } ∩ { B1 } = {}
U ∩ I = {}
U - I = { A1 }
I - U = {}

so the merged schema is:

type Query {
  foo(t: Int): T
  bar(t: T): Int
  baz(t: T): Int
}

enum T {
  A1 @outputOnly
}

Note that specifying any value other than null for Query.bar(t:) and Query.baz(t:) will cause the executor to generate an input coercion error.

Example 3

Consider these 2 subgraphs:

Subgraph A

type Query {
  foo(t: Int): T
  bar(t: T): Int
}

enum T {
  A1 @inputOnly
}

Subgraph B

type Query {
  baz(t: T): Int
}

enum T {
  A1
}

Following the enum merge rules gives:

U = {}
I = { A1 } ∩ { A1 } = { A1 }
U ∩ I = {}
U - I = {}
I - U = { A1 }

so the merged schema is:

type Query {
  foo(t: Int): T
  bar(t: T): Int
  baz(t: T): Int
}

enum T {
  A1 @inputOnly
}

Note that Query.foo returning anything other than null will cause the executor to generate a result coercion error (similar to a field returning an interface with no implementing types).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions