Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix: handle overlapping fields correctly across subschemas (#6091)
* fix: handle overlapping fields correctly across subschemas * Fix prettier * Add another changeset * Introduce a new field
- Loading branch information
Showing
9 changed files
with
325 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
--- | ||
"@graphql-tools/stitch": minor | ||
--- | ||
|
||
New option `useNonNullableFieldOnConflict` in `typeMergingOptions` of `stitchSchemas` | ||
|
||
When you have two schemas like below, you will get a warning about the conflicting fields because `name` field is defined as non-null in one schema and nullable in the other schema, and non-nullable field can exist in the stitched schema because of the order or any other reasons, and this might actually cause an unexpected behavior when you fetch `User.name` from the one who has it as non-nullable. | ||
This option supresses the warning, and takes the field from the schema that has it as non-nullable. | ||
|
||
```graphql | ||
type Query { | ||
user: User | ||
} | ||
|
||
type User { | ||
id: ID! | ||
name: String | ||
email: String | ||
} | ||
``` | ||
And; | ||
|
||
```graphql | ||
type Query { | ||
user: User | ||
} | ||
|
||
type User { | ||
id: ID! | ||
name: String! | ||
} | ||
``` | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
--- | ||
"@graphql-tools/federation": patch | ||
"@graphql-tools/delegate": patch | ||
"@graphql-tools/stitch": patch | ||
--- | ||
|
||
If the gateway receives a query with an overlapping fields for the subschema, it uses aliases to resolve it correctly. | ||
|
||
Let's say subschema A has the following schema; | ||
|
||
```graphql | ||
type Query { | ||
user: User | ||
} | ||
|
||
interface User { | ||
id: ID! | ||
name: String! | ||
} | ||
|
||
type Admin implements User { | ||
id: ID! | ||
name: String! | ||
role: String! | ||
} | ||
|
||
type Customer implements User { | ||
id: ID! | ||
name: String | ||
email: String | ||
} | ||
``` | ||
|
||
And let's say the gateway has the following schema instead; | ||
|
||
```graphql | ||
type Query { | ||
user: User | ||
} | ||
|
||
interface User { | ||
id: ID! | ||
name: String! | ||
} | ||
|
||
type Admin implements User { | ||
id: ID! | ||
name: String! | ||
role: String! | ||
} | ||
|
||
type Customer implements User { | ||
id: ID! | ||
name: String! | ||
email: String! | ||
} | ||
``` | ||
|
||
In this case, the following query is fine for the gateway but for the subschema, it's not; | ||
|
||
```graphql | ||
query { | ||
user { | ||
... on Admin { | ||
id | ||
name # This is nullable in the subschema | ||
role | ||
} | ||
... on Customer { | ||
id | ||
name # This is non-nullable in the subschema | ||
} | ||
} | ||
} | ||
``` | ||
|
||
So the subgraph will throw based on this rule [OverlappingFieldsCanBeMerged](https://github.com/graphql/graphql-js/blob/main/src/validation/rules/OverlappingFieldsCanBeMergedRule.ts) | ||
|
||
To avoid this, the gateway will use aliases to resolve the query correctly. The query will be transformed to the following; | ||
|
||
```graphql | ||
query { | ||
user { | ||
... on Admin { | ||
id | ||
name # This is nullable in the subschema | ||
role | ||
} | ||
... on Customer { | ||
id | ||
name: _nullable_name # This is non-nullable in the subschema | ||
} | ||
} | ||
} | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import { isNullableType, Kind, visit } from 'graphql'; | ||
import { ExecutionRequest, ExecutionResult } from '@graphql-tools/utils'; | ||
import { DelegationContext, Transform } from './types.js'; | ||
|
||
const OverlappingAliases = Symbol('OverlappingAliases'); | ||
|
||
interface OverlappingAliasesContext { | ||
[OverlappingAliases]: boolean; | ||
} | ||
|
||
export class OverlappingAliasesTransform<TContext> | ||
implements Transform<OverlappingAliasesContext, TContext> | ||
{ | ||
transformRequest( | ||
request: ExecutionRequest, | ||
delegationContext: DelegationContext<TContext>, | ||
transformationContext: OverlappingAliasesContext, | ||
) { | ||
const newDocument = visit(request.document, { | ||
[Kind.SELECTION_SET]: node => { | ||
const seenNonNullable = new Set<string>(); | ||
const seenNullable = new Set<string>(); | ||
return { | ||
...node, | ||
selections: node.selections.map(selection => { | ||
if (selection.kind === Kind.INLINE_FRAGMENT) { | ||
const selectionTypeName = selection.typeCondition?.name.value; | ||
if (selectionTypeName) { | ||
const selectionType = | ||
delegationContext.transformedSchema.getType(selectionTypeName); | ||
if (selectionType && 'getFields' in selectionType) { | ||
const selectionTypeFields = selectionType.getFields(); | ||
return { | ||
...selection, | ||
selectionSet: { | ||
...selection.selectionSet, | ||
selections: selection.selectionSet.selections.map(subSelection => { | ||
if (subSelection.kind === Kind.FIELD) { | ||
const fieldName = subSelection.name.value; | ||
if (!subSelection.alias) { | ||
const field = selectionTypeFields[fieldName]; | ||
if (field) { | ||
let currentNullable: boolean; | ||
if (isNullableType(field.type)) { | ||
seenNullable.add(fieldName); | ||
currentNullable = true; | ||
} else { | ||
seenNonNullable.add(fieldName); | ||
currentNullable = false; | ||
} | ||
if (seenNullable.size && seenNonNullable.size) { | ||
transformationContext[OverlappingAliases] = true; | ||
return { | ||
...subSelection, | ||
alias: { | ||
kind: Kind.NAME, | ||
value: currentNullable | ||
? `_nullable_${fieldName}` | ||
: `_nonNullable_${fieldName}`, | ||
}, | ||
}; | ||
} | ||
} | ||
} | ||
} | ||
return subSelection; | ||
}), | ||
}, | ||
}; | ||
} | ||
} | ||
} | ||
return selection; | ||
}), | ||
}; | ||
}, | ||
}); | ||
return { | ||
...request, | ||
document: newDocument, | ||
}; | ||
} | ||
|
||
transformResult( | ||
result: ExecutionResult, | ||
_delegationContext: DelegationContext<TContext>, | ||
transformationContext: OverlappingAliasesContext, | ||
) { | ||
if (transformationContext[OverlappingAliases]) { | ||
return removeOverlappingAliases(result); | ||
} | ||
return result; | ||
} | ||
} | ||
|
||
function removeOverlappingAliases(result: any): any { | ||
if (result != null) { | ||
if (Array.isArray(result)) { | ||
return result.map(removeOverlappingAliases); | ||
} else if (typeof result === 'object') { | ||
const newResult: Record<string, any> = {}; | ||
for (const key in result) { | ||
if (key.startsWith('_nullable_') || key.startsWith('_nonNullable_')) { | ||
const newKey = key.replace(/^_nullable_/, '').replace(/^_nonNullable_/, ''); | ||
newResult[newKey] = removeOverlappingAliases(result[key]); | ||
} else { | ||
newResult[key] = removeOverlappingAliases(result[key]); | ||
} | ||
} | ||
return newResult; | ||
} | ||
} | ||
return result; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.