expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment uniqueFields on Dog { name nickname } ' ); } /** * @see it('identical fields') */ public function testIdenticalFields() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment mergeIdenticalFields on Dog { name name } ' ); } /** * @see it('identical fields with identical args') */ public function testIdenticalFieldsWithIdenticalArgs() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment mergeIdenticalFieldsWithIdenticalArgs on Dog { doesKnowCommand(dogCommand: SIT) doesKnowCommand(dogCommand: SIT) } ' ); } /** * @see it('identical fields with identical directives') */ public function testIdenticalFieldsWithIdenticalDirectives() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment mergeSameFieldsWithSameDirectives on Dog { name @include(if: true) name @include(if: true) } ' ); } /** * @see it('different args with different aliases') */ public function testDifferentArgsWithDifferentAliases() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment differentArgsWithDifferentAliases on Dog { knowsSit : doesKnowCommand(dogCommand: SIT) knowsDown : doesKnowCommand(dogCommand: DOWN) } ' ); } /** * @see it('different directives with different aliases') */ public function testDifferentDirectivesWithDifferentAliases() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment differentDirectivesWithDifferentAliases on Dog { nameIfTrue : name @include(if: true) nameIfFalse : name @include(if: false) } ' ); } /** * @see it('different skip/include directives accepted') */ public function testDifferentSkipIncludeDirectivesAccepted() : void { // Note: Differing skip/include directives don't create an ambiguous return // value and are acceptable in conditions where differing runtime values // may have the same desired effect of including or skipping a field. $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment differentDirectivesWithDifferentAliases on Dog { name @include(if: true) name @include(if: false) } ' ); } /** * @see it('Same aliases with different field targets') */ public function testSameAliasesWithDifferentFieldTargets() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment sameAliasesWithDifferentFieldTargets on Dog { fido : name fido : nickname } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'fido', 'name and nickname are different fields' ), [new SourceLocation(3, 9), new SourceLocation(4, 9)] ), ] ); } /** * @see it('Same aliases allowed on non-overlapping fields') */ public function testSameAliasesAllowedOnNonOverlappingFields() : void { // This is valid since no object can be both a "Dog" and a "Cat", thus // these fields can never overlap. $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment sameAliasesWithDifferentFieldTargets on Pet { ... on Dog { name } ... on Cat { name: nickname } } ' ); } /** * @see it('Alias masking direct field access') */ public function testAliasMaskingDirectFieldAccess() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment aliasMaskingDirectFieldAccess on Dog { name : nickname name } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'name', 'nickname and name are different fields' ), [new SourceLocation(3, 9), new SourceLocation(4, 9)] ), ] ); } /** * @see it('different args, second adds an argument') */ public function testDifferentArgsSecondAddsAnArgument() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment conflictingArgs on Dog { doesKnowCommand doesKnowCommand(dogCommand: HEEL) } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'doesKnowCommand', 'they have differing arguments' ), [new SourceLocation(3, 9), new SourceLocation(4, 9)] ), ] ); } /** * @see it('different args, second missing an argument') */ public function testDifferentArgsSecondMissingAnArgument() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment conflictingArgs on Dog { doesKnowCommand(dogCommand: SIT) doesKnowCommand } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'doesKnowCommand', 'they have differing arguments' ), [new SourceLocation(3, 9), new SourceLocation(4, 9)] ), ] ); } /** * @see it('conflicting args') */ public function testConflictingArgs() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment conflictingArgs on Dog { doesKnowCommand(dogCommand: SIT) doesKnowCommand(dogCommand: HEEL) } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'doesKnowCommand', 'they have differing arguments' ), [new SourceLocation(3, 9), new SourceLocation(4, 9)] ), ] ); } /** * @see it('allows different args where no conflict is possible') */ public function testAllowsDifferentArgsWhereNoConflictIsPossible() : void { // This is valid since no object can be both a "Dog" and a "Cat", thus // these fields can never overlap. $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment conflictingArgs on Pet { ... on Dog { name(surname: true) } ... on Cat { name } } ' ); } /** * @see it('encounters conflict in fragments') */ public function testEncountersConflictInFragments() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { ...A ...B } fragment A on Type { x: a } fragment B on Type { x: b } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and b are different fields'), [new SourceLocation(7, 9), new SourceLocation(10, 9)] ), ] ); } /** * @see it('reports each conflict once') */ public function testReportsEachConflictOnce() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { f1 { ...A ...B } f2 { ...B ...A } f3 { ...A ...B x: c } } fragment A on Type { x: a } fragment B on Type { x: b } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and b are different fields'), [new SourceLocation(18, 9), new SourceLocation(21, 9)] ), FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and a are different fields'), [new SourceLocation(14, 11), new SourceLocation(18, 9)] ), FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'c and b are different fields'), [new SourceLocation(14, 11), new SourceLocation(21, 9)] ), ] ); } /** * @see it('deep conflict') */ public function testDeepConflict() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { x: a }, field { x: b } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'field', [['x', 'a and b are different fields']] ), [ new SourceLocation(3, 9), new SourceLocation(4, 11), new SourceLocation(6, 9), new SourceLocation(7, 11), ] ), ] ); } /** * @see it('deep conflict with multiple issues') */ public function testDeepConflictWithMultipleIssues() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { x: a y: c }, field { x: b y: d } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'field', [ ['x', 'a and b are different fields'], ['y', 'c and d are different fields'], ] ), [ new SourceLocation(3, 9), new SourceLocation(4, 11), new SourceLocation(5, 11), new SourceLocation(7, 9), new SourceLocation(8, 11), new SourceLocation(9, 11), ] ), ] ); } /** * @see it('very deep conflict') */ public function testVeryDeepConflict() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { deepField { x: a } }, field { deepField { x: b } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'field', [['deepField', [['x', 'a and b are different fields']]]] ), [ new SourceLocation(3, 9), new SourceLocation(4, 11), new SourceLocation(5, 13), new SourceLocation(8, 9), new SourceLocation(9, 11), new SourceLocation(10, 13), ] ), ] ); } /** * @see it('reports deep conflict to nearest common ancestor') */ public function testReportsDeepConflictToNearestCommonAncestor() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { deepField { x: a } deepField { x: b } }, field { deepField { y } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'deepField', [['x', 'a and b are different fields']] ), [ new SourceLocation(4, 11), new SourceLocation(5, 13), new SourceLocation(7, 11), new SourceLocation(8, 13), ] ), ] ); } /** * @see it('reports deep conflict to nearest common ancestor in fragments') */ public function testReportsDeepConflictToNearestCommonAncestorInFragments() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { ...F } field { ...F } } fragment F on T { deepField { deeperField { x: a } deeperField { x: b } } deepField { deeperField { y } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'deeperField', [['x', 'a and b are different fields']] ), [ new SourceLocation(12, 11), new SourceLocation(13, 13), new SourceLocation(15, 11), new SourceLocation(16, 13), ] ), ] ); } /** * @see it('reports deep conflict in nested fragments') */ public function testReportsDeepConflictInNestedFragments() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' { field { ...F } field { ...I } } fragment F on T { x: a ...G } fragment G on T { y: c } fragment I on T { y: d ...J } fragment J on T { x: b } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'field', [ ['x', 'a and b are different fields'], ['y', 'c and d are different fields'], ] ), [ new SourceLocation(3, 9), new SourceLocation(11, 9), new SourceLocation(15, 9), new SourceLocation(6, 9), new SourceLocation(22, 9), new SourceLocation(18, 9), ] ), ] ); } /** * @see it('ignores unknown fragments') */ public function testIgnoresUnknownFragments() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' { field { ...Unknown ...Known } } fragment Known on T { field ...OtherUnknown } ' ); } // Describe: return types must be unambiguous /** * @see it('conflicting return types which potentially overlap') */ public function testConflictingReturnTypesWhichPotentiallyOverlap() : void { // This is invalid since an object could potentially be both the Object // type IntBox and the interface type NonNullStringBox1. While that // condition does not exist in the current schema, the schema could // expand in the future to allow this. Thus it is invalid. $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ...on IntBox { scalar } ...on NonNullStringBox1 { scalar } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'scalar', 'they return conflicting types Int and String!' ), [ new SourceLocation(5, 15), new SourceLocation(8, 15), ] ), ] ); } private function getSchema() { $StringBox = null; $IntBox = null; $SomeBox = null; $SomeBox = new InterfaceType([ 'name' => 'SomeBox', 'fields' => static function () use (&$SomeBox) { return [ 'deepBox' => ['type' => $SomeBox], 'unrelatedField' => ['type' => Type::string()], ]; }, ]); $StringBox = new ObjectType([ 'name' => 'StringBox', 'interfaces' => [$SomeBox], 'fields' => static function () use (&$StringBox, &$IntBox) { return [ 'scalar' => ['type' => Type::string()], 'deepBox' => ['type' => $StringBox], 'unrelatedField' => ['type' => Type::string()], 'listStringBox' => ['type' => Type::listOf($StringBox)], 'stringBox' => ['type' => $StringBox], 'intBox' => ['type' => $IntBox], ]; }, ]); $IntBox = new ObjectType([ 'name' => 'IntBox', 'interfaces' => [$SomeBox], 'fields' => static function () use (&$StringBox, &$IntBox) { return [ 'scalar' => ['type' => Type::int()], 'deepBox' => ['type' => $IntBox], 'unrelatedField' => ['type' => Type::string()], 'listStringBox' => ['type' => Type::listOf($StringBox)], 'stringBox' => ['type' => $StringBox], 'intBox' => ['type' => $IntBox], ]; }, ]); $NonNullStringBox1 = new InterfaceType([ 'name' => 'NonNullStringBox1', 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())], ], ]); $NonNullStringBox1Impl = new ObjectType([ 'name' => 'NonNullStringBox1Impl', 'interfaces' => [$SomeBox, $NonNullStringBox1], 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())], 'unrelatedField' => ['type' => Type::string()], 'deepBox' => ['type' => $SomeBox], ], ]); $NonNullStringBox2 = new InterfaceType([ 'name' => 'NonNullStringBox2', 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())], ], ]); $NonNullStringBox2Impl = new ObjectType([ 'name' => 'NonNullStringBox2Impl', 'interfaces' => [$SomeBox, $NonNullStringBox2], 'fields' => [ 'scalar' => ['type' => Type::nonNull(Type::string())], 'unrelatedField' => ['type' => Type::string()], 'deepBox' => ['type' => $SomeBox], ], ]); $Connection = new ObjectType([ 'name' => 'Connection', 'fields' => [ 'edges' => [ 'type' => Type::listOf(new ObjectType([ 'name' => 'Edge', 'fields' => [ 'node' => [ 'type' => new ObjectType([ 'name' => 'Node', 'fields' => [ 'id' => ['type' => Type::id()], 'name' => ['type' => Type::string()], ], ]), ], ], ])), ], ], ]); return new Schema([ 'query' => new ObjectType([ 'name' => 'QueryRoot', 'fields' => [ 'someBox' => ['type' => $SomeBox], 'connection' => ['type' => $Connection], ], ]), 'types' => [$IntBox, $StringBox, $NonNullStringBox1Impl, $NonNullStringBox2Impl], ]); } /** * @see it('compatible return shapes on different return types') */ public function testCompatibleReturnShapesOnDifferentReturnTypes() : void { // In this case `deepBox` returns `SomeBox` in the first usage, and // `StringBox` in the second usage. These return types are not the same! // however this is valid because the return *shapes* are compatible. $this->expectPassesRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on SomeBox { deepBox { unrelatedField } } ... on StringBox { deepBox { unrelatedField } } } } ' ); } /** * @see it('disallows differing return types despite no overlap') */ public function testDisallowsDifferingReturnTypesDespiteNoOverlap() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { scalar } ... on StringBox { scalar } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'scalar', 'they return conflicting types Int and String' ), [ new SourceLocation(5, 15), new SourceLocation(8, 15), ] ), ] ); } /** * @see it('reports correctly when a non-exclusive follows an exclusive') */ public function testReportsCorrectlyWhenANonExclusiveFollowsAnExclusive() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { deepBox { ...X } } } someBox { ... on StringBox { deepBox { ...Y } } } memoed: someBox { ... on IntBox { deepBox { ...X } } } memoed: someBox { ... on StringBox { deepBox { ...Y } } } other: someBox { ...X } other: someBox { ...Y } } fragment X on SomeBox { scalar } fragment Y on SomeBox { scalar: unrelatedField } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'other', [['scalar', 'scalar and unrelatedField are different fields']] ), [ new SourceLocation(31, 11), new SourceLocation(39, 11), new SourceLocation(34, 11), new SourceLocation(42, 11), ] ), ] ); } /** * @see it('disallows differing return type nullability despite no overlap') */ public function testDisallowsDifferingReturnTypeNullabilityDespiteNoOverlap() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on NonNullStringBox1 { scalar } ... on StringBox { scalar } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'scalar', 'they return conflicting types String! and String' ), [ new SourceLocation(5, 15), new SourceLocation(8, 15), ] ), ] ); } /** * @see it('disallows differing return type list despite no overlap') */ public function testDisallowsDifferingReturnTypeListDespiteNoOverlap() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { box: listStringBox { scalar } } ... on StringBox { box: stringBox { scalar } } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'box', 'they return conflicting types [StringBox] and StringBox' ), [ new SourceLocation(5, 15), new SourceLocation(10, 15), ] ), ] ); $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { box: stringBox { scalar } } ... on StringBox { box: listStringBox { scalar } } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'box', 'they return conflicting types StringBox and [StringBox]' ), [ new SourceLocation(5, 15), new SourceLocation(10, 15), ] ), ] ); } public function testDisallowsDifferingSubfields() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { box: stringBox { val: scalar val: unrelatedField } } ... on StringBox { box: stringBox { val: scalar } } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'val', 'scalar and unrelatedField are different fields' ), [ new SourceLocation(6, 17), new SourceLocation(7, 17), ] ), ] ); } /** * @see it('disallows differing deep return types despite no overlap') */ public function testDisallowsDifferingDeepReturnTypesDespiteNoOverlap() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { box: stringBox { scalar } } ... on StringBox { box: intBox { scalar } } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'box', [['scalar', 'they return conflicting types String and Int']] ), [ new SourceLocation(5, 15), new SourceLocation(6, 17), new SourceLocation(10, 15), new SourceLocation(11, 17), ] ), ] ); } /** * @see it('allows non-conflicting overlaping types') */ public function testAllowsNonConflictingOverlapingTypes() : void { $this->expectPassesRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ... on IntBox { scalar: unrelatedField } ... on StringBox { scalar } } } ' ); } /** * @see it('same wrapped scalar return types') */ public function testSameWrappedScalarReturnTypes() : void { $this->expectPassesRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ...on NonNullStringBox1 { scalar } ...on NonNullStringBox2 { scalar } } } ' ); } /** * @see it('allows inline typeless fragments') */ public function testAllowsInlineTypelessFragments() : void { $this->expectPassesRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { a ... { a } } ' ); } /** * @see it('compares deep types including list') */ public function testComparesDeepTypesIncludingList() : void { $this->expectFailsRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { connection { ...edgeID edges { node { id: name } } } } fragment edgeID on Connection { edges { node { id } } } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'edges', [['node', [['id', 'name and id are different fields']]]] ), [ new SourceLocation(5, 13), new SourceLocation(6, 15), new SourceLocation(7, 17), new SourceLocation(14, 11), new SourceLocation(15, 13), new SourceLocation(16, 15), ] ), ] ); } /** * @see it('ignores unknown types') */ public function testIgnoresUnknownTypes() : void { $this->expectPassesRuleWithSchema( $this->getSchema(), new OverlappingFieldsCanBeMerged(), ' { someBox { ...on UnknownType { scalar } ...on NonNullStringBox2 { scalar } } } ' ); } /** * @see it('error message contains hint for alias conflict') */ public function testErrorMessageContainsHintForAliasConflict() : void { // The error template should end with a hint for the user to try using // different aliases. $error = OverlappingFieldsCanBeMerged::fieldsConflictMessage('x', 'a and b are different fields'); $hint = 'Use different aliases on the fields to fetch both if this was intentional.'; self::assertStringEndsWith($hint, $error); } /** * @see it('does not infinite loop on recursive fragment') */ public function testDoesNotInfiniteLoopOnRecursiveFragment() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment fragA on Human { name, relatives { name, ...fragA } } ' ); } /** * @see it('does not infinite loop on immediately recursive fragment') */ public function testDoesNotInfiniteLoopOnImmeditelyRecursiveFragment() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment fragA on Human { name, ...fragA } ' ); } /** * @see it('does not infinite loop on transitively recursive fragment') */ public function testDoesNotInfiniteLoopOnTransitivelyRecursiveFragment() : void { $this->expectPassesRule( new OverlappingFieldsCanBeMerged(), ' fragment fragA on Human { name, ...fragB } fragment fragB on Human { name, ...fragC } fragment fragC on Human { name, ...fragA } ' ); } /** * @see it('find invalid case even with immediately recursive fragment') */ public function testFindInvalidCaseEvenWithImmediatelyRecursiveFragment() : void { $this->expectFailsRule( new OverlappingFieldsCanBeMerged(), ' fragment sameAliasesWithDifferentFieldTargets on Dob { ...sameAliasesWithDifferentFieldTargets fido: name fido: nickname } ', [ FormattedError::create( OverlappingFieldsCanBeMerged::fieldsConflictMessage( 'fido', 'name and nickname are different fields' ), [ new SourceLocation(4, 9), new SourceLocation(5, 9), ] ), ] ); } }