diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a1c6dd677..eb52a7736 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -136,12 +136,12 @@ parameters: path: src/Type/Schema.php - - message: "#^Only booleans are allowed in a negated boolean, \\(callable\\)\\|null given\\.$#" + message: "#^Only booleans are allowed in an if condition, array\\ given\\.$#" count: 1 path: src/Type/Schema.php - - message: "#^Only booleans are allowed in an if condition, array\\ given\\.$#" + message: "#^Only booleans are allowed in a negated boolean, \\(callable\\)\\|null given\\.$#" count: 1 path: src/Type/Schema.php @@ -446,7 +446,7 @@ parameters: path: src/Validator/Rules/KnownDirectives.php - - message: "#^Only booleans are allowed in a negated boolean, array|null given\\.$#" + message: "#^Only booleans are allowed in a negated boolean, array\\|null given\\.$#" count: 1 path: src/Validator/Rules/KnownDirectives.php diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index b5dea24b4..31b862968 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -448,7 +448,7 @@ private function doesFragmentConditionMatch(Node $fragment, ObjectType $type) : return true; } if ($conditionalType instanceof AbstractType) { - return $this->exeContext->schema->isPossibleType($conditionalType, $type); + return $this->exeContext->schema->isSubType($conditionalType, $type); } return false; @@ -1283,7 +1283,7 @@ private function ensureValidRuntimeType( ) ); } - if (! $this->exeContext->schema->isPossibleType($returnType, $runtimeType)) { + if (! $this->exeContext->schema->isSubType($returnType, $runtimeType)) { throw new InvariantViolation( sprintf('Runtime Object type "%s" is not a possible type for "%s".', $runtimeType, $returnType) ); diff --git a/src/Experimental/Executor/Collector.php b/src/Experimental/Executor/Collector.php index fe79eb9d0..dc1b49f73 100644 --- a/src/Experimental/Executor/Collector.php +++ b/src/Experimental/Executor/Collector.php @@ -244,7 +244,7 @@ private function doCollectFields(ObjectType $runtimeType, ?SelectionSetNode $sel continue; } } elseif ($conditionType instanceof AbstractType) { - if (! $this->schema->isPossibleType($conditionType, $runtimeType)) { + if (! $this->schema->isSubType($conditionType, $runtimeType)) { continue; } } @@ -269,7 +269,7 @@ private function doCollectFields(ObjectType $runtimeType, ?SelectionSetNode $sel continue; } } elseif ($conditionType instanceof AbstractType) { - if (! $this->schema->isPossibleType($conditionType, $runtimeType)) { + if (! $this->schema->isSubType($conditionType, $runtimeType)) { continue; } } diff --git a/src/Experimental/Executor/CoroutineExecutor.php b/src/Experimental/Executor/CoroutineExecutor.php index f35cbb882..749810052 100644 --- a/src/Experimental/Executor/CoroutineExecutor.php +++ b/src/Experimental/Executor/CoroutineExecutor.php @@ -745,7 +745,7 @@ private function completeValue(CoroutineContext $ctx, Type $type, $value, array $returnValue = null; goto CHECKED_RETURN; - } elseif (! $this->schema->isPossibleType($type, $objectType)) { + } elseif (! $this->schema->isSubType($type, $objectType)) { $this->addError(Error::createLocatedError( new InvariantViolation(sprintf( 'Runtime Object type "%s" is not a possible type for "%s".', diff --git a/src/Language/AST/InterfaceTypeDefinitionNode.php b/src/Language/AST/InterfaceTypeDefinitionNode.php index e56c38459..3ece2df35 100644 --- a/src/Language/AST/InterfaceTypeDefinitionNode.php +++ b/src/Language/AST/InterfaceTypeDefinitionNode.php @@ -15,6 +15,9 @@ class InterfaceTypeDefinitionNode extends Node implements TypeDefinitionNode /** @var NodeList|null */ public $directives; + /** @var NodeList */ + public $interfaces; + /** @var NodeList|null */ public $fields; diff --git a/src/Language/AST/InterfaceTypeExtensionNode.php b/src/Language/AST/InterfaceTypeExtensionNode.php index 38e340e15..01d3a65e8 100644 --- a/src/Language/AST/InterfaceTypeExtensionNode.php +++ b/src/Language/AST/InterfaceTypeExtensionNode.php @@ -15,6 +15,9 @@ class InterfaceTypeExtensionNode extends Node implements TypeExtensionNode /** @var NodeList|null */ public $directives; + /** @var NodeList */ + public $interfaces; + /** @var NodeList|null */ public $fields; } diff --git a/src/Language/Parser.php b/src/Language/Parser.php index 462de219a..755aacf25 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -1346,12 +1346,14 @@ private function parseInterfaceTypeDefinition() : InterfaceTypeDefinitionNode $description = $this->parseDescription(); $this->expectKeyword('interface'); $name = $this->parseName(); + $interfaces = $this->parseImplementsInterfaces(); $directives = $this->parseDirectives(true); $fields = $this->parseFieldsDefinition(); return new InterfaceTypeDefinitionNode([ 'name' => $name, 'directives' => $directives, + 'interfaces' => $interfaces, 'fields' => $fields, 'loc' => $this->loc($start), 'description' => $description, @@ -1622,10 +1624,12 @@ private function parseInterfaceTypeExtension() : InterfaceTypeExtensionNode $this->expectKeyword('extend'); $this->expectKeyword('interface'); $name = $this->parseName(); + $interfaces = $this->parseImplementsInterfaces(); $directives = $this->parseDirectives(true); $fields = $this->parseFieldsDefinition(); - if (count($directives) === 0 && - count($fields) === 0 + if (count($interfaces) === 0 + && count($directives) === 0 + && count($fields) === 0 ) { throw $this->unexpected(); } @@ -1633,6 +1637,7 @@ private function parseInterfaceTypeExtension() : InterfaceTypeExtensionNode return new InterfaceTypeExtensionNode([ 'name' => $name, 'directives' => $directives, + 'interfaces' => $interfaces, 'fields' => $fields, 'loc' => $this->loc($start), ]); diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 03c635848..0a95efc44 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -309,6 +309,7 @@ function (InterfaceTypeDefinitionNode $def) : string { [ 'interface', $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ' & ')), $this->join($def->directives, ' '), $this->block($def->fields), ], @@ -401,6 +402,7 @@ function (InterfaceTypeDefinitionNode $def) : string { [ 'extend interface', $def->name, + $this->wrap('implements ', $this->join($def->interfaces, ' & ')), $this->join($def->directives, ' '), $this->block($def->fields), ], diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 242043c02..95031e8ab 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -151,7 +151,7 @@ class Visitor NodeKind::OBJECT_TYPE_DEFINITION => ['description', 'name', 'interfaces', 'directives', 'fields'], NodeKind::FIELD_DEFINITION => ['description', 'name', 'arguments', 'type', 'directives'], NodeKind::INPUT_VALUE_DEFINITION => ['description', 'name', 'type', 'defaultValue', 'directives'], - NodeKind::INTERFACE_TYPE_DEFINITION => ['description', 'name', 'directives', 'fields'], + NodeKind::INTERFACE_TYPE_DEFINITION => ['description', 'name', 'interfaces', 'directives', 'fields'], NodeKind::UNION_TYPE_DEFINITION => ['description', 'name', 'directives', 'types'], NodeKind::ENUM_TYPE_DEFINITION => ['description', 'name', 'directives', 'values'], NodeKind::ENUM_VALUE_DEFINITION => ['description', 'name', 'directives'], @@ -159,7 +159,7 @@ class Visitor NodeKind::SCALAR_TYPE_EXTENSION => ['name', 'directives'], NodeKind::OBJECT_TYPE_EXTENSION => ['name', 'interfaces', 'directives', 'fields'], - NodeKind::INTERFACE_TYPE_EXTENSION => ['name', 'directives', 'fields'], + NodeKind::INTERFACE_TYPE_EXTENSION => ['name', 'interfaces', 'directives', 'fields'], NodeKind::UNION_TYPE_EXTENSION => ['name', 'directives', 'types'], NodeKind::ENUM_TYPE_EXTENSION => ['name', 'directives', 'values'], NodeKind::INPUT_OBJECT_TYPE_EXTENSION => ['name', 'directives', 'fields'], diff --git a/src/Type/Definition/ImplementingType.php b/src/Type/Definition/ImplementingType.php new file mode 100644 index 000000000..94bf77179 --- /dev/null +++ b/src/Type/Definition/ImplementingType.php @@ -0,0 +1,20 @@ + + */ + public function getInterfaces() : array; +} diff --git a/src/Type/Definition/InterfaceType.php b/src/Type/Definition/InterfaceType.php index e01987653..845f04d0c 100644 --- a/src/Type/Definition/InterfaceType.php +++ b/src/Type/Definition/InterfaceType.php @@ -7,26 +7,43 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\InterfaceTypeDefinitionNode; use GraphQL\Language\AST\InterfaceTypeExtensionNode; +use GraphQL\Type\Schema; use GraphQL\Utils\Utils; +use function array_map; +use function is_array; use function is_callable; use function is_string; use function sprintf; -class InterfaceType extends Type implements AbstractType, OutputType, CompositeType, NullableType, NamedType +class InterfaceType extends Type implements AbstractType, OutputType, CompositeType, NullableType, NamedType, ImplementingType { /** @var InterfaceTypeDefinitionNode|null */ public $astNode; - /** @var InterfaceTypeExtensionNode[] */ + /** @var array */ public $extensionASTNodes; /** * Lazily initialized. * - * @var FieldDefinition[] + * @var array */ private $fields; + /** + * Lazily initialized. + * + * @var array + */ + private $interfaces; + + /** + * Lazily initialized. + * + * @var array + */ + private $interfaceMap; + /** * @param mixed[] $config */ @@ -99,6 +116,48 @@ protected function initializeFields() : void $this->fields = FieldDefinition::defineFieldMap($this, $fields); } + public function implementsInterface(InterfaceType $interfaceType) : bool + { + if (! isset($this->interfaceMap)) { + $this->interfaceMap = []; + foreach ($this->getInterfaces() as $interface) { + /** @var Type&InterfaceType $interface */ + $interface = Schema::resolveType($interface); + $this->interfaceMap[$interface->name] = $interface; + } + } + + return isset($this->interfaceMap[$interfaceType->name]); + } + + /** + * @return array + */ + public function getInterfaces() : array + { + if (! isset($this->interfaces)) { + $interfaces = $this->config['interfaces'] ?? []; + if (is_callable($interfaces)) { + $interfaces = $interfaces(); + } + + if ($interfaces !== null && ! is_array($interfaces)) { + throw new InvariantViolation( + sprintf('%s interfaces must be an Array or a callable which returns an Array.', $this->name) + ); + } + + /** @var array $interfaces */ + $interfaces = $interfaces === null + ? [] + : array_map([Schema::class, 'resolveType'], $interfaces); + + $this->interfaces = $interfaces; + } + + return $this->interfaces; + } + /** * Resolves concrete ObjectType for given object value * diff --git a/src/Type/Definition/ObjectType.php b/src/Type/Definition/ObjectType.php index 199d90df1..f5dfd339f 100644 --- a/src/Type/Definition/ObjectType.php +++ b/src/Type/Definition/ObjectType.php @@ -55,7 +55,7 @@ * } * ]); */ -class ObjectType extends Type implements OutputType, CompositeType, NullableType, NamedType +class ObjectType extends Type implements OutputType, CompositeType, NullableType, NamedType, ImplementingType { /** @var ObjectTypeDefinitionNode|null */ public $astNode; @@ -76,14 +76,14 @@ class ObjectType extends Type implements OutputType, CompositeType, NullableType /** * Lazily initialized. * - * @var InterfaceType[] + * @var array */ private $interfaces; /** * Lazily initialized. * - * @var InterfaceType[] + * @var array */ private $interfaceMap; @@ -180,7 +180,7 @@ public function implementsInterface(InterfaceType $interfaceType) : bool } /** - * @return InterfaceType[] + * @return array */ public function getInterfaces() : array { diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index 4d523b39f..5f9599fa4 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -359,7 +359,7 @@ static function (FieldDefinition $field) : bool { 'interfaces' => [ 'type' => Type::listOf(Type::nonNull(self::_type())), 'resolve' => static function ($type) : ?array { - if ($type instanceof ObjectType) { + if ($type instanceof ObjectType || $type instanceof InterfaceType) { return $type->getInterfaces(); } @@ -446,7 +446,7 @@ public static function _typeKind() ], 'INTERFACE' => [ 'value' => TypeKind::INTERFACE, - 'description' => 'Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.', + 'description' => 'Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields.', ], 'UNION' => [ 'value' => TypeKind::UNION, diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 628d94b26..e45207c05 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -12,13 +12,16 @@ use GraphQL\Language\AST\SchemaTypeExtensionNode; use GraphQL\Type\Definition\AbstractType; use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; +use GraphQL\Utils\InterfaceImplementations; use GraphQL\Utils\TypeInfo; use GraphQL\Utils\Utils; use Traversable; +use function array_map; use function array_values; use function implode; use function is_array; @@ -62,7 +65,14 @@ class Schema * * @var array> */ - private $possibleTypeMap; + private $subTypeMap; + + /** + * Lazily initialised. + * + * @var array + */ + private $implementationsMap; /** * True when $resolvedTypes contain all possible schema types @@ -416,55 +426,106 @@ public static function resolveType($type) : Type */ public function getPossibleTypes(Type $abstractType) : array { - $possibleTypeMap = $this->getPossibleTypeMap(); + return $abstractType instanceof UnionType + ? $abstractType->getTypes() + : $this->getImplementations($abstractType)->objects(); + } - return array_values($possibleTypeMap[$abstractType->name] ?? []); + /** + * Returns all types that implement a given interface type. + * + * This operations requires full schema scan. Do not use in production environment. + * + * @api + */ + public function getImplementations(InterfaceType $abstractType) : InterfaceImplementations + { + return $this->collectImplementations()[$abstractType->name]; } /** - * @return array> + * @return array */ - private function getPossibleTypeMap() : array + private function collectImplementations() : array { - if (! isset($this->possibleTypeMap)) { - $this->possibleTypeMap = []; + if (! isset($this->implementationsMap)) { + /** @var array> $foundImplementations */ + $foundImplementations = []; foreach ($this->getTypeMap() as $type) { - if ($type instanceof ObjectType) { - foreach ($type->getInterfaces() as $interface) { - if (! ($interface instanceof InterfaceType)) { - continue; - } + if ($type instanceof InterfaceType) { + if (! isset($foundImplementations[$type->name])) { + $foundImplementations[$type->name] = ['objects' => [], 'interfaces' => []]; + } - $this->possibleTypeMap[$interface->name][$type->name] = $type; + foreach ($type->getInterfaces() as $iface) { + if (! isset($foundImplementations[$iface->name])) { + $foundImplementations[$iface->name] = ['objects' => [], 'interfaces' => []]; + } + $foundImplementations[$iface->name]['interfaces'][] = $type; } - } elseif ($type instanceof UnionType) { - foreach ($type->getTypes() as $innerType) { - $this->possibleTypeMap[$type->name][$innerType->name] = $innerType; + } elseif ($type instanceof ObjectType) { + foreach ($type->getInterfaces() as $iface) { + if (! isset($foundImplementations[$iface->name])) { + $foundImplementations[$iface->name] = ['objects' => [], 'interfaces' => []]; + } + $foundImplementations[$iface->name]['objects'][] = $type; } } } + $this->implementationsMap = array_map( + static function (array $implementations) : InterfaceImplementations { + return new InterfaceImplementations($implementations['objects'], $implementations['interfaces']); + }, + $foundImplementations + ); } - return $this->possibleTypeMap; + return $this->implementationsMap; } /** + * @deprecated as of 14.4.0 use isSubType instead, will be removed in 15.0.0. + * * Returns true if object type is concrete type of given abstract type * (implementation for interfaces and members of union type for unions) * * @api + * @codeCoverageIgnore */ public function isPossibleType(AbstractType $abstractType, ObjectType $possibleType) : bool { - if ($abstractType instanceof InterfaceType) { - return $possibleType->implementsInterface($abstractType); - } + return $this->isSubType($abstractType, $possibleType); + } - if ($abstractType instanceof UnionType) { - return $abstractType->isPossibleType($possibleType); + /** + * Returns true if the given type is a sub type of the given abstract type. + * + * @param UnionType|InterfaceType $abstractType + * @param ObjectType|InterfaceType $maybeSubType + * + * @api + */ + public function isSubType(AbstractType $abstractType, ImplementingType $maybeSubType) : bool + { + if (! isset($this->subTypeMap[$abstractType->name])) { + $this->subTypeMap[$abstractType->name] = []; + + if ($abstractType instanceof UnionType) { + foreach ($abstractType->getTypes() as $type) { + $this->subTypeMap[$abstractType->name][$type->name] = true; + } + } else { + $implementations = $this->getImplementations($abstractType); + foreach ($implementations->objects() as $type) { + $this->subTypeMap[$abstractType->name][$type->name] = true; + } + foreach ($implementations->interfaces() as $type) { + $this->subTypeMap[$abstractType->name][$type->name] = true; + } + } } - throw InvariantViolation::shouldNotHappen(); + return isset($this->subTypeMap[$abstractType->name][$maybeSubType->name]); } /** diff --git a/src/Type/SchemaValidationContext.php b/src/Type/SchemaValidationContext.php index 97dca179c..7c5704393 100644 --- a/src/Type/SchemaValidationContext.php +++ b/src/Type/SchemaValidationContext.php @@ -27,6 +27,7 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\EnumValueDefinition; use GraphQL\Type\Definition\FieldDefinition; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\InputObjectField; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; @@ -43,6 +44,7 @@ use function array_key_exists; use function array_merge; use function count; +use function in_array; use function is_array; use function is_object; use function sprintf; @@ -306,7 +308,7 @@ public function validateTypes() : void $this->validateFields($type); // Ensure objects implement the interfaces they claim to. - $this->validateObjectInterfaces($type); + $this->validateInterfaces($type); // Ensure directives are valid $this->validateDirectivesAtLocation( @@ -317,6 +319,9 @@ public function validateTypes() : void // Ensure fields are valid. $this->validateFields($type); + // Ensure interfaces implement the interfaces they claim to. + $this->validateInterfaces($type); + // Ensure directives are valid $this->validateDirectivesAtLocation( $this->getDirectives($type), @@ -537,10 +542,8 @@ private function getAllNodes($obj) /** * @param Schema|ObjectType|InterfaceType|UnionType|EnumType|Directive $obj - * - * @return NodeList */ - private function getAllSubNodes($obj, callable $getter) + private function getAllSubNodes($obj, callable $getter) : NodeList { $result = new NodeList([]); foreach ($this->getAllNodes($obj) as $astNode) { @@ -654,30 +657,47 @@ private function getFieldArgNode($type, $fieldName, $argName) return $nodes[0] ?? null; } - private function validateObjectInterfaces(ObjectType $object) + /** + * @param ObjectType|InterfaceType $type + */ + private function validateInterfaces(ImplementingType $type) : void { - $implementedTypeNames = []; - foreach ($object->getInterfaces() as $iface) { + $ifaceTypeNames = []; + foreach ($type->getInterfaces() as $iface) { if (! $iface instanceof InterfaceType) { $this->reportError( sprintf( 'Type %s must only implement Interface types, it cannot implement %s.', - $object->name, + $type->name, Utils::printSafe($iface) ), - $this->getImplementsInterfaceNode($object, $iface) + $this->getImplementsInterfaceNode($type, $iface) + ); + continue; + } + + if ($type === $iface) { + $this->reportError( + sprintf( + 'Type %s cannot implement itself because it would create a circular reference.', + $type->name + ), + $this->getImplementsInterfaceNode($type, $iface) ); continue; } - if (isset($implementedTypeNames[$iface->name])) { + + if (isset($ifaceTypeNames[$iface->name])) { $this->reportError( - sprintf('Type %s can only implement %s once.', $object->name, $iface->name), - $this->getAllImplementsInterfaceNodes($object, $iface) + sprintf('Type %s can only implement %s once.', $type->name, $iface->name), + $this->getAllImplementsInterfaceNodes($type, $iface) ); continue; } - $implementedTypeNames[$iface->name] = true; - $this->validateObjectImplementsInterface($object, $iface); + $ifaceTypeNames[$iface->name] = true; + + $this->validateTypeImplementsAncestors($type, $iface); + $this->validateTypeImplementsInterface($type, $iface); } } @@ -694,69 +714,68 @@ private function getDirectives($object) } /** - * @param InterfaceType $iface - * - * @return NamedTypeNode|null + * @param ObjectType|InterfaceType $type */ - private function getImplementsInterfaceNode(ObjectType $type, $iface) + private function getImplementsInterfaceNode(ImplementingType $type, Type $shouldBeInterface) : ?NamedTypeNode { - $nodes = $this->getAllImplementsInterfaceNodes($type, $iface); + $nodes = $this->getAllImplementsInterfaceNodes($type, $shouldBeInterface); return $nodes[0] ?? null; } /** - * @param InterfaceType $iface + * @param ObjectType|InterfaceType $type * - * @return NamedTypeNode[] + * @return array */ - private function getAllImplementsInterfaceNodes(ObjectType $type, $iface) + private function getAllImplementsInterfaceNodes(ImplementingType $type, Type $shouldBeInterface) : array { - $subNodes = $this->getAllSubNodes($type, static function ($typeNode) { + $subNodes = $this->getAllSubNodes($type, static function (Node $typeNode) : NodeList { + /** @var ObjectTypeDefinitionNode|ObjectTypeExtensionNode|InterfaceTypeDefinitionNode|InterfaceTypeExtensionNode $typeNode */ return $typeNode->interfaces; }); - return Utils::filter($subNodes, static function ($ifaceNode) use ($iface) : bool { - return $ifaceNode->name->value === $iface->name; + return Utils::filter($subNodes, static function (NamedTypeNode $ifaceNode) use ($shouldBeInterface) : bool { + return $ifaceNode->name->value === $shouldBeInterface->name; }); } /** - * @param InterfaceType $iface + * @param ObjectType|InterfaceType $type */ - private function validateObjectImplementsInterface(ObjectType $object, $iface) + private function validateTypeImplementsInterface(ImplementingType $type, InterfaceType $iface) { - $objectFieldMap = $object->getFields(); - $ifaceFieldMap = $iface->getFields(); + $typeFieldMap = $type->getFields(); + $ifaceFieldMap = $iface->getFields(); // Assert each interface field is implemented. foreach ($ifaceFieldMap as $fieldName => $ifaceField) { - $objectField = array_key_exists($fieldName, $objectFieldMap) - ? $objectFieldMap[$fieldName] + $typeField = array_key_exists($fieldName, $typeFieldMap) + ? $typeFieldMap[$fieldName] : null; - // Assert interface field exists on object. - if (! $objectField) { + // Assert interface field exists on type. + if (! $typeField) { $this->reportError( sprintf( 'Interface field %s.%s expected but %s does not provide it.', $iface->name, $fieldName, - $object->name + $type->name ), array_merge( [$this->getFieldNode($iface, $fieldName)], - $this->getAllNodes($object) + $this->getAllNodes($type) ) ); continue; } - // Assert interface field type is satisfied by object field type, by being + // Assert interface field type is satisfied by type field type, by being // a valid subtype. (covariant) if (! TypeComparators::isTypeSubTypeOf( $this->schema, - $objectField->getType(), + $typeField->getType(), $ifaceField->getType() ) ) { @@ -766,52 +785,52 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) $iface->name, $fieldName, $ifaceField->getType(), - $object->name, + $type->name, $fieldName, - Utils::printSafe($objectField->getType()) + Utils::printSafe($typeField->getType()) ), [ $this->getFieldTypeNode($iface, $fieldName), - $this->getFieldTypeNode($object, $fieldName), + $this->getFieldTypeNode($type, $fieldName), ] ); } // Assert each interface field arg is implemented. foreach ($ifaceField->args as $ifaceArg) { - $argName = $ifaceArg->name; - $objectArg = null; + $argName = $ifaceArg->name; + $typeArg = null; - foreach ($objectField->args as $arg) { + foreach ($typeField->args as $arg) { if ($arg->name === $argName) { - $objectArg = $arg; + $typeArg = $arg; break; } } - // Assert interface field arg exists on object field. - if (! $objectArg) { + // Assert interface field arg exists on type field. + if (! $typeArg) { $this->reportError( sprintf( 'Interface field argument %s.%s(%s:) expected but %s.%s does not provide it.', $iface->name, $fieldName, $argName, - $object->name, + $type->name, $fieldName ), [ $this->getFieldArgNode($iface, $fieldName, $argName), - $this->getFieldNode($object, $fieldName), + $this->getFieldNode($type, $fieldName), ] ); continue; } - // Assert interface field arg type matches object field arg type. + // Assert interface field arg type matches type field arg type. // (invariant) // TODO: change to contravariant? - if (! TypeComparators::isEqualType($ifaceArg->getType(), $objectArg->getType())) { + if (! TypeComparators::isEqualType($ifaceArg->getType(), $typeArg->getType())) { $this->reportError( sprintf( 'Interface field argument %s.%s(%s:) expects type %s but %s.%s(%s:) is type %s.', @@ -819,14 +838,14 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) $fieldName, $argName, Utils::printSafe($ifaceArg->getType()), - $object->name, + $type->name, $fieldName, $argName, - Utils::printSafe($objectArg->getType()) + Utils::printSafe($typeArg->getType()) ), [ $this->getFieldArgTypeNode($iface, $fieldName, $argName), - $this->getFieldArgTypeNode($object, $fieldName, $argName), + $this->getFieldArgTypeNode($type, $fieldName, $argName), ] ); } @@ -834,8 +853,8 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) } // Assert additional arguments must not be required. - foreach ($objectField->args as $objectArg) { - $argName = $objectArg->name; + foreach ($typeField->args as $typeArg) { + $argName = $typeArg->name; $ifaceArg = null; foreach ($ifaceField->args as $arg) { @@ -845,21 +864,21 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) } } - if ($ifaceArg || ! $objectArg->isRequired()) { + if ($ifaceArg || ! $typeArg->isRequired()) { continue; } $this->reportError( sprintf( 'Object field %s.%s includes required argument %s that is missing from the Interface field %s.%s.', - $object->name, + $type->name, $fieldName, $argName, $iface->name, $fieldName ), [ - $this->getFieldArgNode($object, $fieldName, $argName), + $this->getFieldArgNode($type, $fieldName, $argName), $this->getFieldNode($iface, $fieldName), ] ); @@ -867,6 +886,39 @@ private function validateObjectImplementsInterface(ObjectType $object, $iface) } } + /** + * @param ObjectType|InterfaceType $type + */ + private function validateTypeImplementsAncestors(ImplementingType $type, InterfaceType $iface) : void + { + $typeInterfaces = $type->getInterfaces(); + foreach ($iface->getInterfaces() as $transitive) { + if (in_array($transitive, $typeInterfaces, true)) { + continue; + } + + $error = $transitive === $type ? + sprintf( + 'Type %s cannot implement %s because it would create a circular reference.', + $type->name, + $iface->name + ) : + sprintf( + 'Type %s must implement %s because it is implemented by %s.', + $type->name, + $transitive->name, + $iface->name + ); + $this->reportError( + $error, + array_merge( + $this->getAllImplementsInterfaceNodes($iface, $transitive), + $this->getAllImplementsInterfaceNodes($type, $iface) + ) + ); + } + } + private function validateUnionMembers(UnionType $union) { $memberTypes = $union->getTypes(); diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 9f44c4ce4..7eadc6d33 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -278,7 +278,7 @@ private function makeTypeDef(ObjectTypeDefinitionNode $def) 'fields' => function () use ($def) { return $this->makeFieldDefMap($def); }, - 'interfaces' => function () use ($def) { + 'interfaces' => function () use ($def) : ?array { return $this->makeImplementedInterfaces($def); }, 'astNode' => $def, @@ -318,7 +318,7 @@ public function buildField(FieldDefinitionNode $field) * Given a collection of directives, returns the string value for the * deprecation reason. * - * @param EnumValueDefinitionNode | FieldDefinitionNode $node + * @param EnumValueDefinitionNode|FieldDefinitionNode $node * * @return string */ @@ -329,7 +329,12 @@ private function getDeprecationReason($node) return $deprecated['reason'] ?? null; } - private function makeImplementedInterfaces(ObjectTypeDefinitionNode $def) + /** + * @param ObjectTypeDefinitionNode|InterfaceTypeDefinitionNode $def + * + * @return array|null + */ + private function makeImplementedInterfaces(Node $def) : ?array { if ($def->interfaces !== null) { // Note: While this could make early assertions to get the correctly @@ -356,6 +361,9 @@ private function makeInterfaceDef(InterfaceTypeDefinitionNode $def) 'fields' => function () use ($def) { return $this->makeFieldDefMap($def); }, + 'interfaces' => function () use ($def) : ?array { + return $this->makeImplementedInterfaces($def); + }, 'astNode' => $def, ]); } diff --git a/src/Utils/BreakingChangesFinder.php b/src/Utils/BreakingChangesFinder.php index e0d244366..234f114d5 100644 --- a/src/Utils/BreakingChangesFinder.php +++ b/src/Utils/BreakingChangesFinder.php @@ -11,6 +11,7 @@ use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\FieldArgument; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; @@ -41,17 +42,21 @@ class BreakingChangesFinder public const BREAKING_CHANGE_ARG_CHANGED_KIND = 'ARG_CHANGED_KIND'; public const BREAKING_CHANGE_REQUIRED_ARG_ADDED = 'REQUIRED_ARG_ADDED'; public const BREAKING_CHANGE_REQUIRED_INPUT_FIELD_ADDED = 'REQUIRED_INPUT_FIELD_ADDED'; - public const BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT = 'INTERFACE_REMOVED_FROM_OBJECT'; + public const BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED = 'IMPLEMENTED_INTERFACE_REMOVED'; public const BREAKING_CHANGE_DIRECTIVE_REMOVED = 'DIRECTIVE_REMOVED'; public const BREAKING_CHANGE_DIRECTIVE_ARG_REMOVED = 'DIRECTIVE_ARG_REMOVED'; public const BREAKING_CHANGE_DIRECTIVE_LOCATION_REMOVED = 'DIRECTIVE_LOCATION_REMOVED'; public const BREAKING_CHANGE_REQUIRED_DIRECTIVE_ARG_ADDED = 'REQUIRED_DIRECTIVE_ARG_ADDED'; public const DANGEROUS_CHANGE_ARG_DEFAULT_VALUE_CHANGED = 'ARG_DEFAULT_VALUE_CHANGE'; public const DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM = 'VALUE_ADDED_TO_ENUM'; - public const DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT = 'INTERFACE_ADDED_TO_OBJECT'; + public const DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED = 'IMPLEMENTED_INTERFACE_ADDED'; public const DANGEROUS_CHANGE_TYPE_ADDED_TO_UNION = 'TYPE_ADDED_TO_UNION'; public const DANGEROUS_CHANGE_OPTIONAL_INPUT_FIELD_ADDED = 'OPTIONAL_INPUT_FIELD_ADDED'; public const DANGEROUS_CHANGE_OPTIONAL_ARG_ADDED = 'OPTIONAL_ARG_ADDED'; + /** @deprecated use BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED instead, will be removed in v15.0.0. */ + public const BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT = 'IMPLEMENTED_INTERFACE_REMOVED'; + /** @deprecated use DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED instead, will be removed in v15.0.0. */ + public const DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT = 'IMPLEMENTED_INTERFACE_ADDED'; /** * Given two schemas, returns an Array containing descriptions of all the types @@ -590,7 +595,7 @@ public static function findInterfacesRemovedFromObjectTypes( foreach ($oldTypeMap as $typeName => $oldType) { $newType = $newTypeMap[$typeName] ?? null; - if (! ($oldType instanceof ObjectType) || ! ($newType instanceof ObjectType)) { + if (! ($oldType instanceof ImplementingType) || ! ($newType instanceof ImplementingType)) { continue; } @@ -608,7 +613,7 @@ static function (InterfaceType $interface) use ($oldInterface) : bool { } $breakingChanges[] = [ - 'type' => self::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, + 'type' => self::BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED, 'description' => sprintf('%s no longer implements interface %s.', $typeName, $oldInterface->name), ]; } @@ -857,7 +862,8 @@ public static function findInterfacesAddedToObjectTypes( foreach ($newTypeMap as $typeName => $newType) { $oldType = $oldTypeMap[$typeName] ?? null; - if (! ($oldType instanceof ObjectType) || ! ($newType instanceof ObjectType)) { + if (! ($oldType instanceof ObjectType || $oldType instanceof InterfaceType) + || ! ($newType instanceof ObjectType || $newType instanceof InterfaceType)) { continue; } @@ -876,7 +882,7 @@ static function (InterfaceType $interface) use ($newInterface) : bool { } $interfacesAddedToObjectTypes[] = [ - 'type' => self::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'type' => self::DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED, 'description' => sprintf( '%s added to interfaces implemented by %s.', $newInterface->name, diff --git a/src/Utils/BuildClientSchema.php b/src/Utils/BuildClientSchema.php index a013c1ac1..99e8988ee 100644 --- a/src/Utils/BuildClientSchema.php +++ b/src/Utils/BuildClientSchema.php @@ -284,23 +284,36 @@ private function buildScalarDef(array $scalar) : ScalarType } /** - * @param array $object + * @param array $implementingIntrospection + * + * @return array */ - private function buildObjectDef(array $object) : ObjectType + private function buildImplementationsList(array $implementingIntrospection) : array { - if (! array_key_exists('interfaces', $object)) { - throw new InvariantViolation('Introspection result missing interfaces: ' . json_encode($object) . '.'); + // TODO: Temporary workaround until GraphQL ecosystem will fully support 'interfaces' on interface types. + if (array_key_exists('interfaces', $implementingIntrospection) && + $implementingIntrospection['interfaces'] === null && + $implementingIntrospection['kind'] === TypeKind::INTERFACE) { + return []; + } + + if (! array_key_exists('interfaces', $implementingIntrospection)) { + throw new InvariantViolation('Introspection result missing interfaces: ' . json_encode($implementingIntrospection) . '.'); } + return array_map([$this, 'getInterfaceType'], $implementingIntrospection['interfaces']); + } + + /** + * @param array $object + */ + private function buildObjectDef(array $object) : ObjectType + { return new ObjectType([ 'name' => $object['name'], 'description' => $object['description'], 'interfaces' => function () use ($object) : array { - return array_map( - [$this, 'getInterfaceType'], - // Legacy support for interfaces with null as interfaces field - $object['interfaces'] ?? [] - ); + return $this->buildImplementationsList($object); }, 'fields' => function () use ($object) { return $this->buildFieldDefMap($object); @@ -319,6 +332,9 @@ private function buildInterfaceDef(array $interface) : InterfaceType 'fields' => function () use ($interface) { return $this->buildFieldDefMap($interface); }, + 'interfaces' => function () use ($interface) : array { + return $this->buildImplementationsList($interface); + }, ]); } diff --git a/src/Utils/InterfaceImplementations.php b/src/Utils/InterfaceImplementations.php new file mode 100644 index 000000000..eca7fd262 --- /dev/null +++ b/src/Utils/InterfaceImplementations.php @@ -0,0 +1,48 @@ + */ + private $objects; + + /** @var array */ + private $interfaces; + + /** + * @param array $objects + * @param array $interfaces + */ + public function __construct(array $objects, array $interfaces) + { + $this->objects = $objects; + $this->interfaces = $interfaces; + } + + /** + * @return array + */ + public function objects() : array + { + return $this->objects; + } + + /** + * @return array + */ + public function interfaces() : array + { + return $this->interfaces; + } +} diff --git a/src/Utils/SchemaExtender.php b/src/Utils/SchemaExtender.php index 951b8c3ee..93ac77ca7 100644 --- a/src/Utils/SchemaExtender.php +++ b/src/Utils/SchemaExtender.php @@ -22,6 +22,7 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\EnumValueDefinition; use GraphQL\Type\Definition\FieldArgument; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ListOfType; @@ -272,9 +273,11 @@ protected static function extendPossibleTypes(UnionType $type) : array } /** - * @return InterfaceType[] + * @param ObjectType|InterfaceType $type + * + * @return array */ - protected static function extendImplementedInterfaces(ObjectType $type) : array + protected static function extendImplementedInterfaces(ImplementingType $type) : array { $interfaces = array_map(static function (InterfaceType $interfaceType) { return static::extendNamedType($interfaceType); @@ -282,7 +285,7 @@ protected static function extendImplementedInterfaces(ObjectType $type) : array $extensions = static::$typeExtensionsMap[$type->name] ?? null; if ($extensions !== null) { - /** @var ObjectTypeExtensionNode $extension */ + /** @var ObjectTypeExtensionNode|InterfaceTypeExtensionNode $extension */ foreach ($extensions as $extension) { foreach ($extension->interfaces as $namedType) { $interfaces[] = static::$astBuilder->buildType($namedType); @@ -400,6 +403,9 @@ protected static function extendInterfaceType(InterfaceType $type) : InterfaceTy return new InterfaceType([ 'name' => $type->name, 'description' => $type->description, + 'interfaces' => static function () use ($type) : array { + return static::extendImplementedInterfaces($type); + }, 'fields' => static function () use ($type) : array { return static::extendFieldMap($type); }, diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index c94da6e35..c5f958cef 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -414,8 +414,21 @@ protected static function printDeprecated($fieldOrEnumVal) : string */ protected static function printInterface(InterfaceType $type, array $options) : string { + $interfaces = $type->getInterfaces(); + $implementedInterfaces = count($interfaces) > 0 + ? ' implements ' . implode( + ' & ', + array_map( + static function (InterfaceType $interface) : string { + return $interface->name; + }, + $interfaces + ) + ) + : ''; + return static::printDescription($options, $type) . - sprintf("interface %s {\n%s\n}", $type->name, static::printFields($options, $type)); + sprintf("interface %s%s {\n%s\n}", $type->name, $implementedInterfaces, static::printFields($options, $type)); } /** diff --git a/src/Utils/TypeComparators.php b/src/Utils/TypeComparators.php index fccc06f1c..7033eee70 100644 --- a/src/Utils/TypeComparators.php +++ b/src/Utils/TypeComparators.php @@ -6,6 +6,7 @@ use GraphQL\Type\Definition\AbstractType; use GraphQL\Type\Definition\CompositeType; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; @@ -82,10 +83,10 @@ public static function isTypeSubTypeOf(Schema $schema, Type $maybeSubType, Type } // If superType type is an abstract type, maybeSubType type may be a currently - // possible object type. + // possible object or interface type. return Type::isAbstractType($superType) && - $maybeSubType instanceof ObjectType && - $schema->isPossibleType( + $maybeSubType instanceof ImplementingType && + $schema->isSubType( $superType, $maybeSubType ); @@ -114,7 +115,7 @@ public static function doTypesOverlap(Schema $schema, CompositeType $typeA, Comp // If both types are abstract, then determine if there is any intersection // between possible concrete types of each. foreach ($schema->getPossibleTypes($typeA) as $type) { - if ($schema->isPossibleType($typeB, $type)) { + if ($schema->isSubType($typeB, $type)) { return true; } } @@ -123,12 +124,12 @@ public static function doTypesOverlap(Schema $schema, CompositeType $typeA, Comp } // Determine if the latter type is a possible concrete type of the former. - return $schema->isPossibleType($typeA, $typeB); + return $schema->isSubType($typeA, $typeB); } if ($typeB instanceof AbstractType) { // Determine if the former type is a possible concrete type of the latter. - return $schema->isPossibleType($typeB, $typeA); + return $schema->isSubType($typeB, $typeA); } // Otherwise the types do not overlap. diff --git a/src/Utils/TypeInfo.php b/src/Utils/TypeInfo.php index 6cd11ee6b..7ae3a880e 100644 --- a/src/Utils/TypeInfo.php +++ b/src/Utils/TypeInfo.php @@ -25,6 +25,7 @@ use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\FieldArgument; use GraphQL\Type\Definition\FieldDefinition; +use GraphQL\Type\Definition\ImplementingType; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InputType; use GraphQL\Type\Definition\InterfaceType; @@ -192,7 +193,7 @@ public static function extractTypes($type, ?array $typeMap = null) if ($type instanceof UnionType) { $nestedTypes = $type->getTypes(); } - if ($type instanceof ObjectType) { + if ($type instanceof ImplementingType) { $nestedTypes = array_merge($nestedTypes, $type->getInterfaces()); } if ($type instanceof ObjectType || $type instanceof InterfaceType) { diff --git a/src/Validator/Rules/PossibleFragmentSpreads.php b/src/Validator/Rules/PossibleFragmentSpreads.php index 184f58bec..4251400ba 100644 --- a/src/Validator/Rules/PossibleFragmentSpreads.php +++ b/src/Validator/Rules/PossibleFragmentSpreads.php @@ -68,12 +68,12 @@ private function doTypesOverlap(Schema $schema, CompositeType $fragType, Composi // Parent type is interface or union, fragment type is object type if ($parentType instanceof AbstractType && $fragType instanceof ObjectType) { - return $schema->isPossibleType($parentType, $fragType); + return $schema->isSubType($parentType, $fragType); } // Parent type is object type, fragment type is interface (or rather rare - union) if ($parentType instanceof ObjectType && $fragType instanceof AbstractType) { - return $schema->isPossibleType($fragType, $parentType); + return $schema->isSubType($fragType, $parentType); } // Both are object types: diff --git a/tests/Executor/TestClasses/Cat.php b/tests/Executor/TestClasses/Cat.php index 500e6a977..cc3caada0 100644 --- a/tests/Executor/TestClasses/Cat.php +++ b/tests/Executor/TestClasses/Cat.php @@ -12,9 +12,21 @@ class Cat /** @var bool */ public $meows; + /** @var Cat|null */ + public $mother; + + /** @var Cat|null */ + public $father; + + /** @var array */ + public $progeny; + public function __construct(string $name, bool $meows) { - $this->name = $name; - $this->meows = $meows; + $this->name = $name; + $this->meows = $meows; + $this->mother = null; + $this->father = null; + $this->progeny = []; } } diff --git a/tests/Executor/TestClasses/Dog.php b/tests/Executor/TestClasses/Dog.php index 46861c38b..332d00a3f 100644 --- a/tests/Executor/TestClasses/Dog.php +++ b/tests/Executor/TestClasses/Dog.php @@ -12,9 +12,21 @@ class Dog /** @var bool */ public $woofs; + /** @var Dog|null */ + public $mother; + + /** @var Dog|null */ + public $father; + + /** @var array */ + public $progeny; + public function __construct(string $name, bool $woofs) { - $this->name = $name; - $this->woofs = $woofs; + $this->name = $name; + $this->woofs = $woofs; + $this->mother = null; + $this->father = null; + $this->progeny = []; } } diff --git a/tests/Executor/UnionInterfaceTest.php b/tests/Executor/UnionInterfaceTest.php index 7c52cb29e..cd47d1e6f 100644 --- a/tests/Executor/UnionInterfaceTest.php +++ b/tests/Executor/UnionInterfaceTest.php @@ -8,11 +8,15 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Executor\Executor; use GraphQL\GraphQL; +use GraphQL\Language\AST\FieldDefinitionNode; +use GraphQL\Language\AST\FieldNode; +use GraphQL\Language\AST\NodeList; use GraphQL\Language\Parser; use GraphQL\Tests\Executor\TestClasses\Cat; use GraphQL\Tests\Executor\TestClasses\Dog; use GraphQL\Tests\Executor\TestClasses\Person; use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; @@ -46,13 +50,39 @@ public function setUp() : void ], ]); + $LifeType = new InterfaceType([ + 'name' => 'Life', + 'fields' => static function () use (&$LifeType) : array { + return [ + 'progeny' => ['type' => Type::listOf($LifeType)], + ]; + }, + ]); + + $MammalType = new InterfaceType([ + 'name' => 'Mammal', + 'interfaces' => [$LifeType], + 'fields' => static function () use (&$MammalType) : array { + return [ + 'progeny' => ['type' => Type::listOf($MammalType)], + 'mother' => ['type' => &$MammalType], + 'father' => ['type' => &$MammalType], + ]; + }, + ]); + $DogType = new ObjectType([ 'name' => 'Dog', - 'interfaces' => [$NamedType], - 'fields' => [ - 'name' => ['type' => Type::string()], - 'woofs' => ['type' => Type::boolean()], - ], + 'interfaces' => [$MammalType, $LifeType, $NamedType], + 'fields' => static function () use (&$DogType) : array { + return [ + 'name' => ['type' => Type::string()], + 'woofs' => ['type' => Type::boolean()], + 'progeny' => ['type' => Type::listOf($DogType)], + 'mother' => ['type' => &$DogType], + 'father' => ['type' => &$DogType], + ]; + }, 'isTypeOf' => static function ($value) : bool { return $value instanceof Dog; }, @@ -60,11 +90,16 @@ public function setUp() : void $CatType = new ObjectType([ 'name' => 'Cat', - 'interfaces' => [$NamedType], - 'fields' => [ - 'name' => ['type' => Type::string()], - 'meows' => ['type' => Type::boolean()], - ], + 'interfaces' => [$MammalType, $LifeType, $NamedType], + 'fields' => static function () use (&$CatType) : array { + return [ + 'name' => ['type' => Type::string()], + 'meows' => ['type' => Type::boolean()], + 'progeny' => ['type' => Type::listOf($CatType)], + 'mother' => ['type' => &$CatType], + 'father' => ['type' => &$CatType], + ]; + }, 'isTypeOf' => static function ($value) : bool { return $value instanceof Cat; }, @@ -87,12 +122,17 @@ public function setUp() : void $PersonType = new ObjectType([ 'name' => 'Person', - 'interfaces' => [$NamedType], - 'fields' => [ - 'name' => ['type' => Type::string()], - 'pets' => ['type' => Type::listOf($PetType)], - 'friends' => ['type' => Type::listOf($NamedType)], - ], + 'interfaces' => [$NamedType, $MammalType, $LifeType], + 'fields' => static function () use (&$PetType, &$NamedType, &$PersonType) : array { + return [ + 'name' => ['type' => Type::string()], + 'pets' => ['type' => Type::listOf($PetType)], + 'friends' => ['type' => Type::listOf($NamedType)], + 'progeny' => ['type' => Type::listOf($PersonType)], + 'mother' => ['type' => $PersonType], + 'father' => ['type' => $PersonType], + ]; + }, 'isTypeOf' => static function ($value) : bool { return $value instanceof Person; }, @@ -103,10 +143,16 @@ public function setUp() : void 'types' => [$PetType], ]); - $this->garfield = new Cat('Garfield', false); - $this->odie = new Dog('Odie', true); - $this->liz = new Person('Liz'); - $this->john = new Person('John', [$this->garfield, $this->odie], [$this->liz, $this->odie]); + $this->garfield = new Cat('Garfield', false); + $this->garfield->mother = new Cat("Garfield's Mom", false); + $this->garfield->mother->progeny = [$this->garfield]; + + $this->odie = new Dog('Odie', true); + $this->odie->mother = new Dog("Odie's Mom", true); + $this->odie->mother->progeny = [$this->odie]; + + $this->liz = new Person('Liz'); + $this->john = new Person('John', [$this->garfield, $this->odie], [$this->liz, $this->odie]); } // Execute: Union and intersection types @@ -127,6 +173,15 @@ interfaces { name } enumValues { name } inputFields { name } } + Mammal: __type(name: "Mammal") { + kind + name + fields { name } + interfaces { name } + possibleTypes { name } + enumValues { name } + inputFields { name } + } Pet: __type(name: "Pet") { kind name @@ -147,7 +202,26 @@ enumValues { name } 'fields' => [ ['name' => 'name'], ], - 'interfaces' => null, + 'interfaces' => [], + 'possibleTypes' => [ + ['name' => 'Person'], + ['name' => 'Dog'], + ['name' => 'Cat'], + ], + 'enumValues' => null, + 'inputFields' => null, + ], + 'Mammal' => [ + 'kind' => 'INTERFACE', + 'name' => 'Mammal', + 'fields' => [ + ['name' => 'progeny'], + ['name' => 'mother'], + ['name' => 'father'], + ], + 'interfaces' => [ + ['name' => 'Life'], + ], 'possibleTypes' => [ ['name' => 'Person'], ['name' => 'Dog'], @@ -196,8 +270,16 @@ public function testExecutesUsingUnionTypes() : void '__typename' => 'Person', 'name' => 'John', 'pets' => [ - ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], - ['__typename' => 'Dog', 'name' => 'Odie', 'woofs' => true], + [ + '__typename' => 'Cat', + 'name' => 'Garfield', + 'meows' => false, + ], + [ + '__typename' => 'Dog', + 'name' => 'Odie', + 'woofs' => true, + ], ], ], ]; @@ -233,8 +315,16 @@ public function testExecutesUnionTypesWithInlineFragments() : void '__typename' => 'Person', 'name' => 'John', 'pets' => [ - ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], - ['__typename' => 'Dog', 'name' => 'Odie', 'woofs' => true], + [ + '__typename' => 'Cat', + 'name' => 'Garfield', + 'meows' => false, + ], + [ + '__typename' => 'Dog', + 'name' => 'Odie', + 'woofs' => true, + ], ], ], @@ -293,6 +383,20 @@ public function testExecutesInterfaceTypesWithInlineFragments() : void ... on Cat { meows } + + ... on Mammal { + mother { + __typename + ... on Dog { + name + woofs + } + ... on Cat { + name + meows + } + } + } } } '); @@ -301,8 +405,21 @@ public function testExecutesInterfaceTypesWithInlineFragments() : void '__typename' => 'Person', 'name' => 'John', 'friends' => [ - ['__typename' => 'Person', 'name' => 'Liz'], - ['__typename' => 'Dog', 'name' => 'Odie', 'woofs' => true], + [ + '__typename' => 'Person', + 'name' => 'Liz', + 'mother' => null, + ], + [ + '__typename' => 'Dog', + 'name' => 'Odie', + 'woofs' => true, + 'mother' => [ + '__typename' => 'Dog', + 'name' => "Odie's Mom", + 'woofs' => true, + ], + ], ], ], ]; @@ -319,7 +436,14 @@ public function testAllowsFragmentConditionsToBeAbstractTypes() : void { __typename name - pets { ...PetFields } + pets { + ...PetFields, + ...on Mammal { + mother { + ...ProgenyFields + } + } + } friends { ...FriendFields } } @@ -345,6 +469,12 @@ public function testAllowsFragmentConditionsToBeAbstractTypes() : void meows } } + + fragment ProgenyFields on Life { + progeny { + __typename + } + } '); $expected = [ @@ -352,12 +482,37 @@ public function testAllowsFragmentConditionsToBeAbstractTypes() : void '__typename' => 'Person', 'name' => 'John', 'pets' => [ - ['__typename' => 'Cat', 'name' => 'Garfield', 'meows' => false], - ['__typename' => 'Dog', 'name' => 'Odie', 'woofs' => true], + [ + '__typename' => 'Cat', + 'name' => 'Garfield', + 'meows' => false, + 'mother' => [ + 'progeny' => [ + ['__typename' => 'Cat'], + ], + ], + ], + [ + '__typename' => 'Dog', + 'name' => 'Odie', + 'woofs' => true, + 'mother' => [ + 'progeny' => [ + ['__typename' => 'Dog'], + ], + ], + ], ], 'friends' => [ - ['__typename' => 'Person', 'name' => 'Liz'], - ['__typename' => 'Dog', 'name' => 'Odie', 'woofs' => true], + [ + '__typename' => 'Person', + 'name' => 'Liz', + ], + [ + '__typename' => 'Dog', + 'name' => 'Odie', + 'woofs' => true, + ], ], ], ]; diff --git a/tests/Language/SchemaParserTest.php b/tests/Language/SchemaParserTest.php index 7f463e5e3..d03e3937a 100644 --- a/tests/Language/SchemaParserTest.php +++ b/tests/Language/SchemaParserTest.php @@ -223,9 +223,9 @@ public function testSimpleExtension() : void } /** - * @see it('Extension without fields') + * @see it('Object extension without fields') */ - public function testExtensionWithoutFields() : void + public function testObjectExtensionWithoutFields() : void { $body = 'extend type Hello implements Greeting'; $doc = Parser::parse($body); @@ -253,9 +253,39 @@ public function testExtensionWithoutFields() : void } /** - * @see it('Extension without fields followed by extension') + * @see it('Interface extension without fields') */ - public function testExtensionWithoutFieldsFollowedByExtension() : void + public function testInterfaceExtensionWithoutFields() : void + { + $body = 'extend interface Hello implements Greeting'; + $doc = Parser::parse($body); + $loc = static function ($start, $end) : array { + return TestUtils::locArray($start, $end); + }; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::INTERFACE_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', $loc(17, 22)), + 'interfaces' => [ + $this->typeNode('Greeting', $loc(34, 42)), + ], + 'directives' => [], + 'fields' => [], + 'loc' => $loc(0, 42), + ], + ], + 'loc' => $loc(0, 42), + ]; + self::assertEquals($expected, TestUtils::nodeToArray($doc)); + } + + /** + * @see it('Object extension without fields followed by extension') + */ + public function testObjectExtensionWithoutFieldsFollowedByExtension() : void { $body = ' extend type Hello implements Greeting @@ -267,7 +297,7 @@ public function testExtensionWithoutFieldsFollowedByExtension() : void 'kind' => 'Document', 'definitions' => [ [ - 'kind' => 'ObjectTypeExtension', + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, 'name' => $this->nameNode('Hello', ['start' => 23, 'end' => 28]), 'interfaces' => [$this->typeNode('Greeting', ['start' => 40, 'end' => 48])], 'directives' => [], @@ -275,7 +305,7 @@ public function testExtensionWithoutFieldsFollowedByExtension() : void 'loc' => ['start' => 11, 'end' => 48], ], [ - 'kind' => 'ObjectTypeExtension', + 'kind' => NodeKind::OBJECT_TYPE_EXTENSION, 'name' => $this->nameNode('Hello', ['start' => 76, 'end' => 81]), 'interfaces' => [$this->typeNode('SecondGreeting', ['start' => 93, 'end' => 107])], 'directives' => [], @@ -289,9 +319,45 @@ public function testExtensionWithoutFieldsFollowedByExtension() : void } /** - * @see it('Extension without anything throws') + * @see it('Interface extension without fields followed by extension') + */ + public function testInterfaceExtensionWithoutFieldsFollowedByExtension() : void + { + $body = ' + extend interface Hello implements Greeting + + extend interface Hello implements SecondGreeting + '; + $doc = Parser::parse($body); + $expected = [ + 'kind' => 'Document', + 'definitions' => [ + [ + 'kind' => NodeKind::INTERFACE_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', ['start' => 28, 'end' => 33]), + 'interfaces' => [$this->typeNode('Greeting', ['start' => 45, 'end' => 53])], + 'directives' => [], + 'fields' => [], + 'loc' => ['start' => 11, 'end' => 53], + ], + [ + 'kind' => NodeKind::INTERFACE_TYPE_EXTENSION, + 'name' => $this->nameNode('Hello', ['start' => 82, 'end' => 87]), + 'interfaces' => [$this->typeNode('SecondGreeting', ['start' => 99, 'end' => 113])], + 'directives' => [], + 'fields' => [], + 'loc' => ['start' => 65, 'end' => 113], + ], + ], + 'loc' => ['start' => 0, 'end' => 122], + ]; + self::assertEquals($expected, $doc->toArray(true)); + } + + /** + * @see it('Object extension without anything throws') */ - public function testExtensionWithoutAnythingThrows() : void + public function testObjectExtensionWithoutAnythingThrows() : void { $this->expectSyntaxError( 'extend type Hello', @@ -300,6 +366,18 @@ public function testExtensionWithoutAnythingThrows() : void ); } + /** + * @see it('Interface extension without anything throws') + */ + public function testInterfaceExtensionWithoutAnythingThrows() : void + { + $this->expectSyntaxError( + 'extend interface Hello', + 'Unexpected ', + $this->loc(1, 23) + ); + } + private function expectSyntaxError($text, $message, $location) { $this->expectException(SyntaxError::class); @@ -318,9 +396,9 @@ private function loc($line, $column) } /** - * @see it('Extension do not include descriptions') + * @see it('Object extension do not include descriptions') */ - public function testExtensionDoNotIncludeDescriptions() : void + public function testObjectExtensionDoNotIncludeDescriptions() : void { $body = ' "Description" @@ -335,9 +413,26 @@ public function testExtensionDoNotIncludeDescriptions() : void } /** - * @see it('Extension do not include descriptions') + * @see it('Interface extension do not include descriptions') + */ + public function testInterfaceExtensionDoNotIncludeDescriptions() : void + { + $body = ' + "Description" + extend interface Hello { + world: String + }'; + $this->expectSyntaxError( + $body, + 'Unexpected Name "extend"', + $this->loc(3, 7) + ); + } + + /** + * @see it('Object Extension do not include descriptions') */ - public function testExtensionDoNotIncludeDescriptions2() : void + public function testObjectExtensionDoNotIncludeDescriptions2() : void { $body = ' extend "Description" type Hello { @@ -351,6 +446,23 @@ public function testExtensionDoNotIncludeDescriptions2() : void ); } + /** + * @see it('Interface Extension do not include descriptions') + */ + public function testInterfaceExtensionDoNotIncludeDescriptions2() : void + { + $body = ' + extend "Description" interface Hello { + world: String + } +}'; + $this->expectSyntaxError( + $body, + 'Unexpected String "Description"', + $this->loc(2, 14) + ); + } + /** * @see it('Simple non-null type') */ @@ -395,6 +507,44 @@ public function testSimpleNonNullType() : void self::assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @see it('Simple interface inheriting interface') + */ + public function testSimpleInterfaceInheritingInterface() : void + { + $body = 'interface Hello implements World { field: String }'; + $doc = Parser::parse($body); + $loc = static function ($start, $end) : array { + return TestUtils::locArray($start, $end); + }; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::INTERFACE_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(10, 15)), + 'interfaces' => [ + $this->typeNode('World', $loc(27, 32)), + ], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(35, 40)), + $this->typeNode('String', $loc(42, 48)), + $loc(35, 48) + ), + ], + 'loc' => $loc(0, 50), + 'description' => null, + ], + ], + 'loc' => $loc(0, 50), + ]; + + self::assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @see it('Simple type inheriting interface') */ @@ -472,6 +622,45 @@ public function testSimpleTypeInheritingMultipleInterfaces() : void self::assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @see it('Simple interface inheriting multiple interfaces') + */ + public function testSimpleInterfaceInheritingMultipleInterfaces() : void + { + $body = 'interface Hello implements Wo & rld { field: String }'; + $doc = Parser::parse($body); + $loc = static function ($start, $end) : array { + return TestUtils::locArray($start, $end); + }; + + $expected = [ + 'kind' => NodeKind::DOCUMENT, + 'definitions' => [ + [ + 'kind' => NodeKind::INTERFACE_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(10, 15)), + 'interfaces' => [ + $this->typeNode('Wo', $loc(27, 29)), + $this->typeNode('rld', $loc(32, 35)), + ], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(38, 43)), + $this->typeNode('String', $loc(45, 51)), + $loc(38, 51) + ), + ], + 'loc' => $loc(0, 53), + 'description' => null, + ], + ], + 'loc' => $loc(0, 53), + ]; + + self::assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @see it('Simple type inheriting multiple interfaces with leading ampersand') */ @@ -487,7 +676,7 @@ public function testSimpleTypeInheritingMultipleInterfacesWithLeadingAmpersand() 'kind' => 'Document', 'definitions' => [ [ - 'kind' => 'ObjectTypeDefinition', + 'kind' => NodeKind::OBJECT_TYPE_DEFINITION, 'name' => $this->nameNode('Hello', $loc(5, 10)), 'interfaces' => [ $this->typeNode('Wo', $loc(24, 26)), @@ -510,6 +699,44 @@ public function testSimpleTypeInheritingMultipleInterfacesWithLeadingAmpersand() self::assertEquals($expected, TestUtils::nodeToArray($doc)); } + /** + * @see it('Simple interface inheriting multiple interfaces with leading ampersand') + */ + public function testSimpleInterfaceInheritingMultipleInterfacesWithLeadingAmpersand() : void + { + $body = 'interface Hello implements & Wo & rld { field: String }'; + $doc = Parser::parse($body); + $loc = static function ($start, $end) : array { + return TestUtils::locArray($start, $end); + }; + + $expected = [ + 'kind' => 'Document', + 'definitions' => [ + [ + 'kind' => NodeKind::INTERFACE_TYPE_DEFINITION, + 'name' => $this->nameNode('Hello', $loc(10, 15)), + 'interfaces' => [ + $this->typeNode('Wo', $loc(29, 31)), + $this->typeNode('rld', $loc(34, 37)), + ], + 'directives' => [], + 'fields' => [ + $this->fieldNode( + $this->nameNode('field', $loc(40, 45)), + $this->typeNode('String', $loc(47, 53)), + $loc(40, 53) + ), + ], + 'loc' => $loc(0, 55), + 'description' => null, + ], + ], + 'loc' => $loc(0, 55), + ]; + self::assertEquals($expected, TestUtils::nodeToArray($doc)); + } + /** * @see it('Single value enum') */ @@ -610,6 +837,7 @@ interface Hello { $loc(21, 34) ), ], + 'interfaces' => [], 'loc' => $loc(1, 36), 'description' => null, ], diff --git a/tests/Language/SchemaPrinterTest.php b/tests/Language/SchemaPrinterTest.php index 05a326189..0c62e337d 100644 --- a/tests/Language/SchemaPrinterTest.php +++ b/tests/Language/SchemaPrinterTest.php @@ -71,7 +71,7 @@ public function testPrintsKitchenSink() : void This is a description of the `Foo` type. """ -type Foo implements Bar & Baz { +type Foo implements Bar & Baz & Two { one: Type """ This is a description of the `two` field. @@ -112,12 +112,18 @@ interface AnnotatedInterface @onInterface { interface UndefinedInterface -extend interface Bar { +extend interface Bar implements Two { two(argument: InputType!): Type } extend interface Bar @onInterface +interface Baz implements Bar & Two { + one: Type + two(argument: InputType!): Type + four(argument: String = "string"): String +} + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index b912f06d5..2b0f54f11 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -12,7 +12,7 @@ schema { This is a description of the `Foo` type. """ -type Foo implements Bar & Baz { +type Foo implements Bar & Baz & Two { one: Type """ This is a description of the `two` field. @@ -53,12 +53,18 @@ interface AnnotatedInterface @onInterface { interface UndefinedInterface - extend interface Bar { +extend interface Bar implements Two{ two(argument: InputType!): Type } extend interface Bar @onInterface +interface Baz implements Bar & Two { + one: Type + two(argument: InputType!): Type + four(argument: String = "string"): String +} + union Feed = Story | Article | Advert union AnnotatedUnion @onUnion = A | B diff --git a/tests/Type/DefinitionTest.php b/tests/Type/DefinitionTest.php index 5426b4816..8e1513f90 100644 --- a/tests/Type/DefinitionTest.php +++ b/tests/Type/DefinitionTest.php @@ -1138,6 +1138,70 @@ public function testAcceptsAnInterfaceTypeDefiningResolveType() : void ); } + /** + * @see it('accepts an Interface type with an array of interfaces') + */ + public function testAcceptsAnInterfaceTypeWithAnArrayOfInterfaces() : void + { + $interfaceType = new InterfaceType([ + 'name' => 'AnotherInterface', + 'fields' => [], + 'interfaces' => [$this->interfaceType], + ]); + self::assertSame($this->interfaceType, $interfaceType->getInterfaces()[0]); + } + + /** + * @see it('accepts an Interface type with interfaces as a function returning an array') + */ + public function testAcceptsAnInterfaceTypeWithInterfacesAsAFunctionReturningAnArray() : void + { + $interfaceType = new InterfaceType([ + 'name' => 'AnotherInterface', + 'fields' => [], + 'interfaces' => function () : array { + return [$this->interfaceType]; + }, + ]); + self::assertSame($this->interfaceType, $interfaceType->getInterfaces()[0]); + } + + /** + * @see it('rejects an Interface type with incorrectly typed interfaces') + */ + public function testRejectsAnInterfaceTypeWithIncorrectlyTypedInterfaces() : void + { + $objType = new InterfaceType([ + 'name' => 'AnotherInterface', + 'interfaces' => new stdClass(), + 'fields' => [], + ]); + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage( + 'AnotherInterface interfaces must be an Array or a callable which returns an Array.' + ); + $objType->getInterfaces(); + } + + /** + * @see it('rejects an Interface type with interfaces as a function returning an incorrect type') + */ + public function testRejectsAnInterfaceTypeWithInterfacesAsAFunctionReturningAnIncorrectType() : void + { + $objType = new ObjectType([ + 'name' => 'AnotherInterface', + 'interfaces' => static function () : stdClass { + return new stdClass(); + }, + 'fields' => [], + ]); + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage( + 'AnotherInterface interfaces must be an Array or a callable which returns an Array.' + ); + $objType->getInterfaces(); + } + private function schemaWithFieldType($type) { $schema = new Schema([ diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index de8363172..39ae4ea35 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -1583,7 +1583,7 @@ enumValues { ], [ 'description' => 'Indicates this type is an interface. ' . - '`fields` and `possibleTypes` are valid fields.', + '`fields`, `interfaces`, and `possibleTypes` are valid fields.', 'name' => 'INTERFACE', ], [ diff --git a/tests/Type/LazyTypeLoaderTest.php b/tests/Type/LazyTypeLoaderTest.php index efece81a6..1e208f994 100644 --- a/tests/Type/LazyTypeLoaderTest.php +++ b/tests/Type/LazyTypeLoaderTest.php @@ -285,7 +285,19 @@ public function testWorksWithTypeLoader() : void Schema::resolveType($this->blogStory) ); self::assertTrue($result); - self::assertEquals(['Node', 'Content', 'PostStoryMutationInput'], $this->calls); + self::assertEquals( + [ + 'Node', + 'Content', + 'PostStoryMutationInput', + 'Query.fields', + 'Content.fields', + 'Node.fields', + 'Mutation.fields', + 'BlogStory.fields', + ], + $this->calls + ); } public function testOnlyCallsLoaderOnce() : void diff --git a/tests/Type/TypeLoaderTest.php b/tests/Type/TypeLoaderTest.php index 516b4cb72..18ae90376 100644 --- a/tests/Type/TypeLoaderTest.php +++ b/tests/Type/TypeLoaderTest.php @@ -236,9 +236,21 @@ public function testWorksWithTypeLoader() : void self::assertSame($this->postStoryMutationInput, $input); self::assertEquals(['Node', 'Content', 'PostStoryMutationInput'], $this->calls); - $result = $schema->isPossibleType($this->node, $this->blogStory); + $result = $schema->isSubType($this->node, $this->blogStory); self::assertTrue($result); - self::assertEquals(['Node', 'Content', 'PostStoryMutationInput'], $this->calls); + self::assertEquals( + [ + 'Node', + 'Content', + 'PostStoryMutationInput', + 'Query.fields', + 'Content.fields', + 'Node.fields', + 'Mutation.fields', + 'BlogStory.fields', + ], + $this->calls + ); } public function testOnlyCallsLoaderOnce() : void diff --git a/tests/Type/ValidationTest.php b/tests/Type/ValidationTest.php index b60474a3e..aef50fc86 100644 --- a/tests/Type/ValidationTest.php +++ b/tests/Type/ValidationTest.php @@ -2338,6 +2338,610 @@ interface AnotherInterface { ); } + /** + * @see it('rejects an Object missing a transitive interface') + */ + public function testRejectsAnObjectMissingATransitiveInterface() : void + { + $schema = BuildSchema::build(' + type Query { + test: AnotherObject + } + + interface SuperInterface { + field: String! + } + + interface AnotherInterface implements SuperInterface { + field: String! + } + + type AnotherObject implements AnotherInterface { + field: String! + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type AnotherObject must implement SuperInterface ' . + 'because it is implemented by AnotherInterface.', + 'locations' => [['line' => 10, 'column' => 45], ['line' => 14, 'column' => 37]], + ], + ] + ); + } + + /** + * @see it('accepts an Interface which implements an Interface') + */ + public function testAcceptsAnInterfaceWhichImplementsAnInterface() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): String + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('accepts an Interface which implements an Interface along with more fields') + */ + public function testAcceptsAnInterfaceWhichImplementsAnInterfaceAlongWithMoreFields() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): String + anotherField: String + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('accepts an Interface which implements an Interface field along with additional optional arguments') + */ + public function testAcceptsAnInterfaceWhichImplementsAnInterfaceFieldAlongWithAdditionalOptionalArguments() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String, anotherInput: String): String + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('rejects an Interface missing an Interface field') + */ + public function testRejectsAnInterfaceMissingAnInterfaceField() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + anotherField: String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expected ' . + 'but ChildInterface does not provide it.', + 'locations' => [['line' => 7, 'column' => 9], ['line' => 10, 'column' => 7]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface with an incorrectly typed Interface field') + */ + public function testRejectsAnInterfaceWithAnIncorrectlyTypedInterfaceField() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: String): Int + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expects type String ' . + 'but ChildInterface.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 31]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface with a differently typed Interface field') + */ + public function testRejectsAnInterfaceWithADifferentlyTypedInterfaceField() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + type A { foo: String } + type B { foo: String } + + interface ParentInterface { + field: A + } + + interface ChildInterface implements ParentInterface { + field: B + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expects type A ' . + 'but ChildInterface.field is type B.', + 'locations' => [['line' => 10, 'column' => 16], ['line' => 14, 'column' => 16]], + ], + ] + ); + } + + /** + * @see it('accepts an interface with a subtyped Interface field (interface)') + */ + public function testAcceptsAnInterfaceWithASubtypedInterfaceFieldInterface() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: ParentInterface + } + + interface ChildInterface implements ParentInterface { + field: ChildInterface + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('accepts an interface with a subtyped Interface field (union)') + */ + public function testAcceptsAnInterfaceWithASubtypedInterfaceFieldUnion() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + type SomeObject { + field: String + } + union SomeUnionType = SomeObject + + interface ParentInterface { + field: SomeUnionType + } + + interface ChildInterface implements ParentInterface { + field: SomeObject + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('rejects an Interface with an Interface argument') + */ + public function testRejectsAnInterfaceMissingAnInterfaceArgument() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field: String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field argument ParentInterface.field(input:) expected ' . + 'but ChildInterface.field does not provide it.', + 'locations' => [['line' => 7, 'column' => 15], ['line' => 11, 'column' => 9]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface with an incorrectly typed Interface argument') + */ + public function testRejectsAnInterfaceWithAnIncorrectlyTypedInterfaceArgument() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: Int): String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field argument ParentInterface.field(input:) expects type String ' . + 'but ChildInterface.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface with both an incorrectly typed field and argument') + */ + public function testRejectsAnInterfaceWithBothAnIncorrectlyTypedFieldAndArgument() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(input: String): String + } + + interface ChildInterface implements ParentInterface { + field(input: Int): Int + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [ + [ + 'message' => 'Interface field ParentInterface.field expects type String ' . + 'but ChildInterface.field is type Int.', + 'locations' => [['line' => 7, 'column' => 31], ['line' => 11, 'column' => 28]], + ], + [ + 'message' => 'Interface field argument ParentInterface.field(input:) expects type String ' . + 'but ChildInterface.field(input:) is type Int.', + 'locations' => [['line' => 7, 'column' => 22], ['line' => 11, 'column' => 22]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface which implements an Interface field along with additional required arguments') + */ + public function testRejectsAnInterfaceWhichImplementsAnInterfaceFieldAlongWithAdditionalRequiredArguments() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field(baseArg: String): String + } + + interface ChildInterface implements ParentInterface { + field( + baseArg: String, + requiredArg: String! + optionalArg1: String, + optionalArg2: String = "", + ): String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Object field ChildInterface.field includes required argument requiredArg ' . + 'that is missing from the Interface field ParentInterface.field.', + 'locations' => [['line' => 13, 'column' => 11], ['line' => 7, 'column' => 9]], + ], + ] + ); + } + + /** + * @see it('accepts an Interface with an equivalently wrapped Interface field type') + */ + public function testAcceptsAnInterfaceWithAnEquivalentlyWrappedInterfaceFieldType() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: [String]! + } + + interface ChildInterface implements ParentInterface { + field: [String]! + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('rejects an Interface with a non-list Interface field list type') + */ + public function testRejectsAnInterfaceWithANonListInterfaceFieldListType() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: [String] + } + + interface ChildInterface implements ParentInterface { + field: String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expects type [String] ' . + 'but ChildInterface.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ], + ] + ); + } + + /** + * @see it('rejects an Interface with a list Interface field non-list type') + */ + public function testRejectsAnInterfaceWithAListInterfaceFieldNonListType() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String + } + + interface ChildInterface implements ParentInterface { + field: [String] + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expects type String ' . + 'but ChildInterface.field is type [String].', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ], + ] + ); + } + + /** + * @see it('accepts an Interface with a subset non-null Interface field type') + */ + public function testAcceptsAnInterfaceWithASubsetNonNullInterfaceFieldType() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String + } + + interface ChildInterface implements ParentInterface { + field: String! + } + '); + + self::assertEquals([], $schema->validate()); + } + + /** + * @see it('rejects an Interface with a superset nullable interface field type') + */ + public function testRejectsAnInterfaceWithASupsersetNullableInterfaceFieldType() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface ParentInterface { + field: String! + } + + interface ChildInterface implements ParentInterface { + field: String + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Interface field ParentInterface.field expects type String! ' . + 'but ChildInterface.field is type String.', + 'locations' => [['line' => 7, 'column' => 16], ['line' => 11, 'column' => 16]], + ], + ] + ); + } + + /** + * @see it('rejects an Object missing a transitive interface') + */ + public function testRejectsAnInterfaceMissingATransitiveInterface() : void + { + $schema = BuildSchema::build(' + type Query { + test: ChildInterface + } + + interface SuperInterface { + field: String! + } + + interface ParentInterface implements SuperInterface { + field: String! + } + + interface ChildInterface implements ParentInterface { + field: String! + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type ChildInterface must implement SuperInterface ' . + 'because it is implemented by ParentInterface.', + 'locations' => [['line' => 10, 'column' => 44], ['line' => 14, 'column' => 43]], + ], + ] + ); + } + + /** + * @see it('rejects a self reference interface') + */ + public function testRejectsASelfReferenceInterface() : void + { + $schema = BuildSchema::build(' + type Query { + test: FooInterface + } + + interface FooInterface implements FooInterface { + field: String! + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [[ + 'message' => 'Type FooInterface cannot implement itself ' . + 'because it would create a circular reference.', + 'locations' => [['line' => 6, 'column' => 41]], + ], + ] + ); + } + + /** + * @see it('rejects a circulare Interface implementation') + */ + public function testRejectsACircularInterfaceImplementation() : void + { + $schema = BuildSchema::build(' + type Query { + test: FooInterface + } + + interface FooInterface implements BarInterface { + field: String! + } + + interface BarInterface implements FooInterface { + field: String! + } + '); + + $this->assertMatchesValidationMessage( + $schema->validate(), + [ + [ + 'message' => 'Type FooInterface cannot implement BarInterface ' . + 'because it would create a circular reference.', + 'locations' => [['line' => 10, 'column' => 41], ['line' => 6, 'column' => 41]], + ], + [ + 'message' => 'Type BarInterface cannot implement FooInterface ' . + 'because it would create a circular reference.', + 'locations' => [['line' => 6, 'column' => 41], ['line' => 10, 'column' => 41]], + ], + ] + ); + } + public function testRejectsDifferentInstancesOfTheSameType() : void { // Invalid: always creates new instance vs returning one from registry diff --git a/tests/Utils/BreakingChangesFinderTest.php b/tests/Utils/BreakingChangesFinderTest.php index dc09e58fa..5253035cd 100644 --- a/tests/Utils/BreakingChangesFinderTest.php +++ b/tests/Utils/BreakingChangesFinderTest.php @@ -1037,7 +1037,7 @@ public function testShouldDetectInterfacesRemovedFromTypes() : void $expected = [ [ - 'type' => BreakingChangesFinder::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, + 'type' => BreakingChangesFinder::BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED, 'description' => 'Type1 no longer implements interface Interface1.', ], ]; @@ -1048,6 +1048,54 @@ public function testShouldDetectInterfacesRemovedFromTypes() : void ); } + /** + * @see it('should detect interfaces removed from interfaces') + */ + public function testShouldDetectInterfacesRemovedFromInterfaces() : void + { + $interface1 = new InterfaceType([ + 'name' => 'Interface1', + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $oldInterface2 = new InterfaceType([ + 'name' => 'Interface2', + 'fields' => [ + 'field1' => Type::string(), + ], + 'interfaces' => [$interface1], + ]); + $newInterface2 = new InterfaceType([ + 'name' => 'Interface2', + 'fields' => [ + 'field1' => Type::string(), + ], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$interface1, $oldInterface2], + ]); + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$interface1, $newInterface2], + ]); + + $expected = [ + [ + 'type' => BreakingChangesFinder::BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED, + 'description' => 'Interface2 no longer implements interface Interface1.', + ], + ]; + + self::assertEquals( + $expected, + BreakingChangesFinder::findInterfacesRemovedFromObjectTypes($oldSchema, $newSchema) + ); + } + /** * @see it('should detect all breaking changes') */ @@ -1291,7 +1339,7 @@ public function testShouldDetectAllBreakingChanges() : void 'description' => 'ArgThatChanges.field1 arg id has changed type from Int to String', ], [ - 'type' => BreakingChangesFinder::BREAKING_CHANGE_INTERFACE_REMOVED_FROM_OBJECT, + 'type' => BreakingChangesFinder::BREAKING_CHANGE_IMPLEMENTED_INTERFACE_REMOVED, 'description' => 'TypeThatLosesInterface1 no longer implements interface Interface1.', ], [ @@ -1659,7 +1707,7 @@ public function testShouldDetectInterfacesAddedToTypes() : void $expected = [ [ - 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED, 'description' => 'Interface1 added to interfaces implemented by Type1.', ], ]; @@ -1670,6 +1718,46 @@ public function testShouldDetectInterfacesAddedToTypes() : void ); } + /** + * @see it('should detect interfaces added to interfaces') + */ + public function testShouldDetectInterfacesAddedToInterfaces() : void + { + $oldInterface = new InterfaceType(['name' => 'OldInterface']); + $newInterface = new InterfaceType(['name' => 'NewInterface']); + + $oldInterface1 = new InterfaceType([ + 'name' => 'Interface1', + 'interfaces' => [$oldInterface], + ]); + $newInterface1 = new InterfaceType([ + 'name' => 'Interface1', + 'interfaces' => [$oldInterface, $newInterface], + ]); + + $oldSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$oldInterface1], + ]); + + $newSchema = new Schema([ + 'query' => $this->queryType, + 'types' => [$newInterface1], + ]); + + $expected = [ + [ + 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED, + 'description' => 'NewInterface added to interfaces implemented by Interface1.', + ], + ]; + + self::assertEquals( + $expected, + BreakingChangesFinder::findInterfacesAddedToObjectTypes($oldSchema, $newSchema) + ); + } + /** * @see it('should detect if a type was added to a union type') */ @@ -1903,7 +1991,7 @@ public function testShouldFindAllDangerousChanges() : void 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_VALUE_ADDED_TO_ENUM, ], [ - 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_INTERFACE_ADDED_TO_OBJECT, + 'type' => BreakingChangesFinder::DANGEROUS_CHANGE_IMPLEMENTED_INTERFACE_ADDED, 'description' => 'Interface1 added to interfaces implemented by TypeThatGainsInterface1.', ], [ diff --git a/tests/Utils/BuildClientSchemaTest.php b/tests/Utils/BuildClientSchemaTest.php index 5592f4b14..528592bdc 100644 --- a/tests/Utils/BuildClientSchemaTest.php +++ b/tests/Utils/BuildClientSchemaTest.php @@ -244,7 +244,6 @@ interface Friendly { */ public function testBuildsASchemaWithAnInterfaceHierarchy() : void { - self::markTestSkipped('Will work only once intermediate interfaces are possible'); self::assertCycleIntrospection(' type Dog implements Friendly & Named { bestFriend: Friendly @@ -767,7 +766,7 @@ public function testLegacySupportForInterfacesWithNullAsInterfacesField() : void $introspection = Introspection::fromSchema($dummySchema); $queryTypeIntrospection = null; foreach ($introspection['__schema']['types'] as &$type) { - if ($type['name'] !== 'Query') { + if ($type['name'] !== 'SomeInterface') { continue; } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index dde89942a..6ac487691 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -344,6 +344,32 @@ interface WorldInterface { self::assertEquals($output, $body); } + /** + * @see it('Simple interface heirarchy') + */ + public function testSimpleInterfaceHeirarchy() : void + { + $body = ' +schema { + query: Child +} + +interface Child implements Parent { + str: String +} + +type Hello implements Parent & Child { + str: String +} + +interface Parent { + str: String +} +'; + $output = $this->cycleOutput($body); + self::assertEquals($output, $body); + } + /** * @see it('Simple output enum') */ @@ -716,6 +742,28 @@ interface Iface { self::assertEquals($output, $body); } + /** + * @see it('Unreferenced interface implementing referenced interface') + */ + public function testUnreferencedInterfaceImplementingReferencedInterface() : void + { + $body = ' +interface Child implements Parent { + key: String +} + +interface Parent { + key: String +} + +type Query { + iface: Parent +} +'; + $output = $this->cycleOutput($body); + self::assertEquals($output, $body); + } + /** * @see it('Unreferenced type implementing referenced union') */ @@ -1301,7 +1349,8 @@ interface Hello { self::assertEquals('Hello', $defaultConfig['name']); self::assertInstanceOf(Closure::class, $defaultConfig['fields']); self::assertArrayHasKey('description', $defaultConfig); - self::assertCount(4, $defaultConfig); + self::assertArrayHasKey('interfaces', $defaultConfig); + self::assertCount(5, $defaultConfig); self::assertEquals(array_keys($allNodesMap), ['Query', 'Color', 'Hello']); self::assertEquals('My description of Hello', $schema->getType('Hello')->description); } diff --git a/tests/Utils/SchemaExtenderTest.php b/tests/Utils/SchemaExtenderTest.php index ea5fbc476..150f8eaba 100644 --- a/tests/Utils/SchemaExtenderTest.php +++ b/tests/Utils/SchemaExtenderTest.php @@ -70,19 +70,29 @@ public function setUp() : void 'name' => 'SomeInterface', 'fields' => static function () use (&$SomeInterfaceType) : array { return [ - 'name' => [ 'type' => Type::string()], 'some' => [ 'type' => $SomeInterfaceType], ]; }, ]); + $AnotherInterfaceType = new InterfaceType([ + 'name' => 'AnotherInterface', + 'interfaces' => [$SomeInterfaceType], + 'fields' => static function () use (&$AnotherInterfaceType) : array { + return [ + 'name' => [ 'type' => Type::string()], + 'some' => [ 'type' => $AnotherInterfaceType], + ]; + }, + ]); + $FooType = new ObjectType([ 'name' => 'Foo', - 'interfaces' => [$SomeInterfaceType], - 'fields' => static function () use ($SomeInterfaceType, &$FooType) : array { + 'interfaces' => [$AnotherInterfaceType, $SomeInterfaceType], + 'fields' => static function () use ($AnotherInterfaceType, &$FooType) : array { return [ 'name' => [ 'type' => Type::string() ], - 'some' => [ 'type' => $SomeInterfaceType ], + 'some' => [ 'type' => $AnotherInterfaceType ], 'tree' => [ 'type' => Type::nonNull(Type::listOf($FooType))], ]; }, @@ -93,7 +103,6 @@ public function setUp() : void 'interfaces' => [$SomeInterfaceType], 'fields' => static function () use ($SomeInterfaceType, $FooType) : array { return [ - 'name' => [ 'type' => Type::string() ], 'some' => [ 'type' => $SomeInterfaceType ], 'foo' => [ 'type' => $FooType ], ]; @@ -345,9 +354,9 @@ public function testExtendsObjectsByAddingNewFields() self::assertEquals( $this->printTestSchemaChanges($extendedSchema), $this->dedent(' - type Foo implements SomeInterface { + type Foo implements AnotherInterface & SomeInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! newField: String } @@ -781,9 +790,9 @@ public function testExtendsObjectsByAddingNewFieldsWithArguments() self::assertEquals( $this->dedent(' - type Foo implements SomeInterface { + type Foo implements AnotherInterface & SomeInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! newField(arg1: String, arg2: NewInputObj!): String } @@ -811,9 +820,9 @@ public function testExtendsObjectsByAddingNewFieldsWithExistingTypes() self::assertEquals( $this->dedent(' - type Foo implements SomeInterface { + type Foo implements AnotherInterface & SomeInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! newField(arg1: SomeEnum!): SomeEnum } @@ -885,9 +894,9 @@ enum NewEnum { self::assertEquals( $this->dedent(' - type Foo implements SomeInterface { + type Foo implements AnotherInterface & SomeInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! newObject: NewObject newInterface: NewInterface @@ -939,9 +948,9 @@ interface NewInterface { self::assertEquals( $this->dedent(' - type Foo implements SomeInterface & NewInterface { + type Foo implements AnotherInterface & SomeInterface & NewInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! baz: String } @@ -1059,6 +1068,10 @@ public function testExtendsInterfacesByAddingNewFields() extend interface SomeInterface { newField: String } + + extend interface AnotherInterface { + newField: String + } extend type Bar { newField: String @@ -1071,22 +1084,26 @@ public function testExtendsInterfacesByAddingNewFields() self::assertEquals( $this->dedent(' - type Bar implements SomeInterface { + interface AnotherInterface implements SomeInterface { name: String + some: AnotherInterface + newField: String + } + + type Bar implements SomeInterface { some: SomeInterface foo: Foo newField: String } - type Foo implements SomeInterface { + type Foo implements AnotherInterface & SomeInterface { name: String - some: SomeInterface + some: AnotherInterface tree: [Foo]! newField: String } interface SomeInterface { - name: String some: SomeInterface newField: String } @@ -1095,6 +1112,48 @@ interface SomeInterface { ); } + /** + * @see it('extends interfaces by adding new implemted interfaces') + */ + public function testExtendsInterfacesByAddingNewImplementedInterfaces() + { + $extendedSchema = $this->extendTestSchema(' + interface NewInterface { + newField: String + } + + extend interface AnotherInterface implements NewInterface { + newField: String + } + + extend type Foo implements NewInterface { + newField: String + } + '); + + self::assertEquals( + $this->dedent(' + interface AnotherInterface implements SomeInterface & NewInterface { + name: String + some: AnotherInterface + newField: String + } + + type Foo implements AnotherInterface & SomeInterface & NewInterface { + name: String + some: AnotherInterface + tree: [Foo]! + newField: String + } + + interface NewInterface { + newField: String + } + '), + $this->printTestSchemaChanges($extendedSchema) + ); + } + /** * @see it('allows extension of interface with missing Object fields') */ @@ -1112,7 +1171,6 @@ public function testAllowsExtensionOfInterfaceWithMissingObjectFields() self::assertEquals( $this->dedent(' interface SomeInterface { - name: String some: SomeInterface newField: String } @@ -1130,6 +1188,7 @@ public function testExtendsInterfacesMultipleTimes() extend interface SomeInterface { newFieldA: Int } + extend interface SomeInterface { newFieldB(test: Boolean): String } @@ -1138,7 +1197,6 @@ public function testExtendsInterfacesMultipleTimes() self::assertEquals( $this->dedent(' interface SomeInterface { - name: String some: SomeInterface newFieldA: Int newFieldB(test: Boolean): String diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index 9ffc51437..cd874e613 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -524,6 +524,68 @@ interface Foo { str: String } +type Query { + bar: Bar +} +', + $output + ); + } + + /** + * @see it('Print Hierarchical Interface') + */ + public function testPrintHierarchicalInterface() : void + { + $FooType = new InterfaceType([ + 'name' => 'Foo', + 'fields' => ['str' => ['type' => Type::string()]], + ]); + + $BaazType = new InterfaceType([ + 'name' => 'Baaz', + 'interfaces' => [$FooType], + 'fields' => [ + 'int' => ['type' => Type::int()], + 'str' => ['type' => Type::string()], + ], + ]); + + $BarType = new ObjectType([ + 'name' => 'Bar', + 'fields' => [ + 'str' => ['type' => Type::string()], + 'int' => ['type' => Type::int()], + ], + 'interfaces' => [$FooType, $BaazType], + ]); + + $query = new ObjectType([ + 'name' => 'Query', + 'fields' => ['bar' => ['type' => $BarType]], + ]); + + $schema = new Schema([ + 'query' => $query, + 'types' => [$BarType], + ]); + $output = $this->printForTest($schema); + self::assertEquals( + ' +interface Baaz implements Foo { + int: Int + str: String +} + +type Bar implements Foo & Baaz { + str: String + int: Int +} + +interface Foo { + str: String +} + type Query { bar: Bar } @@ -1063,7 +1125,7 @@ enum __TypeKind { OBJECT """ - Indicates this type is an interface. `fields` and `possibleTypes` are valid fields. + Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields. """ INTERFACE @@ -1283,7 +1345,7 @@ enum __TypeKind { # Indicates this type is an object. `fields` and `interfaces` are valid fields. OBJECT - # Indicates this type is an interface. `fields` and `possibleTypes` are valid fields. + # Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields. INTERFACE # Indicates this type is a union. `possibleTypes` is a valid field. diff --git a/tests/Validator/FragmentsOnCompositeTypesTest.php b/tests/Validator/FragmentsOnCompositeTypesTest.php index e8a77800a..50ad1712a 100644 --- a/tests/Validator/FragmentsOnCompositeTypesTest.php +++ b/tests/Validator/FragmentsOnCompositeTypesTest.php @@ -59,6 +59,23 @@ public function testObjectIsValidInlineFragmentType() : void ); } + /** + * @see it('interface is valid inline fragment type') + */ + public function testInterfaceIsValidInlineFragmentType() : void + { + $this->expectPassesRule( + new FragmentsOnCompositeTypes(), + ' + fragment validFragment on Mammal { + ... on Canine { + name + } + } + ' + ); + } + /** * @see it('inline fragment without type is valid') */