diff --git a/src/Scaffolding/Scaffolders/DataObjectScaffolder.php b/src/Scaffolding/Scaffolders/DataObjectScaffolder.php index 51999556e..269f70df6 100644 --- a/src/Scaffolding/Scaffolders/DataObjectScaffolder.php +++ b/src/Scaffolding/Scaffolders/DataObjectScaffolder.php @@ -30,6 +30,11 @@ /** * Scaffolds a DataObjectTypeCreator. + * + * Creates an interface with the same fields for each class in the ancestry. + * This is important for deterministic query creation: + * When new types are added to the resulting union (through new subclasses), + * it doesn't invalidate queries, since all types implement the interfaces of their parent classes. */ class DataObjectScaffolder implements ManagerMutatorInterface, ScaffolderInterface, ConfigurationApplier { @@ -63,6 +68,12 @@ class DataObjectScaffolder implements ManagerMutatorInterface, ScaffolderInterfa */ protected $nestedQueries = []; + /** + * @var string[] Used to set interfaces on the object. + * Relies on the actual types to be defined externally. + */ + protected $interfaceTypeNames; + /** * DataObjectScaffold constructor. * @@ -85,6 +96,24 @@ public function getTypeName() return $this->getDataObjectTypeName(); } + /** + * Name of all interface types related to this base type. + * + * @return string[] + */ + public function getInterfaceTypeNames() + { + return $this->interfaceTypeNames; + } + + /** + * @param string[] $interfaceTypeNames + */ + public function setInterfaceTypeNames($interfaceTypeNames) + { + $this->interfaceTypeNames = $interfaceTypeNames; + } + /** * Adds visible fields, and optional descriptions. * @@ -543,12 +572,24 @@ public function applyConfig(array $config) */ public function scaffold(Manager $manager) { + // Relies on interfaces for ancestors created through InterfaceScaffolder + $interfaceTypeNames = $this->getInterfaceTypeNames(); + $interfaceFn = null; + if ($interfaceTypeNames) { + $interfaceFn = function () use ($manager, $interfaceTypeNames) { + return array_map(function($interfaceTypeName) use ($manager) { + return $manager->getType($interfaceTypeName); + }, $interfaceTypeNames); + }; + } + return new ObjectType( [ 'name' => $this->getTypeName(), 'fields' => function () use ($manager) { return $this->createFields($manager); }, + 'interfaces' => $interfaceFn ] ); } @@ -561,9 +602,9 @@ public function scaffold(Manager $manager) public function addToManager(Manager $manager) { $this->extend('onBeforeAddToManager', $manager); - $scaffold = $this->scaffold($manager); + if (!$manager->hasType($this->getTypeName())) { - $manager->addType($scaffold, $this->getTypeName()); + $manager->addType($this->scaffold($manager), $this->getTypeName()); } foreach ($this->operations as $op) { diff --git a/src/Scaffolding/Scaffolders/InheritanceScaffolder.php b/src/Scaffolding/Scaffolders/InheritanceScaffolder.php index 6b86ea4e7..19842b41a 100644 --- a/src/Scaffolding/Scaffolders/InheritanceScaffolder.php +++ b/src/Scaffolding/Scaffolders/InheritanceScaffolder.php @@ -10,7 +10,8 @@ use SilverStripe\ORM\DataObject; /** - * Scaffolds a UnionType based on the ancestry of a DataObject class + * Scaffolds a UnionType based on the ancestry of a DataObject class. + * Relies on types being made available elsewhere. */ class InheritanceScaffolder extends UnionScaffolder implements ManagerMutatorInterface { diff --git a/src/Scaffolding/Scaffolders/InterfaceScaffolder.php b/src/Scaffolding/Scaffolders/InterfaceScaffolder.php new file mode 100644 index 000000000..60665a6d3 --- /dev/null +++ b/src/Scaffolding/Scaffolders/InterfaceScaffolder.php @@ -0,0 +1,204 @@ +" in unions + * will break queries as soon as a new type is added, + * even if you don't need fields from this new type on your specific query. + * The query can return entries of the new type, and without altering the query, + * none of the already queried fields are selected on this new type. + * Interfaces are effectively a workaround + * to represent a dynamic inheritance model in stable API types. + * + * Does not apply those interfaces to the DataObject type. + * Use {@link DataObjectScaffolder->setInterfaceTypeNames()} for this purpose. + */ +class InterfaceScaffolder implements ManagerMutatorInterface +{ + use Configurable; + + /** + * @var string + */ + protected $rootClass; + + /** + * @var string + */ + protected $suffix; + + /** + * @param string $rootDataObjectClass + * @param string $suffix + */ + public function __construct($rootDataObjectClass, $suffix = '') + { + $this->setRootClass($rootDataObjectClass); + $this->setSuffix($suffix); + } + + /** + * @return string + */ + public function getRootClass() + { + return $this->rootClass; + } + + /** + * @param string $rootClass + * @return self + */ + public function setRootClass($rootClass) + { + if (!class_exists($rootClass)) { + throw new InvalidArgumentException(sprintf( + 'Class %s does not exist.', + $rootClass + )); + } + + if (!is_subclass_of($rootClass, DataObject::class)) { + throw new InvalidArgumentException(sprintf( + 'Class %s is not a subclass of %s.', + $rootClass, + DataObject::class + )); + } + + $this->rootClass = $rootClass; + + return $this; + } + + /** + * @return string + */ + public function getSuffix() + { + return $this->suffix; + } + + /** + * @param string $suffix + * @return $this + */ + public function setSuffix($suffix) + { + $this->suffix = $suffix; + + return $this; + } + + /** + * @return string[] + */ + public function getTypeNames() + { + return array_map(function($baseTypeName) { + return $this->generateInterfaceTypeName($baseTypeName); + }, $this->getBaseTypeNames()); + } + + /** + * @param Manager $manager + */ + public function addToManager(Manager $manager) + { + foreach ($this->getBaseTypeNames() as $baseTypeName) { + $interfaceTypeName = $this->generateInterfaceTypeName($baseTypeName); + if (!$manager->hasType($interfaceTypeName)) { + $manager->addType( + $this->scaffoldInterfaceType($manager, $baseTypeName), + $this->generateInterfaceTypeName($baseTypeName) + ); + } + } + } + + /** + * @param Manager $manager + * @param string $baseTypeName + * @return InterfaceType + */ + public function scaffoldInterfaceType(Manager $manager, $baseTypeName) + { + return new InterfaceType([ + 'name' => $this->generateInterfaceTypeName($baseTypeName), + // Use same fields as base type + 'fields' => function () use ($manager, $baseTypeName) { + return $this->createFields($manager, $baseTypeName); + }, + 'resolveType' => function ($obj) use ($manager) { + if (!$obj instanceof DataObject) { + throw new Exception(sprintf( + 'Type with class %s is not a DataObject', + get_class($obj) + )); + } + $class = get_class($obj); + while ($class !== DataObject::class) { + $typeName = StaticSchema::inst()->typeNameForDataObject($class); + if ($manager->hasType($typeName)) { + return $manager->getType($typeName); + } + $class = get_parent_class($class); + } + throw new Exception(sprintf( + 'There is no type defined for %s, and none of its ancestors are defined.', + get_class($obj) + )); + } + ]); + } + + protected function createFields(Manager $manager, $baseTypeName) + { + $excludeFields = ['Versions']; + + $baseType = $manager->getType($baseTypeName); + return array_filter($baseType->getFields(), function ($field) use ($excludeFields) { + return !in_array($field->name, $excludeFields); + }); + } + + /** + * @return array + */ + protected function getBaseTypeNames() + { + $schema = StaticSchema::inst(); + + $tree = array_merge( + [$this->rootClass], + $schema->getAncestry($this->rootClass) + ); + + return array_map(function ($class) use ($schema) { + return $schema->typeNameForDataObject($class); + }, $tree); + } + + /** + * @return string + */ + protected function generateInterfaceTypeName($typeName) + { + return $typeName . $this->suffix; + } +} diff --git a/src/Scaffolding/Scaffolders/SchemaScaffolder.php b/src/Scaffolding/Scaffolders/SchemaScaffolder.php index 89e6fe358..4b8fbb1b5 100644 --- a/src/Scaffolding/Scaffolders/SchemaScaffolder.php +++ b/src/Scaffolding/Scaffolders/SchemaScaffolder.php @@ -289,16 +289,31 @@ public function addToManager(Manager $manager) $this->extend('onBeforeAddToManager', $manager); // Add all DataObjects to the manager - foreach ($this->types as $scaffold) { - $scaffold->addToManager($manager); + foreach ($this->types as $dataObjectScaffolder) { + // Types can't be modified after creation, so we need to determine the interface type names + // before actually creating the associated types below. + $interfaceScaffolder = new InterfaceScaffolder( + $dataObjectScaffolder->getDataObjectClass(), + StaticSchema::config()->get('interfaceTypeSuffix') + ); + $dataObjectScaffolder->setInterfaceTypeNames($interfaceScaffolder->getTypeNames()); + + // Create base type + $dataObjectScaffolder->addToManager($manager); + + // Create unions if required. + // Relies on registerPeripheralTypes() above to create the actual types. $inheritanceScaffolder = new InheritanceScaffolder( - $scaffold->getDataObjectClass(), + $dataObjectScaffolder->getDataObjectClass(), StaticSchema::config()->get('inheritanceTypeSuffix') ); // Due to shared ancestry, it's inevitable that the same union type will get added multiple times. if (!$manager->hasType($inheritanceScaffolder->getName())) { $inheritanceScaffolder->addToManager($manager); } + + // Create interfaces for base type + $interfaceScaffolder->addToManager($manager); } foreach ($this->queries as $scaffold) { diff --git a/src/Scaffolding/StaticSchema.php b/src/Scaffolding/StaticSchema.php index 91ebeee9f..7598f925a 100644 --- a/src/Scaffolding/StaticSchema.php +++ b/src/Scaffolding/StaticSchema.php @@ -7,7 +7,6 @@ use Exception; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Configurable; -use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\Manager; use SilverStripe\ORM\ArrayLib; use SilverStripe\ORM\DataObject; @@ -45,6 +44,12 @@ class StaticSchema */ private static $inheritanceTypeSuffix = 'WithDescendants'; + /** + * @config + * @var string + */ + private static $interfaceTypeSuffix = 'Interface'; + /** * @return static */ @@ -82,15 +87,38 @@ public static function reset() */ public function typeNameForDataObject($class) { - $customTypeName = $this->mappedTypeName($class); - if ($customTypeName) { - return $customTypeName; + $typeName = $this->mappedTypeName($class); + + // Alternatively infer from class name + if (!$typeName) { + $parts = explode('\\', $class); + $inferredName = sizeof($parts) > 1 ? $parts[0] . end($parts) : $parts[0]; + $typeName = $this->typeName($inferredName); + } + + return $typeName; + } + + /** + * See {@link typeNameForDataObject}. + * + * @param string $class + * @return string + */ + public function interfaceTypeNameForDataObject($class) + { + $typeName = $this->mappedTypeName($class); + + // Alternatively infer from class name + if (!$typeName) { + $parts = explode('\\', $class); + $inferredName = sizeof($parts) > 1 ? $parts[0] . end($parts) : $parts[0]; + $typeName = $this->typeName($inferredName); } - $parts = explode('\\', $class); - $typeName = sizeof($parts) > 1 ? $parts[0] . end($parts) : $parts[0]; + $typeName = $this->interfaceTypeNameForType($typeName); - return $this->typeName($typeName); + return $typeName; } /** @@ -114,6 +142,17 @@ public function inheritanceTypeNameForType($typeName) return $typeName . $this->config()->get('inheritanceTypeSuffix'); } + /** + * Gets the interface type name. + * + * @param string $typeName + * @return string + */ + public function interfaceTypeNameForType($typeName) + { + return $typeName . $this->config()->get('interfaceTypeSuffix'); + } + /** * @param string $str * @return mixed @@ -252,6 +291,27 @@ public function fetchFromManager($class, Manager $manager, $mode = self::PREFER_ )); } + /** + * Gets the interface type from the manager given a DataObject class. + * + * @param string $class + * @param Manager $manager + * @return Type + */ + public function fetchInterfaceFromManager($class, Manager $manager) + { + $typeName = $this->interfaceTypeNameForDataObject($class); + + if (!$manager->hasType($typeName)) { + throw new InvalidArgumentException(sprintf( + 'The class %s could not be resolved to any type in the manager instance.', + $class + )); + } + + return $manager->getType($typeName); + } + /** * @param Manager $manager * @return array diff --git a/tests/Fake/ExtendedDataObjectFake.php b/tests/Fake/ExtendedDataObjectFake.php new file mode 100644 index 000000000..3cc38da37 --- /dev/null +++ b/tests/Fake/ExtendedDataObjectFake.php @@ -0,0 +1,14 @@ + 'Varchar', + ]; +} diff --git a/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php b/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php index 6e9af401a..3f8a5e941 100644 --- a/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php +++ b/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php @@ -3,6 +3,7 @@ namespace SilverStripe\GraphQL\Tests\Scaffolders\Scaffolding; use Exception; +use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; use InvalidArgumentException; @@ -209,6 +210,25 @@ public function testDataObjectScaffolderAncestralClasses() ], $classes); } + public function testDataObjectScaffolderCreatesInterfaceType() + { + $scaffolder = new DataObjectScaffolder(DataObjectFake::class); + $managerMock = $this->getMockBuilder(Manager::class) + ->setMethods(['addType', 'hasType']) + ->getMock(); + $managerMock->method('hasType') + ->will($this->returnValue(false)); + + $managerMock->expects($this->exactly(2)) + ->method('addType') + ->withConsecutive( + [$this->isInstanceOf(ObjectType::class)], + [$this->isInstanceOf(InterfaceType::class)] + ); + + $scaffolder->addToManager($managerMock); + } + public function testDataObjectScaffolderApplyConfig() { /** @var DataObjectScaffolder $observer */