Skip to content

Commit

Permalink
NEW Interfaces in DataObjectScaffolder (fixes silverstripe#209)
Browse files Browse the repository at this point in the history
This enables more stable query creation with union types.
Instead of referring to specific types (e.g. "... on Page"),
you can use the relevant interface for that type (e.g. "...on PageInterface").
Since all types in the union share those interfaces, this results in less duplication
of fields in the query. It also makes the query resilient against adding new types
to the union (or subclasses to the DataObject), which would previously result in NULL query results.
  • Loading branch information
chillu committed Feb 21, 2019
1 parent c691a47 commit 0cbd2ea
Show file tree
Hide file tree
Showing 7 changed files with 368 additions and 13 deletions.
45 changes: 43 additions & 2 deletions src/Scaffolding/Scaffolders/DataObjectScaffolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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
]
);
}
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/Scaffolding/Scaffolders/InheritanceScaffolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
204 changes: 204 additions & 0 deletions src/Scaffolding/Scaffolders/InterfaceScaffolder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

namespace SilverStripe\GraphQL\Scaffolding\Scaffolders;

use Exception;
use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\ObjectType;
use InvalidArgumentException;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\GraphQL\Manager;
use SilverStripe\GraphQL\Scaffolding\Interfaces\ManagerMutatorInterface;
use SilverStripe\GraphQL\Scaffolding\StaticSchema;
use SilverStripe\ORM\DataObject;

/**
* Scaffolds a set of interfaces which apply to a DataObject,
* based on its class ancestry. This allows efficient querying
* of the union types created through {@link InheritanceScaffolder}.
*
* Without interfaces, using "...on <type>" 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;
}
}
21 changes: 18 additions & 3 deletions src/Scaffolding/Scaffolders/SchemaScaffolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 0cbd2ea

Please sign in to comment.