Skip to content

Commit

Permalink
[Schema] Implementing interfaces with covariant return types.
Browse files Browse the repository at this point in the history
This proposes loosening the definition of implementing an interface by allowing an implementing field to return a subtype of the interface field's return type.

This example would previously be an illegal schema, but become legal after this diff:

```graphql
interface Friendly {
  bestFriend: Friendly
}

type Person implements Friendly {
  bestFriend: Person
}
```
  • Loading branch information
leebyron committed Nov 17, 2015
1 parent e81cf39 commit edbe063
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 66 deletions.
175 changes: 160 additions & 15 deletions src/type/__tests__/validation.js
Expand Up @@ -1579,25 +1579,15 @@ describe('Objects must adhere to Interface they implement', () => {
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: {
type: GraphQLString,
args: {
input: { type: GraphQLString },
}
}
field: { type: GraphQLString }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: {
type: SomeScalarType,
args: {
input: { type: GraphQLString },
}
}
field: { type: SomeScalarType }
}
});

Expand All @@ -1608,6 +1598,89 @@ describe('Objects must adhere to Interface they implement', () => {
);
});

it('rejects an Object with a differently typed Interface field', () => {
expect(() => {
var TypeA = new GraphQLObjectType({
name: 'A',
fields: {
foo: { type: GraphQLString }
}
});

var TypeB = new GraphQLObjectType({
name: 'B',
fields: {
foo: { type: GraphQLString }
}
});

var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: { type: TypeA }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: TypeB }
}
});

return schemaWithFieldType(AnotherObject);
}).to.throw(
'AnotherInterface.field expects type "A" but ' +
'AnotherObject.field provides type "B".'
);
});

it('accepts an Object with a subtyped Interface field (interface)', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: () => ({
field: { type: AnotherInterface }
})
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: () => ({
field: { type: AnotherObject }
})
});

return schemaWithFieldType(AnotherObject);
}).not.to.throw();
});

it('accepts an Object with a subtyped Interface field (union)', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: { type: SomeUnionType }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: SomeObjectType }
}
});

return schemaWithFieldType(AnotherObject);
}).not.to.throw();
});

it('rejects an Object missing an Interface argument', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
Expand Down Expand Up @@ -1697,7 +1770,32 @@ describe('Objects must adhere to Interface they implement', () => {
}).not.to.throw();
});

it('rejects an Object with a differently modified Interface field type', () => {
it('rejects an Object with a non-list Interface field list type', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: { type: new GraphQLList(GraphQLString) }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: GraphQLString }
}
});

return schemaWithFieldType(AnotherObject);
}).to.throw(
'AnotherInterface.field expects type "[String]" but ' +
'AnotherObject.field provides type "String".'
);
});

it('rejects an Object with a list Interface field non-list type', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
Expand All @@ -1711,14 +1809,61 @@ describe('Objects must adhere to Interface they implement', () => {
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: new GraphQLNonNull(GraphQLString) }
field: { type: new GraphQLList(GraphQLString) }
}
});

return schemaWithFieldType(AnotherObject);
}).to.throw(
'AnotherInterface.field expects type "String" but ' +
'AnotherObject.field provides type "String!".'
'AnotherObject.field provides type "[String]".'
);
});

it('accepts an Object with a subset non-null Interface field type', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: { type: GraphQLString }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: new GraphQLNonNull(GraphQLString) }
}
});

return schemaWithFieldType(AnotherObject);
}).not.to.throw();
});

it('rejects an Object with a superset nullable Interface field type', () => {
expect(() => {
var AnotherInterface = new GraphQLInterfaceType({
name: 'AnotherInterface',
resolveType: () => null,
fields: {
field: { type: new GraphQLNonNull(GraphQLString) }
}
});

var AnotherObject = new GraphQLObjectType({
name: 'AnotherObject',
interfaces: [ AnotherInterface ],
fields: {
field: { type: GraphQLString }
}
});

return schemaWithFieldType(AnotherObject);
}).to.throw(
'AnotherInterface.field expects type "String!" but ' +
'AnotherObject.field provides type "String".'
);
});

