Skip to content

Commit d71e063

Browse files
committed
[validation] Allow safe divergence
As pointed out in #53, our validation is more conservative than it needs to be in some cases. Particularly, two fields which could not overlap are still treated as a potential conflict. This change loosens this rule, allowing fields which can never both apply in a frame of execution to diverge.
1 parent 228215a commit d71e063

File tree

2 files changed

+151
-22
lines changed

2 files changed

+151
-22
lines changed

src/validation/__tests__/OverlappingFieldsCanBeMerged.js

Lines changed: 124 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
import {
2222
GraphQLSchema,
2323
GraphQLObjectType,
24-
GraphQLUnionType,
24+
GraphQLInterfaceType,
2525
GraphQLList,
2626
GraphQLNonNull,
2727
GraphQLInt,
@@ -101,6 +101,21 @@ describe('Validate: Overlapping fields can be merged', () => {
101101
]);
102102
});
103103

104+
it('Same aliases allowed on non-overlapping fields', () => {
105+
// This is valid since no object can be both a "Dog" and a "Cat", thus
106+
// these fields can never overlap.
107+
expectPassesRule(OverlappingFieldsCanBeMerged, `
108+
fragment sameAliasesWithDifferentFieldTargets on Pet {
109+
... on Dog {
110+
name
111+
}
112+
... on Cat {
113+
name: nickname
114+
}
115+
}
116+
`);
117+
});
118+
104119
it('Alias masking direct field access', () => {
105120
expectFailsRule(OverlappingFieldsCanBeMerged, `
106121
fragment aliasMaskingDirectFieldAccess on Dog {
@@ -116,6 +131,36 @@ describe('Validate: Overlapping fields can be merged', () => {
116131
]);
117132
});
118133

134+
it('different args, second adds an argument', () => {
135+
expectFailsRule(OverlappingFieldsCanBeMerged, `
136+
fragment conflictingArgs on Dog {
137+
doesKnowCommand
138+
doesKnowCommand(dogCommand: HEEL)
139+
}
140+
`, [
141+
{ message: fieldsConflictMessage(
142+
'doesKnowCommand',
143+
'they have differing arguments'
144+
),
145+
locations: [ { line: 3, column: 9 }, { line: 4, column: 9 } ] }
146+
]);
147+
});
148+
149+
it('different args, second missing an argument', () => {
150+
expectFailsRule(OverlappingFieldsCanBeMerged, `
151+
fragment conflictingArgs on Dog {
152+
doesKnowCommand(dogCommand: SIT)
153+
doesKnowCommand
154+
}
155+
`, [
156+
{ message: fieldsConflictMessage(
157+
'doesKnowCommand',
158+
'they have differing arguments'
159+
),
160+
locations: [ { line: 3, column: 9 }, { line: 4, column: 9 } ] }
161+
]);
162+
});
163+
119164
it('conflicting args', () => {
120165
expectFailsRule(OverlappingFieldsCanBeMerged, `
121166
fragment conflictingArgs on Dog {
@@ -131,6 +176,21 @@ describe('Validate: Overlapping fields can be merged', () => {
131176
]);
132177
});
133178

179+
it('allows different args where no conflict is possible', () => {
180+
// This is valid since no object can be both a "Dog" and a "Cat", thus
181+
// these fields can never overlap.
182+
expectPassesRule(OverlappingFieldsCanBeMerged, `
183+
fragment conflictingArgs on Pet {
184+
... on Dog {
185+
name(surname: true)
186+
}
187+
... on Cat {
188+
name
189+
}
190+
}
191+
`);
192+
});
193+
134194
it('conflicting directives', () => {
135195
expectFailsRule(OverlappingFieldsCanBeMerged, `
136196
fragment conflictingDirectiveArgs on Dog {
@@ -353,39 +413,67 @@ describe('Validate: Overlapping fields can be merged', () => {
353413

354414
describe('return types must be unambiguous', () => {
355415

416+
var SomeBox = new GraphQLInterfaceType({
417+
name: 'SomeBox',
418+
resolveType: () => StringBox,
419+
fields: {
420+
unrelatedField: { type: GraphQLString }
421+
}
422+
});
423+
424+
/* eslint-disable no-unused-vars */
356425
var StringBox = new GraphQLObjectType({
357426
name: 'StringBox',
427+
interfaces: [ SomeBox ],
358428
fields: {
359-
scalar: { type: GraphQLString }
429+
scalar: { type: GraphQLString },
430+
unrelatedField: { type: GraphQLString },
360431
}
361432
});
362433

363434
var IntBox = new GraphQLObjectType({
364435
name: 'IntBox',
436+
interfaces: [ SomeBox ],
365437
fields: {
366-
scalar: { type: GraphQLInt }
438+
scalar: { type: GraphQLInt },
439+
unrelatedField: { type: GraphQLString },
367440
}
368441
});
369442

370-
var NonNullStringBox1 = new GraphQLObjectType({
443+
var NonNullStringBox1 = new GraphQLInterfaceType({
371444
name: 'NonNullStringBox1',
445+
resolveType: () => StringBox,
372446
fields: {
373447
scalar: { type: new GraphQLNonNull(GraphQLString) }
374448
}
375449
});
376450

377-
var NonNullStringBox2 = new GraphQLObjectType({
451+
var NonNullStringBox1Impl = new GraphQLObjectType({
452+
name: 'NonNullStringBox1Impl',
453+
interfaces: [ SomeBox, NonNullStringBox1 ],
454+
fields: {
455+
scalar: { type: new GraphQLNonNull(GraphQLString) },
456+
unrelatedField: { type: GraphQLString },
457+
}
458+
});
459+
460+
var NonNullStringBox2 = new GraphQLInterfaceType({
378461
name: 'NonNullStringBox2',
462+
resolveType: () => StringBox,
379463
fields: {
380464
scalar: { type: new GraphQLNonNull(GraphQLString) }
381465
}
382466
});
383467

384-
var BoxUnion = new GraphQLUnionType({
385-
name: 'BoxUnion',
386-
resolveType: () => StringBox,
387-
types: [ StringBox, IntBox, NonNullStringBox1, NonNullStringBox2 ]
468+
var NonNullStringBox2Impl = new GraphQLObjectType({
469+
name: 'NonNullStringBox2Impl',
470+
interfaces: [ SomeBox, NonNullStringBox2 ],
471+
fields: {
472+
scalar: { type: new GraphQLNonNull(GraphQLString) },
473+
unrelatedField: { type: GraphQLString },
474+
}
388475
});
476+
/* eslint-enable no-unused-vars */
389477

390478
var Connection = new GraphQLObjectType({
391479
name: 'Connection',
@@ -413,37 +501,57 @@ describe('Validate: Overlapping fields can be merged', () => {
413501
query: new GraphQLObjectType({
414502
name: 'QueryRoot',
415503
fields: () => ({
416-
boxUnion: { type: BoxUnion },
504+
someBox: { type: SomeBox },
417505
connection: { type: Connection }
418506
})
419507
})
420508
});
421509

422-
it('conflicting scalar return types', () => {
510+
it('conflicting return types which potentially overlap', () => {
511+
// This is invalid since an object could potentially be both the Object
512+
// type IntBox and the interface type NonNullStringBox1. While that
513+
// condition does not exist in the current schema, the schema could
514+
// expand in the future to allow this. Thus it is invalid.
423515
expectFailsRuleWithSchema(schema, OverlappingFieldsCanBeMerged, `
424516
{
425-
boxUnion {
517+
someBox {
426518
...on IntBox {
427519
scalar
428520
}
429-
...on StringBox {
521+
...on NonNullStringBox1 {
430522
scalar
431523
}
432524
}
433525
}
434526
`, [
435527
{ message: fieldsConflictMessage(
436528
'scalar',
437-
'they return differing types Int and String'
529+
'they return differing types Int and String!'
438530
),
439531
locations: [ { line: 5, column: 15 }, { line: 8, column: 15 } ] }
440532
]);
441533
});
442534

535+
it('allows differing return types which cannot overlap', () => {
536+
// This is valid since an object cannot be both an IntBox and a StringBox.
537+
expectPassesRuleWithSchema(schema, OverlappingFieldsCanBeMerged, `
538+
{
539+
someBox {
540+
...on IntBox {
541+
scalar
542+
}
543+
...on StringBox {
544+
scalar
545+
}
546+
}
547+
}
548+
`);
549+
});
550+
443551
it('same wrapped scalar return types', () => {
444552
expectPassesRuleWithSchema(schema, OverlappingFieldsCanBeMerged, `
445553
{
446-
boxUnion {
554+
someBox {
447555
...on NonNullStringBox1 {
448556
scalar
449557
}
@@ -505,7 +613,7 @@ describe('Validate: Overlapping fields can be merged', () => {
505613
it('ignores unknown types', () => {
506614
expectPassesRuleWithSchema(schema, OverlappingFieldsCanBeMerged, `
507615
{
508-
boxUnion {
616+
someBox {
509617
...on UnknownType {
510618
scalar
511619
}

src/validation/rules/OverlappingFieldsCanBeMerged.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import type {
2828
GraphQLType,
2929
GraphQLNamedType,
30+
GraphQLCompositeType,
3031
GraphQLFieldDefinition
3132
} from '../../type/definition';
3233
import { typeFromAST } from '../../utilities/typeFromAST';
@@ -75,12 +76,32 @@ export function OverlappingFieldsCanBeMerged(context: ValidationContext): any {
7576

7677
function findConflict(
7778
responseName: string,
78-
pair1: [Field, GraphQLFieldDefinition],
79-
pair2: [Field, GraphQLFieldDefinition]
79+
field1: [ GraphQLCompositeType, Field, GraphQLFieldDefinition ],
80+
field2: [ GraphQLCompositeType, Field, GraphQLFieldDefinition ]
8081
): ?Conflict {
81-
var [ ast1, def1 ] = pair1;
82-
var [ ast2, def2 ] = pair2;
83-
if (ast1 === ast2 || comparedSet.has(ast1, ast2)) {
82+
var [ parentType1, ast1, def1 ] = field1;
83+
var [ parentType2, ast2, def2 ] = field2;
84+
85+
// Not a pair.
86+
if (ast1 === ast2) {
87+
return;
88+
}
89+
90+
// If the statically known parent types could not possibly apply at the same
91+
// time, then it is safe to permit them to diverge as they will not present
92+
// any ambiguity by differing.
93+
// It is known that two parent types could never overlap if they are
94+
// different Object types. Interface or Union types might overlap - if not
95+
// in the current state of the schema, then perhaps in some future version,
96+
// thus may not safely diverge.
97+
if (parentType1 !== parentType2 &&
98+
parentType1 instanceof GraphQLObjectType &&
99+
parentType2 instanceof GraphQLObjectType) {
100+
return;
101+
}
102+
103+
// Memoize, do not report the same issue twice.
104+
if (comparedSet.has(ast1, ast2)) {
84105
return;
85106
}
86107
comparedSet.add(ast1, ast2);
@@ -267,7 +288,7 @@ function collectFieldASTsAndDefs(
267288
if (!_astAndDefs[responseName]) {
268289
_astAndDefs[responseName] = [];
269290
}
270-
_astAndDefs[responseName].push([ selection, fieldDef ]);
291+
_astAndDefs[responseName].push([ parentType, selection, fieldDef ]);
271292
break;
272293
case INLINE_FRAGMENT:
273294
var typeCondition = selection.typeCondition;

0 commit comments

Comments
 (0)