Expand Down
16 changes: 4 additions & 12 deletions src/type/schema.js
Expand Up @@ -25,6 +25,7 @@ import {
import { __Schema } from './introspection';
import find from '../jsutils/find';
import invariant from '../jsutils/invariant';
import { isEqualType, isTypeSubTypeOf } from '../utilities/typeComparators';


/**
Expand Down Expand Up @@ -211,9 +212,10 @@ function assertObjectImplementsInterface(
`provide it.`
);

// Assert interface field type matches object field type. (invariant)
// Assert interface field type is satisfied by object field type, by being
// a valid subtype. (covariant)
invariant(
isEqualType(ifaceField.type, objectField.type),
isTypeSubTypeOf(objectField.type, ifaceField.type),
`${iface}.${fieldName} expects type "${ifaceField.type}" but ` +
`${object}.${fieldName} provides type "${objectField.type}".`
);
Expand Down Expand Up @@ -255,13 +257,3 @@ function assertObjectImplementsInterface(
});
});
}

function isEqualType(typeA: GraphQLType, typeB: GraphQLType): boolean {
if (typeA instanceof GraphQLNonNull && typeB instanceof GraphQLNonNull) {
return isEqualType(typeA.ofType, typeB.ofType);
}
if (typeA instanceof GraphQLList && typeB instanceof GraphQLList) {
return isEqualType(typeA.ofType, typeB.ofType);
}
return typeA === typeB;
}
88 changes: 88 additions & 0 deletions src/utilities/typeComparators.js
@@ -0,0 +1,88 @@
/* @flow */
/**
* Copyright (c) 2015, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/

import {
isAbstractType,
GraphQLObjectType,
GraphQLList,
GraphQLNonNull,
} from '../type/definition';
import type { GraphQLType, GraphQLAbstractType } from '../type/definition';


/**
* Provided two types, return true if the types are equal (invariant).
*/
export function isEqualType(typeA: GraphQLType, typeB: GraphQLType): boolean {
// Equivalent types are equal.
if (typeA === typeB) {
return true;
}

// If either type is non-null, the other must also be non-null.
if (typeA instanceof GraphQLNonNull && typeB instanceof GraphQLNonNull) {
return isEqualType(typeA.ofType, typeB.ofType);
}

// If either type is a list, the other must also be a list.
if (typeA instanceof GraphQLList && typeB instanceof GraphQLList) {
return isEqualType(typeA.ofType, typeB.ofType);
}

// Otherwise the types are not equal.
return false;
}

/**
* Provided a type and a super type, return true if the first type is either
* equal or a subset of the second super type (covariant).
*/
export function isTypeSubTypeOf(
maybeSubType: GraphQLType,
superType: GraphQLType
): boolean {
// Equivalent type is a valid subtype
if (maybeSubType === superType) {
return true;
}

// If superType is non-null, maybeSubType must also be nullable.
if (superType instanceof GraphQLNonNull) {
if (maybeSubType instanceof GraphQLNonNull) {
return isTypeSubTypeOf(maybeSubType.ofType, superType.ofType);
}
return false;
} else if (maybeSubType instanceof GraphQLNonNull) {
// If superType is nullable, maybeSubType may be non-null.
return isTypeSubTypeOf(maybeSubType.ofType, superType);
}

// If superType type is a list, maybeSubType type must also be a list.
if (superType instanceof GraphQLList) {
if (maybeSubType instanceof GraphQLList) {
return isTypeSubTypeOf(maybeSubType.ofType, superType.ofType);
}
return false;
} else if (maybeSubType instanceof GraphQLList) {
// If superType is not a list, maybeSubType must also be not a list.
return false;
}

// If superType type is an abstract type, maybeSubType type may be a currently
// possible object type.
if (isAbstractType(superType) &&
maybeSubType instanceof GraphQLObjectType &&
((superType: any): GraphQLAbstractType).isPossibleType(maybeSubType)) {
return true;
}

// Otherwise, the child type is not a valid subtype of the parent type.
return false;
}
19 changes: 2 additions & 17 deletions src/validation/rules/OverlappingFieldsCanBeMerged.js
Expand Up @@ -22,15 +22,13 @@ import {
getNamedType,
GraphQLObjectType,
GraphQLInterfaceType,
GraphQLList,
GraphQLNonNull,
} from '../../type/definition';
import type {
GraphQLType,
GraphQLNamedType,
GraphQLCompositeType,
GraphQLFieldDefinition
} from '../../type/definition';
import { isEqualType } from '../../utilities/typeComparators';
import { typeFromAST } from '../../utilities/typeFromAST';


Expand Down Expand Up @@ -119,7 +117,7 @@ export function OverlappingFieldsCanBeMerged(context: ValidationContext): any {

var type1 = def1 && def1.type;
var type2 = def2 && def2.type;
if (type1 && type2 && !sameType(type1, type2)) {
if (type1 && type2 && !isEqualType(type1, type2)) {
return [
[ responseName, `they return differing types ${type1} and ${type2}` ],
[ ast1 ],
Expand Down Expand Up @@ -221,19 +219,6 @@ function sameValue(value1, value2) {
return (!value1 && !value2) || print(value1) === print(value2);
}

function sameType(type1: GraphQLType, type2: GraphQLType) {
if (type1 === type2) {
return true;
}
if (type1 instanceof GraphQLList && type2 instanceof GraphQLList) {
return sameType(type1.ofType, type2.ofType);
}
if (type1 instanceof GraphQLNonNull && type2 instanceof GraphQLNonNull) {
return sameType(type1.ofType, type2.ofType);
}
return false;
}


/**
* Given a selectionSet, adds all of the fields in that selection to
Expand Down

0 comments on commit edbe063

Please sign in to comment.