From 1190b40d27c7fabca23fc6596ffb83e5d6205913 Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Wed, 30 Jun 2021 19:02:48 +1200 Subject: [PATCH] Pulls/master/ extendsive refactor (#393) * First cut * Apply to queries, fix inheritance bugs * Tests passing * Initial fixes for decoupled * Numerous updates to union/interface generation to support Gatsby * Getting there * Back to schema generation in gatsby * Broke up Inheritance into separate services * Unit tests for new inheritance * Tests passing * Rename to SchemaConfigProvider * Linting * Add inheritance check for field mapping * Use correct event name, no dot syntax * Use unions on single results, e.g. has_one * NEW: Refactor to use interface queries (non-breaking) * Get tests passing * Allow type eager loading for types that don't appear in queries * Simplify exists() check so it doesn't create a new schema every time * Ensure method exists * MINOR: firstResult typed to SS_List for non-dataobject cases * Remove API change to SchemaUpdater * Linting * Fix method return type * Fix Enum overwriting local variable and breaking enumerated arrays * Add base resolver, allow native field to resolve * Change inheritance to apply on models, instead of updateSchema * Fix tests * Linting * Ensure inheritance follows versioned, include bespoke types in query collector, add hiddenAncestor * Cache query collection for performance Co-authored-by: Aaron Carlino --- _config/dataobject.yml | 1 - _config/dbtypes.yml | 4 +- _config/schema-global.yml | 13 +- src/Config/Configuration.php | 38 ++- src/Config/ModelConfiguration.php | 20 ++ src/Controller.php | 27 +- src/Dev/Build.php | 1 - src/QueryHandler/QueryHandler.php | 59 +++- src/QueryHandler/QueryHandlerInterface.php | 9 +- src/QueryHandler/QueryStateProvider.php | 52 +++ ...tProvider.php => SchemaConfigProvider.php} | 4 +- .../DataObject/AbstractTypeResolver.php | 45 +++ src/Schema/DataObject/CreateCreator.php | 8 +- src/Schema/DataObject/DataObjectModel.php | 40 ++- src/Schema/DataObject/InheritanceBuilder.php | 169 ++++++++++ src/Schema/DataObject/InheritanceChain.php | 129 ++----- .../DataObject/InheritanceUnionBuilder.php | 152 +++++++++ src/Schema/DataObject/InterfaceBuilder.php | 206 ++++++++++++ .../DataObject/Plugin/CanViewPermission.php | 32 +- src/Schema/DataObject/Plugin/Inheritance.php | 188 ++--------- .../DataObject/Plugin/InheritedPlugins.php | 1 + src/Schema/DataObject/Plugin/Paginator.php | 11 +- .../DataObject/Plugin/QueryCollector.php | 82 +++++ .../Plugin/QueryFilter/QueryFilter.php | 9 +- src/Schema/DataObject/Plugin/QuerySort.php | 6 +- src/Schema/DataObject/Resolver.php | 41 ++- src/Schema/DataObject/UpdateCreator.php | 8 +- src/Schema/Exception/ResolverFailure.php | 12 +- src/Schema/Field/Field.php | 14 + src/Schema/Field/ModelField.php | 4 + src/Schema/Interfaces/BaseFieldsProvider.php | 16 + src/Schema/Interfaces/SchemaUpdater.php | 3 + .../Plugin/AbstractQueryFilterPlugin.php | 4 +- src/Schema/Plugin/AbstractQuerySortPlugin.php | 4 +- src/Schema/Plugin/PluginConsumer.php | 2 +- src/Schema/Plugin/SortPlugin.php | 2 +- src/Schema/Resolver/JSONResolver.php | 34 ++ src/Schema/Schema.php | 169 +++++++++- src/Schema/SchemaBuilder.php | 4 +- src/Schema/SchemaConfig.php | 10 + src/Schema/Services/NestedInputBuilder.php | 25 +- src/Schema/Storage/CodeGenerationStore.php | 40 ++- .../Storage/templates/interface.inc.php | 7 + src/Schema/Type/CanonicalModelAware.php | 31 ++ src/Schema/Type/ModelInterfaceType.php | 29 ++ src/Schema/Type/ModelType.php | 46 ++- src/Schema/Type/ModelUnionType.php | 27 ++ src/Schema/Type/Type.php | 18 +- src/Schema/Type/UnionType.php | 20 ++ tests/Fake/Inheritance/A.php | 22 ++ tests/Fake/Inheritance/A1.php | 13 + tests/Fake/Inheritance/A1a.php | 13 + tests/Fake/Inheritance/A1b.php | 13 + tests/Fake/Inheritance/A2.php | 13 + tests/Fake/Inheritance/A2a.php | 13 + tests/Fake/Inheritance/B.php | 16 + tests/Fake/Inheritance/B1.php | 12 + tests/Fake/Inheritance/B1a.php | 13 + tests/Fake/Inheritance/B1b.php | 13 + tests/Fake/Inheritance/B2.php | 13 + tests/Fake/Inheritance/C.php | 16 + tests/Fake/Inheritance/C1.php | 13 + tests/Fake/Inheritance/C2.php | 13 + tests/Fake/Inheritance/C2a.php | 13 + .../DataObject/FakeInheritanceBuilder.php | 24 ++ .../FakeInheritanceUnionBuilder.php | 32 ++ .../DataObject/FakeInterfaceBuilder.php | 40 +++ .../DataObject/InheritanceBuilderTest.php | 317 ++++++++++++++++++ .../InheritanceUnionBuilderTest.php | 173 ++++++++++ .../DataObject/InterfaceBuilderTest.php | 181 ++++++++++ .../DataObject/Plugin/InheritanceTest.php | 163 +++++++++ tests/Schema/DataObject/TestSchema.php | 30 ++ tests/Schema/IntegrationTest.php | 23 +- tests/Schema/_testFieldInclusion/models.yml | 1 + tests/Schema/_testModelPlugins/models.yml | 2 + 75 files changed, 2674 insertions(+), 397 deletions(-) create mode 100644 src/QueryHandler/QueryStateProvider.php rename src/QueryHandler/{SchemaContextProvider.php => SchemaConfigProvider.php} (90%) create mode 100644 src/Schema/DataObject/AbstractTypeResolver.php create mode 100644 src/Schema/DataObject/InheritanceBuilder.php create mode 100644 src/Schema/DataObject/InheritanceUnionBuilder.php create mode 100644 src/Schema/DataObject/InterfaceBuilder.php create mode 100644 src/Schema/DataObject/Plugin/QueryCollector.php create mode 100644 src/Schema/Interfaces/BaseFieldsProvider.php create mode 100644 src/Schema/Resolver/JSONResolver.php create mode 100644 src/Schema/Type/CanonicalModelAware.php create mode 100644 src/Schema/Type/ModelInterfaceType.php create mode 100644 src/Schema/Type/ModelUnionType.php create mode 100644 tests/Fake/Inheritance/A.php create mode 100644 tests/Fake/Inheritance/A1.php create mode 100644 tests/Fake/Inheritance/A1a.php create mode 100644 tests/Fake/Inheritance/A1b.php create mode 100644 tests/Fake/Inheritance/A2.php create mode 100644 tests/Fake/Inheritance/A2a.php create mode 100644 tests/Fake/Inheritance/B.php create mode 100644 tests/Fake/Inheritance/B1.php create mode 100644 tests/Fake/Inheritance/B1a.php create mode 100644 tests/Fake/Inheritance/B1b.php create mode 100644 tests/Fake/Inheritance/B2.php create mode 100644 tests/Fake/Inheritance/C.php create mode 100644 tests/Fake/Inheritance/C1.php create mode 100644 tests/Fake/Inheritance/C2.php create mode 100644 tests/Fake/Inheritance/C2a.php create mode 100644 tests/Schema/DataObject/FakeInheritanceBuilder.php create mode 100644 tests/Schema/DataObject/FakeInheritanceUnionBuilder.php create mode 100644 tests/Schema/DataObject/FakeInterfaceBuilder.php create mode 100644 tests/Schema/DataObject/InheritanceBuilderTest.php create mode 100644 tests/Schema/DataObject/InheritanceUnionBuilderTest.php create mode 100644 tests/Schema/DataObject/InterfaceBuilderTest.php create mode 100644 tests/Schema/DataObject/Plugin/InheritanceTest.php create mode 100644 tests/Schema/DataObject/TestSchema.php diff --git a/_config/dataobject.yml b/_config/dataobject.yml index 72c29221f..f1280ebdf 100644 --- a/_config/dataobject.yml +++ b/_config/dataobject.yml @@ -3,7 +3,6 @@ Name: silverstripe-graphql-dataobject --- SilverStripe\ORM\DataObject: graphql_blacklisted_fields: - ClassName: true LinkTracking: true FileTracking: true extensions: diff --git a/_config/dbtypes.yml b/_config/dbtypes.yml index fe6a2cb9e..6fb46cbd8 100644 --- a/_config/dbtypes.yml +++ b/_config/dbtypes.yml @@ -13,6 +13,6 @@ SilverStripe\ORM\FieldType\DBFloat: SilverStripe\ORM\FieldType\DBDecimal: graphql_type: Float SilverStripe\ORM\FieldType\DBPrimaryKey: - graphql_type: ID + graphql_type: ID! SilverStripe\ORM\FieldType\DBForeignKey: - graphql_type: ID + graphql_type: ID! diff --git a/_config/schema-global.yml b/_config/schema-global.yml index e3bd18818..275ac989b 100644 --- a/_config/schema-global.yml +++ b/_config/schema-global.yml @@ -4,6 +4,11 @@ Name: 'graphql-schema-global' SilverStripe\GraphQL\Schema\Schema: schemas: '*': + scalars: + JSONBlob: + serialiser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::serialise' + valueParser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::parseValue' + literalParser: 'SilverStripe\GraphQL\Schema\Resolver\JSONResolver::parseLiteral' config: resolverStrategy: 'SilverStripe\GraphQL\Schema\Resolver\DefaultResolverStrategy::getResolverMethod' defaultResolver: 'SilverStripe\GraphQL\Schema\Resolver\DefaultResolver::defaultFieldResolver' @@ -14,8 +19,14 @@ SilverStripe\GraphQL\Schema\Schema: type_formatter: 'SilverStripe\Core\ClassInfo::shortName' type_prefix: '' type_mapping: [] + base_fields: + ID: ID plugins: - inheritance: true + inheritance: + useUnionQueries: false + hideAncestors: + - SilverStripe\CMS\Model\SiteTree + after: 'versioning' inheritedPlugins: after: '*' operations: diff --git a/src/Config/Configuration.php b/src/Config/Configuration.php index 92a173403..0efc8efdc 100644 --- a/src/Config/Configuration.php +++ b/src/Config/Configuration.php @@ -53,11 +53,11 @@ public function get($path, $default = null) /** * @param $path - * @param $value + * @param callable $callback * @return $this * @throws SchemaBuilderException */ - public function set($path, $value): self + private function path($path, $callback): void { if (is_string($path)) { $path = explode('.', $path); @@ -72,8 +72,8 @@ public function set($path, $value): self foreach ($path as $i => $part) { $last = ($i + 1) === sizeof($path); if ($last) { - $scope[$part] = $value; - return $this; + $callback($scope, $part); + return; } if (!isset($scope[$part])) { $scope[$part] = []; @@ -82,6 +82,36 @@ public function set($path, $value): self } } + /** + * @param $path + * @param $value + * @return $this + * @throws SchemaBuilderException + */ + public function set($path, $value): self + { + $this->path($path, function (&$scope, $part) use ($value) { + $scope[$part] = $value; + }); + + return $this; + } + + /** + * @param $path + * @param $value + * @return $this + * @throws SchemaBuilderException + */ + public function unset($path): self + { + $this->path($path, function (&$scope, $part) { + unset($scope[$part]); + }); + + return $this; + } + /** * @param array $settings * @return $this diff --git a/src/Config/ModelConfiguration.php b/src/Config/ModelConfiguration.php index babb9ffcb..e6213e9b9 100644 --- a/src/Config/ModelConfiguration.php +++ b/src/Config/ModelConfiguration.php @@ -64,6 +64,26 @@ public function getTypeName(string $class): string return $prefix . $typeName; } + /** + * Fields that are added to the model by default. Can be opted out per type + * @return array + * @throws SchemaBuilderException + */ + public function getDefaultFields(): array + { + return $this->get('default_fields', []); + } + + /** + * Fields that will appear on all models. Cannot be opted out on any type. + * @return array + * @throws SchemaBuilderException + */ + public function getBaseFields(): array + { + return $this->get('base_fields', []); + } + /** * @param string $class * @return string diff --git a/src/Controller.php b/src/Controller.php index 3ed897d5a..2eeef74cc 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -3,6 +3,8 @@ namespace SilverStripe\GraphQL; use Exception; +use GraphQL\Language\Parser; +use GraphQL\Language\Source; use InvalidArgumentException; use LogicException; use SilverStripe\Control\Controller as BaseController; @@ -15,13 +17,14 @@ use SilverStripe\EventDispatcher\Symfony\Event; use SilverStripe\GraphQL\Auth\Handler; use SilverStripe\GraphQL\PersistedQuery\RequestProcessor; +use SilverStripe\GraphQL\QueryHandler\QueryHandler; use SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface; +use SilverStripe\GraphQL\QueryHandler\QueryStateProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\QueryHandler\RequestContextProvider; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\QueryHandler\TokenContextProvider; use SilverStripe\GraphQL\QueryHandler\UserContextProvider; -use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\SchemaBuilder; use SilverStripe\Security\Member; use SilverStripe\Security\Permission; @@ -128,21 +131,22 @@ public function index(HTTPRequest $request): HTTPResponse } $handler = $this->getQueryHandler(); $this->applyContext($handler); - + $queryDocument = Parser::parse(new Source($query)); $ctx = $handler->getContext(); + $result = $handler->query($graphqlSchema, $query, $variables); + + // Fire an eventYou $eventContext = [ 'schema' => $graphqlSchema, + 'schemaKey' => $this->getSchemaKey(), 'query' => $query, 'context' => $ctx, 'variables' => $variables, + 'result' => $result, ]; - - Dispatcher::singleton()->trigger('onGraphQLQuery', Event::create($this->getSchemaKey(), $eventContext)); - - $result = $handler->query($graphqlSchema, $query, $variables); - $eventContext['result'] = $result; - - Dispatcher::singleton()->trigger('onGraphQLResponse', Event::create($this->getSchemaKey(), $eventContext)); + $event = QueryHandler::isMutation($query) ? 'graphqlMutation' : 'graphqlQuery'; + $operationName = QueryHandler::getOperationName($queryDocument); + Dispatcher::singleton()->trigger($event, Event::create($operationName, $eventContext)); } catch (Exception $exception) { $error = ['message' => $exception->getMessage()]; @@ -305,8 +309,9 @@ protected function applyContext(QueryHandlerInterface $handler) ->addContextProvider(RequestContextProvider::create($request)); $schemaContext = SchemaBuilder::singleton()->getConfig($this->getSchemaKey()); if ($schemaContext) { - $handler->addContextProvider(SchemaContextProvider::create($schemaContext)); + $handler->addContextProvider(SchemaConfigProvider::create($schemaContext)); } + $handler->addContextProvider(QueryStateProvider::create()); } /** diff --git a/src/Dev/Build.php b/src/Dev/Build.php index 22f1ee14e..963d058a1 100644 --- a/src/Dev/Build.php +++ b/src/Dev/Build.php @@ -12,7 +12,6 @@ use SilverStripe\GraphQL\Schema\Exception\SchemaNotFoundException; use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\SchemaBuilder; -use SilverStripe\ORM\DatabaseAdmin; class Build extends Controller { diff --git a/src/QueryHandler/QueryHandler.php b/src/QueryHandler/QueryHandler.php index fb2e5027f..68a6f7362 100644 --- a/src/QueryHandler/QueryHandler.php +++ b/src/QueryHandler/QueryHandler.php @@ -7,6 +7,7 @@ use GraphQL\Error\SyntaxError; use GraphQL\Executor\ExecutionResult; use GraphQL\GraphQL; +use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; use GraphQL\Language\Source; @@ -22,6 +23,7 @@ use SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider; use SilverStripe\GraphQL\PersistedQuery\PersistedQueryProvider; use SilverStripe\GraphQL\Schema\Interfaces\ContextProvider; +use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\ORM\ValidationException; /** @@ -69,11 +71,11 @@ public function __construct(array $contextProviders = []) /** * @param GraphQLSchema $schema - * @param string $query + * @param string|DocumentNode $query * @param array|null $vars * @return array */ - public function query(GraphQLSchema $schema, string $query, ?array $vars = []): array + public function query(GraphQLSchema $schema, $query, ?array $vars = []): array { $executionResult = $this->queryAndReturnResult($schema, $query, $vars); @@ -86,11 +88,11 @@ public function query(GraphQLSchema $schema, string $query, ?array $vars = []): /** * @param GraphQLSchema $schema - * @param string $query + * @param string|DocumentNode $query * @param array|null $vars * @return array|ExecutionResult */ - public function queryAndReturnResult(GraphQLSchema $schema, string $query, ?array $vars = []) + public function queryAndReturnResult(GraphQLSchema $schema, $query, ?array $vars = []) { $context = $this->getContext(); $last = function ($schema, $query, $context, $vars) { @@ -204,16 +206,6 @@ public function addMiddleware(QueryMiddleware $middleware): self return $this; } - /** - * @return array - */ - protected function getContextDefaults(): array - { - return [ - self::CURRENT_USER => $this->getMemberContext(), - ]; - } - /** * Call middleware to evaluate a graphql query * @@ -296,7 +288,7 @@ public static function isMutation(string $query): bool } // Otherwise, bring in the big guns. - $document = Parser::parse(new Source($query ?: 'GraphQL')); + $document = Parser::parse(new Source($query)); $defs = $document->definitions; foreach ($defs as $statement) { $options = [ @@ -313,4 +305,41 @@ public static function isMutation(string $query): bool return false; } + + /** + * @param DocumentNode $document + * @param bool $preferStatementName If true, use query . If false, use the actual field name. + * @return string + */ + public static function getOperationName(DocumentNode $document, bool $preferStatementName = true): string + { + $defs = $document->definitions; + foreach ($defs as $statement) { + $options = [ + NodeKind::OPERATION_DEFINITION, + NodeKind::OPERATION_TYPE_DEFINITION + ]; + if (!in_array($statement->kind, $options, true)) { + continue; + } + if (in_array($statement->operation, ['query', 'mutation'])) { + // If the operation was given a name, use that + $name = $statement->name; + if ($name && $name->value && $preferStatementName) { + return $name->value; + } + $selectionSet = $statement->selectionSet; + if ($selectionSet) { + $selections = $selectionSet->selections; + if (!empty($selections)) { + $firstField = $selections[0]; + + return $firstField->name->value; + } + } + return $statement->operation; + } + } + return 'graphql'; + } } diff --git a/src/QueryHandler/QueryHandlerInterface.php b/src/QueryHandler/QueryHandlerInterface.php index 3885e3dda..9176f5f7b 100644 --- a/src/QueryHandler/QueryHandlerInterface.php +++ b/src/QueryHandler/QueryHandlerInterface.php @@ -4,6 +4,7 @@ namespace SilverStripe\GraphQL\QueryHandler; use GraphQL\Executor\ExecutionResult; +use GraphQL\Language\AST\DocumentNode; use GraphQL\Type\Schema; use SilverStripe\GraphQL\Schema\Interfaces\ContextProvider; @@ -13,7 +14,13 @@ */ interface QueryHandlerInterface { - public function query(Schema $schema, string $query, array $params = []): array; + /** + * @param Schema $schema + * @param string|DocumentNode $query + * @param array $params + * @return array + */ + public function query(Schema $schema, $query, array $params = []): array; /** * Serialise a Graphql result object for output diff --git a/src/QueryHandler/QueryStateProvider.php b/src/QueryHandler/QueryStateProvider.php new file mode 100644 index 000000000..cf7990ae4 --- /dev/null +++ b/src/QueryHandler/QueryStateProvider.php @@ -0,0 +1,52 @@ +queryState = new Configuration(); + } + + /** + * @param array $context + * @return mixed|null + */ + public static function get(array $context): Configuration + { + return $context[self::KEY] ?? new Configuration(); + } + + /** + * @return array[] + */ + public function provideContext(): array + { + return [ + self::KEY => $this->queryState, + ]; + } +} diff --git a/src/QueryHandler/SchemaContextProvider.php b/src/QueryHandler/SchemaConfigProvider.php similarity index 90% rename from src/QueryHandler/SchemaContextProvider.php rename to src/QueryHandler/SchemaConfigProvider.php index dd53a9a5c..bbc0b0e3a 100644 --- a/src/QueryHandler/SchemaContextProvider.php +++ b/src/QueryHandler/SchemaConfigProvider.php @@ -7,7 +7,7 @@ use SilverStripe\GraphQL\Schema\Interfaces\ContextProvider; use SilverStripe\GraphQL\Schema\SchemaConfig; -class SchemaContextProvider implements ContextProvider +class SchemaConfigProvider implements ContextProvider { use Injectable; @@ -19,7 +19,7 @@ class SchemaContextProvider implements ContextProvider private $schemaConfig; /** - * SchemaContextProvider constructor. + * SchemaConfigProvider constructor. * @param SchemaConfig $schemaConfig */ public function __construct(SchemaConfig $schemaConfig) diff --git a/src/Schema/DataObject/AbstractTypeResolver.php b/src/Schema/DataObject/AbstractTypeResolver.php new file mode 100644 index 000000000..ec8ebe1e1 --- /dev/null +++ b/src/Schema/DataObject/AbstractTypeResolver.php @@ -0,0 +1,45 @@ +hasModel($class)) { + if ($class === DataObject::class) { + throw new Exception(sprintf( + 'No models were registered in the ancestry of %s', + get_class($obj) + )); + } + $class = get_parent_class($class); + Schema::invariant( + $class, + 'Could not resolve type for %s.', + get_class($obj) + ); + } + return $schemaContext->getTypeNameForClass($class); + } +} diff --git a/src/Schema/DataObject/CreateCreator.php b/src/Schema/DataObject/CreateCreator.php index 56a772124..e36566784 100644 --- a/src/Schema/DataObject/CreateCreator.php +++ b/src/Schema/DataObject/CreateCreator.php @@ -8,7 +8,7 @@ use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\QueryHandler\QueryHandler; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\QueryHandler\UserContextProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Field\ModelMutation; @@ -81,12 +81,12 @@ public static function resolve(array $resolverContext = []): Closure if (!$dataClass) { return null; } - $schema = SchemaContextProvider::get($context); + $schema = SchemaConfigProvider::get($context); Schema::invariant( $schema, 'Could not access schema in resolver for %s. Did you not add the %s context provider?', __CLASS__, - SchemaContextProvider::class + SchemaConfigProvider::class ); $singleton = Injector::inst()->get($dataClass); $member = UserContextProvider::get($context); @@ -134,7 +134,7 @@ public function provideInputTypes(ModelType $modelType, array $config = []): arr if (!$fieldObj) { continue; } - $type = $fieldObj->getType(); + $type = $fieldObj->getNamedType(); if ($type && Schema::isInternalType($type)) { $fieldMap[$fieldName] = $type; } diff --git a/src/Schema/DataObject/DataObjectModel.php b/src/Schema/DataObject/DataObjectModel.php index 89fd5d741..10309ba7e 100644 --- a/src/Schema/DataObject/DataObjectModel.php +++ b/src/Schema/DataObject/DataObjectModel.php @@ -9,6 +9,7 @@ use SilverStripe\GraphQL\Config\ModelConfiguration; use SilverStripe\GraphQL\Schema\Field\ModelField; use SilverStripe\GraphQL\Schema\Field\ModelQuery; +use SilverStripe\GraphQL\Schema\Interfaces\BaseFieldsProvider; use SilverStripe\GraphQL\Schema\Interfaces\DefaultFieldsProvider; use SilverStripe\GraphQL\Schema\Interfaces\ModelBlacklist; use SilverStripe\GraphQL\Schema\Resolver\ResolverReference; @@ -31,6 +32,7 @@ class DataObjectModel implements SchemaModelInterface, OperationProvider, DefaultFieldsProvider, + BaseFieldsProvider, ModelBlacklist { use Injectable; @@ -141,13 +143,43 @@ public function getField(string $fieldName, array $config = []): ?ModelField /** * @return array + * @throws SchemaBuilderException */ public function getDefaultFields(): array { - $idField = $this->getFieldAccessor()->formatField('ID'); - return [ - $idField => 'ID', - ]; + $fields = $this->getModelConfiguration()->getDefaultFields(); + $map = []; + foreach ($fields as $name => $type) { + if ($type === false) { + continue; + } + $formatted = $this->getFieldAccessor()->formatField($name); + $map[$formatted] = $type; + } + + return $map; + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getBaseFields(): array + { + $fields = $this->getModelConfiguration()->getBaseFields(); + $map = []; + foreach ($fields as $name => $type) { + Schema::invariant( + $type, + 'Default field %s cannot be falsy on %s', + $name, + $this->getSourceClass() + ); + $formatted = $this->getFieldAccessor()->formatField($name); + $map[$formatted] = $type; + } + + return $map; } /** diff --git a/src/Schema/DataObject/InheritanceBuilder.php b/src/Schema/DataObject/InheritanceBuilder.php new file mode 100644 index 000000000..50cbe6144 --- /dev/null +++ b/src/Schema/DataObject/InheritanceBuilder.php @@ -0,0 +1,169 @@ +setSchema($schema); + $this->hideAncestors = $hideAncestors; + } + + /** + * @param ModelType $modelType + * @throws SchemaBuilderException + */ + public function fillAncestry(ModelType $modelType): void + { + $chain = InheritanceChain::create($modelType->getModel()->getSourceClass()) + ->hideAncestors($this->hideAncestors); + $ancestors = $chain->getAncestralModels(); + if (empty($ancestors)) { + return; + } + $parent = $ancestors[0]; + $parentModel = $this->getSchema()->findOrMakeModel($parent); + // Merge descendant fields up into the ancestor + foreach ($modelType->getFields() as $fieldObj) { + // If the field already exists on the ancestor, skip it + if ($parentModel->getFieldByName($fieldObj->getName())) { + continue; + } + $fieldName = $fieldObj instanceof ModelField + ? $fieldObj->getPropertyName() + : $fieldObj->getName(); + // If the field is unique to the descendant, skip it. + if ($parentModel->getModel()->hasField($fieldName)) { + $clone = clone $fieldObj; + $parentModel->addField($fieldObj->getName(), $clone); + } + } + $this->fillAncestry($parentModel); + } + + /** + * @param ModelType $modelType + * @return void + * @throws ReflectionException + * @throws SchemaBuilderException + */ + public function fillDescendants(ModelType $modelType): void + { + $chain = InheritanceChain::create($modelType->getModel()->getSourceClass()) + ->hideAncestors($this->hideAncestors); + + $descendants = $chain->getDirectDescendants(); + if (empty($descendants)) { + return; + } + foreach ($descendants as $descendant) { + $descendantModel = $this->getSchema()->getModelByClassName($descendant); + if ($descendantModel) { + foreach ($modelType->getFields() as $fieldObj) { + if ($descendantModel->getFieldByName($fieldObj->getName())) { + continue; + } + $clone = clone $fieldObj; + $descendantModel->addField($fieldObj->getName(), $clone); + } + $this->fillDescendants($descendantModel); + } + } + } + + /** + * @param string $class + * @return bool + */ + public function isBaseModel(string $class): bool + { + if (!$this->getSchema()->getModelByClassName($class)) { + return false; + } + + $chain = InheritanceChain::create($class) + ->hideAncestors($this->hideAncestors); + + if ($chain->getBaseClass() === $class) { + return true; + } + foreach ($chain->getAncestralModels() as $class) { + if ($this->getSchema()->getModelByClassName($class)) { + return false; + } + } + + return true; + } + + /** + * @param string $class + * @return bool + * @throws ReflectionException + */ + public function isLeafModel(string $class): bool + { + if (!$this->getSchema()->getModelByClassName($class)) { + return false; + } + + $chain = InheritanceChain::create($class) + ->hideAncestors($this->hideAncestors); + + foreach ($chain->getDescendantModels() as $class) { + if ($this->getSchema()->getModelByClassName($class)) { + return false; + } + } + return true; + } + + /** + * @return Schema + */ + public function getSchema(): Schema + { + return $this->schema; + } + + /** + * @param Schema $schema + * @return InheritanceBuilder + */ + public function setSchema(Schema $schema): InheritanceBuilder + { + $this->schema = $schema; + return $this; + } +} diff --git a/src/Schema/DataObject/InheritanceChain.php b/src/Schema/DataObject/InheritanceChain.php index 673526f13..2da4eaa49 100644 --- a/src/Schema/DataObject/InheritanceChain.php +++ b/src/Schema/DataObject/InheritanceChain.php @@ -34,27 +34,14 @@ class InheritanceChain private $inst; /** - * @var string - * @config - */ - private static $field_name = '_extend'; - - /** - * @var callable - * @config - */ - private static $descendant_typename_creator = [ self::class, 'createDescendantTypename' ]; - - /** - * @var callable - * @config + * @var array */ - private static $subtype_name_creator = [ self::class, 'createSubtypeName' ]; + private $hiddenAncestors = []; /** * @var array */ - private $descendantTypeResult; + private $hiddenDescendants = []; /** * InheritanceChain constructor. @@ -73,14 +60,6 @@ public function __construct(string $dataObjectClass) $this->inst = DataObject::singleton($this->dataObjectClass); } - /** - * @return string - */ - public static function getName(): string - { - return static::config()->get('field_name'); - } - /** * @return array */ @@ -93,6 +72,9 @@ public function getAncestralModels(): array if ($class === $this->dataObjectClass) { continue; } + if (in_array($class, $this->hiddenAncestors)) { + continue; + } if ($class == DataObject::class) { break; } @@ -110,6 +92,18 @@ public function hasAncestors(): bool return count($this->getAncestralModels()) > 0; } + /** + * Hides ancestors by classname, e.g. SiteTree::class + * @param array $ancestors + * @return $this + */ + public function hideAncestors(array $ancestors): self + { + $this->hiddenAncestors = $ancestors; + + return $this; + } + /** * @return array * @throws ReflectionException @@ -118,7 +112,9 @@ public function getDescendantModels(): array { $descendants = ClassInfo::subclassesFor($this->dataObjectClass, false); - return array_values($descendants); + return array_filter(array_values($descendants), function ($class) { + return !in_array($class, $this->hiddenDescendants); + }); } /** @@ -143,92 +139,39 @@ public function hasDescendants(): bool } /** - * @return string + * @param array $descendants + * @return $this */ - public function getBaseClass(): string + public function hideDescendants(array $descendants): self { - return $this->inst->baseClass(); + $this->hiddenDescendants = $descendants; + + return $this; } /** - * @param SchemaConfig $schemaContext - * @return array|null + * @return bool * @throws ReflectionException - * @throws SchemaBuilderException */ - public function getExtensionType(SchemaConfig $schemaContext): ?array + public function hasInheritance(): bool { - if ($this->descendantTypeResult) { - return $this->descendantTypeResult; - } - if (empty($this->getDescendantModels())) { - return null; - } - $typeName = call_user_func_array( - $this->config()->get('descendant_typename_creator'), - [$this->inst, $schemaContext] - ); - - $nameCreator = $this->config()->get('subtype_name_creator'); - - $subtypes = []; - foreach ($this->getDescendantModels() as $className) { - $model = $schemaContext->createModel($className); - if (!$model) { - continue; - } - $modelType = ModelType::create($model); - $originalName = $modelType->getName(); - $newName = call_user_func_array($nameCreator, [$originalName]); - $modelType->setName($newName); - $subtypes[$originalName] = $modelType; - } - - $descendantType = Type::create($typeName, [ - 'fieldResolver' => [static::class, 'resolveExtensionType'], - ]); - - $this->descendantTypeResult = [$descendantType, $subtypes]; - - return $this->descendantTypeResult; + return $this->hasDescendants() || $this->hasAncestors(); } - - /** - * @param DataObject $dataObject - * @param SchemaConfig $schemaContext - * @return string - * @throws SchemaBuilderException + * @return array + * @throws ReflectionException */ - public static function createDescendantTypename(DataObject $dataObject, SchemaConfig $schemaContext): string + public function getInheritance(): array { - $model = $schemaContext->createModel(get_class($dataObject)); - Schema::invariant( - $model, - 'No model defined for %s. Cannot create inheritance typename', - get_class($dataObject) - ); - - return $model->getTypeName() . 'Descendants'; + return array_merge($this->getAncestralModels(), $this->getDescendantModels()); } /** - * @param string $modelTypeName * @return string */ - public static function createSubtypeName(string $modelTypeName): string - { - return $modelTypeName . 'ExtensionType'; - } - - /** - * Noop, because __extends is just structure - * @param $obj - * @return DataObject|null - */ - public static function resolveExtensionType($obj): ?DataObject + public function getBaseClass(): string { - return $obj; + return $this->inst->baseClass(); } } diff --git a/src/Schema/DataObject/InheritanceUnionBuilder.php b/src/Schema/DataObject/InheritanceUnionBuilder.php new file mode 100644 index 000000000..018828470 --- /dev/null +++ b/src/Schema/DataObject/InheritanceUnionBuilder.php @@ -0,0 +1,152 @@ +setSchema($schema); + } + + /** + * @param ModelType $modelType + * @return void + * @throws ReflectionException + * @throws SchemaBuilderException + * @return $this + */ + public function createUnions(ModelType $modelType): InheritanceUnionBuilder + { + $schema = $this->getSchema(); + $chain = InheritanceChain::create($modelType->getModel()->getSourceClass()); + if (!$chain->hasDescendants()) { + return $this; + } + $name = static::unionName($modelType->getName(), $schema->getConfig()); + $union = ModelUnionType::create($modelType, $name); + + $types = array_filter(array_map(function ($class) use ($schema) { + if (!$schema->getModelByClassName($class)) { + return null; + } + return $schema->getConfig()->getTypeNameForClass($class); + }, $chain->getDescendantModels())); + + if (empty($types)) { + return $this; + } + + $types[] = $modelType->getName(); + + $union->setTypes($types); + $union->setTypeResolver([AbstractTypeResolver::class, 'resolveType']); + $schema->addUnion($union); + return $this; + } + + /** + * Changes all queries to use inheritance unions where applicable + * @param ModelType $modelType + * @throws SchemaBuilderException + * @return $this + */ + public function applyUnionsToQueries(ModelType $modelType): InheritanceUnionBuilder + { + $schema = $this->getSchema(); + $queryCollector = QueryCollector::create($schema); + + /* @var ModelQuery $query */ + foreach ($queryCollector->collectQueriesForType($modelType) as $query) { + $typeName = $query->getNamedType(); + $modelType = $schema->getModel($typeName); + // Type was customised. Ignore. + if (!$modelType) { + continue; + } + if (!$modelType->getModel() instanceof DataObjectModel) { + continue; + } + + $unionName = static::unionName($modelType->getName(), $schema->getConfig()); + if ($union = $schema->getUnion($unionName)) { + $query->setNamedType($unionName); + } + } + + return $this; + } + + /** + * @param string $modelName + * @param SchemaConfig $schemaConfig + * @return string + * @throws SchemaBuilderException + */ + public static function unionName(string $modelName, SchemaConfig $schemaConfig): string + { + $callable = $schemaConfig->get( + 'inheritanceUnionBuilder.name_formatter', + [static:: class, 'defaultUnionFormatter'] + ); + return $callable($modelName); + } + + /** + * @param string $modelName + * @return string + */ + public static function defaultUnionFormatter(string $modelName): string + { + return $modelName . 'InheritanceUnion'; + } + + + /** + * @return Schema + */ + public function getSchema(): Schema + { + return $this->schema; + } + + /** + * @param Schema $schema + * @return InheritanceUnionBuilder + */ + public function setSchema(Schema $schema): InheritanceUnionBuilder + { + $this->schema = $schema; + return $this; + } +} diff --git a/src/Schema/DataObject/InterfaceBuilder.php b/src/Schema/DataObject/InterfaceBuilder.php new file mode 100644 index 000000000..c4dc48e41 --- /dev/null +++ b/src/Schema/DataObject/InterfaceBuilder.php @@ -0,0 +1,206 @@ +setSchema($schema); + } + + /** + * @param ModelType $modelType + * @param ModelInterfaceType[] $interfaceStack + * @throws ReflectionException + * @throws SchemaBuilderException + * @return $this + */ + public function createInterfaces(ModelType $modelType, array $interfaceStack = []): InterfaceBuilder + { + $interface = ModelInterfaceType::create( + $modelType, + self::interfaceName($modelType->getName(), $this->getSchema()->getConfig()) + ) + ->setTypeResolver([AbstractTypeResolver::class, 'resolveType']); + + // TODO: this makes a really good case for + // https://github.com/silverstripe/silverstripe-graphql/issues/364 + $validPlugins = []; + foreach ($modelType->getPlugins() as $name => $config) { + $plugin = $modelType->getPluginRegistry()->getPluginByID($name); + if ($plugin && $plugin instanceof TypePlugin) { + $validPlugins[$name] = $config; + } + } + $interface->setPlugins($validPlugins); + + + // Start by adding all the fields in the model + foreach ($modelType->getFields() as $fieldObj) { + // Assign by reference, because anything that happens to the field + // should be updated in both places to avoid breaking the contract + $interface->addField($fieldObj->getName(), $fieldObj); + } + + $this->getSchema()->addInterface($interface); + + foreach ($interfaceStack as $ancestorInterface) { + $modelType->addInterface($ancestorInterface->getName()); + } + + $interfaceStack[] = $interface; + $modelType->addInterface($interface->getName()); + + $chain = InheritanceChain::create($modelType->getModel()->getSourceClass()); + foreach ($chain->getDirectDescendants() as $class) { + if ($childType = $this->getSchema()->getModelByClassName($class)) { + $this->createInterfaces($childType, $interfaceStack); + } + } + + return $this; + } + + /** + * @return $this + * @throws SchemaBuilderException + */ + public function applyBaseInterface(): InterfaceBuilder + { + $commonFields = $this->getSchema()->getConfig() + ->getModelConfiguration('DataObject') + ->getBaseFields(); + + if (empty($commonFields)) { + return $this; + } + $baseInterface = InterfaceType::create(self::BASE_INTERFACE_NAME); + foreach ($commonFields as $fieldName => $fieldType) { + $baseInterface->addField( + FieldAccessor::singleton()->formatField($fieldName), + $fieldType + ); + } + $baseInterface->setDescription('The common interface shared by all DataObject types'); + $baseInterface->setTypeResolver([AbstractTypeResolver::class, 'resolveType']); + $this->getSchema()->addInterface($baseInterface); + + $dataObjects = $this->getSchema()->getModelTypesFromClass(DataObject::class); + foreach ($dataObjects as $modelType) { + $modelType->addInterface($baseInterface->getName()); + } + + return $this; + } + + /** + * @param ModelType $type + * @throws SchemaBuilderException + * @return $this + */ + public function applyInterfacesToQueries(ModelType $type): InterfaceBuilder + { + $schema = $this->getSchema(); + $queryCollector = QueryCollector::create($schema); + /* @var ModelQuery $query */ + foreach ($queryCollector->collectQueriesForType($type) as $query) { + $typeName = $query->getNamedType(); + $modelType = $this->getSchema()->getModel($typeName); + // Type was customised. Ignore. + if (!$modelType) { + continue; + } + if (!$modelType->getModel() instanceof DataObjectModel) { + continue; + } + + $interfaceName = static::interfaceName($modelType->getName(), $schema->getConfig()); + if ($interface = $schema->getInterface($interfaceName)) { + $query->setNamedType($interfaceName); + // Because the canonical type no longer appears in a query, we need to eagerly load + // it into the schema so it is discoverable. Helps with intellisense + $this->schema->eagerLoad($modelType->getName()); + } + } + + return $this; + } + + /** + * @return Schema + */ + public function getSchema(): Schema + { + return $this->schema; + } + + /** + * @param Schema $schema + * @return InterfaceBuilder + */ + public function setSchema(Schema $schema): InterfaceBuilder + { + $this->schema = $schema; + return $this; + } + + + /** + * @param string $modelName + * @param SchemaConfig $schemaConfig + * @return string + * @throws SchemaBuilderException + */ + public static function interfaceName(string $modelName, SchemaConfig $schemaConfig): string + { + $callable = $schemaConfig->get( + 'interfaceBuilder.name_formatter', + [static:: class, 'defaultInterfaceFormatter'] + ); + return $callable($modelName); + } + + /** + * @param string $modelName + * @return string + */ + public static function defaultInterfaceFormatter(string $modelName): string + { + return $modelName . 'Interface'; + } +} diff --git a/src/Schema/DataObject/Plugin/CanViewPermission.php b/src/Schema/DataObject/Plugin/CanViewPermission.php index 45bfd52c5..5e5256e80 100644 --- a/src/Schema/DataObject/Plugin/CanViewPermission.php +++ b/src/Schema/DataObject/Plugin/CanViewPermission.php @@ -7,9 +7,12 @@ use SilverStripe\Core\ClassInfo; use SilverStripe\GraphQL\QueryHandler\QueryHandler; use SilverStripe\GraphQL\QueryHandler\UserContextProvider; +use SilverStripe\ORM\ArrayLib; +use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\Filterable; use InvalidArgumentException; use SilverStripe\ORM\SS_List; +use SilverStripe\View\ArrayData; /** * A permission checking plugin for DataLists @@ -46,15 +49,19 @@ public static function permissionCheck($obj, array $args, array $context, Resolv } if (is_array($obj)) { - return static::paginatedPermissionCheck($obj, $args, $context, $info); + if (isset($obj['nodes'])) { + return static::paginatedPermissionCheck($obj, $args, $context, $info); + } + // This is just arbitrary array data (either a list or a single record). + // Either way, we have no way of checking canView() and should assume it's viewable. + return $obj; } - if ($obj instanceof Filterable) { - return static::listPermissionCheck($obj, $args, $context, $info); - } if (is_object($obj)) { - return static::itemPermissionCheck($obj, $args, $context, $info); + return $obj instanceof Filterable + ? static::listPermissionCheck($obj, $args, $context, $info) + : static::itemPermissionCheck($obj, $args, $context, $info); } throw new InvalidArgumentException(sprintf( @@ -79,21 +86,8 @@ public static function permissionCheck($obj, array $args, array $context, Resolv */ public static function paginatedPermissionCheck(array $obj, array $args, array $context, ResolveInfo $info): array { - if (!isset($obj['nodes'])) { - throw new InvalidArgumentException(sprintf( - 'Permission checker "%s" cannot be applied to field "%s" because it resolves to an array - that does not appear to be a paginated list. Make sure this plugin is listed after the pagination plugin - using the "after: %s" syntax in your config. If you are trying to check permissions on a simple array - of data, you will need to implement a custom permission checker that extends %s', - self::IDENTIFIER, - $info->fieldName, - Paginator::IDENTIFIER, - AbstractCanViewPermission::class - )); - } - $list = $obj['nodes']; - $originalCount = $list->count(); + $originalCount = count($list); $filteredList = static::permissionCheck($list, $args, $context, $info); $newCount = $filteredList->count(); if ($originalCount === $newCount) { diff --git a/src/Schema/DataObject/Plugin/Inheritance.php b/src/Schema/DataObject/Plugin/Inheritance.php index 1eaa72e30..cca0fc61c 100644 --- a/src/Schema/DataObject/Plugin/Inheritance.php +++ b/src/Schema/DataObject/Plugin/Inheritance.php @@ -3,31 +3,26 @@ namespace SilverStripe\GraphQL\Schema\DataObject\Plugin; -use SilverStripe\Core\Convert; use SilverStripe\GraphQL\Schema\DataObject\DataObjectModel; -use SilverStripe\GraphQL\Schema\DataObject\InheritanceChain; +use SilverStripe\GraphQL\Schema\DataObject\InheritanceBuilder; +use SilverStripe\GraphQL\Schema\DataObject\InheritanceUnionBuilder; +use SilverStripe\GraphQL\Schema\DataObject\InterfaceBuilder; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; -use SilverStripe\GraphQL\Schema\Field\ModelField; -use SilverStripe\GraphQL\Schema\Field\ModelQuery; +use SilverStripe\GraphQL\Schema\Interfaces\ModelTypePlugin; use SilverStripe\GraphQL\Schema\Interfaces\PluginInterface; use SilverStripe\GraphQL\Schema\Interfaces\SchemaUpdater; use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\Type\ModelType; -use SilverStripe\GraphQL\Schema\Type\Type; use SilverStripe\ORM\DataObject; use ReflectionException; /** * Adds inheritance fields to a DataObject type, and exposes its ancestry */ -class Inheritance implements PluginInterface, SchemaUpdater +class Inheritance implements PluginInterface, SchemaUpdater, ModelTypePlugin { - const IDENTIFIER = 'inheritance'; - /** - * @var array - */ - private static $touchedNodes = []; + const IDENTIFIER = 'inheritance'; /** * @return string @@ -39,169 +34,46 @@ public function getIdentifier(): string /** * @param Schema $schema - * @throws ReflectionException * @throws SchemaBuilderException */ public static function updateSchema(Schema $schema): void { - $baseModels = []; - foreach ($schema->getModels() as $modelType) { - $class = $modelType->getModel()->getSourceClass(); - if (!is_subclass_of($class, DataObject::class)) { - continue; - } - if (self::isBaseModel($class, $schema)) { - $baseModels[] = $class; - } - } - - foreach ($baseModels as $baseClass) { - if (self::isTouched($schema, $baseClass)) { - continue; - } - self::addInheritance($schema, $baseClass); - self::touchNode($schema, $baseClass); - } + InterfaceBuilder::create($schema) + ->applyBaseInterface(); } /** + * @param ModelType $type * @param Schema $schema - * @param string $class - * @param ModelType|null $parentModel + * @param array $config * @throws ReflectionException * @throws SchemaBuilderException */ - private static function addInheritance(Schema $schema, string $class, ?ModelType $parentModel = null) + public function apply(ModelType $type, Schema $schema, array $config = []): void { - $inheritance = InheritanceChain::create($class); - $modelType = $schema->getModelByClassName($class); - if (!$modelType) { - $modelType = $schema->findOrMakeModel($class); - } - // Merge with the parent model for inherited fields - if ($parentModel) { - $modelType->mergeWith($parentModel); - //$modelType->removeField(InheritanceChain::getName()); - } - - if (!$inheritance->hasDescendants()) { + if (!$type->getModel() instanceof DataObjectModel) { return; } - - // Add the new _extend field to the base class only - if (!$parentModel) { - $result = $inheritance->getExtensionType($schema->getConfig()); - if ($result) { - /* @var Type $extendsType */ - list($extendsType, $subtypes) = $result; - $extendFields = []; - foreach ($subtypes as $modelName => $subtype) { - $existingType = $schema->getModel($modelName); - - // If the type has not been explicitly added, skip over it, because there's nothing - // to show in _extend (other than id, which we already have) - if (!$existingType) { - continue; - } - - /* @var DataObjectModel $model */ - $model = $subtype->getModel(); - - // If the type is exposed, but has no native fields, skip over it. Nothing to show. - $nativeFields = array_map('strtolower', $model->getUninheritedFields()); - if (empty($nativeFields)) { - continue; - } - - /* @var ModelField $fieldObj */ - foreach ($existingType->getFields() as $fieldObj) { - // Add the field if it's explicitly added and native - $isNative = in_array(strtolower($fieldObj->getName()), $nativeFields); - // If it's a custom property, e.g. Comments.Count(), throw it in, too - $isCustom = $fieldObj->getProperty() !== null; - - if ($isNative || $isCustom) { - $subtype->addField($fieldObj->getName(), $fieldObj); - } - } - - // Remove default fields, like "id" - foreach ($model->getDefaultFields() as $fieldName => $propName) { - $subtype->removeField($fieldName); - } - - if (!empty($subtype->getFields())) { - $extendFieldName = Convert::upperCamelToLowerCamel($modelName); - $extendFields[$extendFieldName] = $subtype->getName(); - $schema->addModel($subtype); - } - } - if (!empty($extendFields)) { - $extendsType->setFields($extendFields); - $schema->addType($extendsType); - $modelType->addField(InheritanceChain::getName(), [ - 'type' => $extendsType->getName(), - 'resolver' => [InheritanceChain::class, 'resolveExtensionType'] - ]); - } - } - } - foreach ($inheritance->getDirectDescendants() as $descendantClass) { - self::addInheritance($schema, $descendantClass, $modelType); - } - } - - /** - * A "base model" is one that either has no ancestors or is one that has no ancestors - * that are queryable. - * - * @param string $class - * @param Schema $schema - * @return bool - */ - private static function isBaseModel(string $class, Schema $schema): bool - { - $chain = InheritanceChain::create($class, $schema); - if ($chain->getBaseClass() === $class) { - return true; + $useUnions = $config['useUnionQueries'] ?? false; + $hideAncestors = $config['hideAncestors'] ?? []; + + $inheritance = InheritanceBuilder::create($schema, $hideAncestors); + $interfaces = InterfaceBuilder::create($schema); + + $class = $type->getModel()->getSourceClass(); + if ($inheritance->isLeafModel($class)) { + $inheritance->fillAncestry($type); + } elseif ($inheritance->isBaseModel($class)) { + $inheritance->fillDescendants($type); + $interfaces->createInterfaces($type); } - // Check if any ancestors are queryable. - $ancestors = $chain->getAncestralModels(); - $hasReadableAncestor = false; - foreach ($ancestors as $ancestor) { - $existing = $schema->getModelByClassName($ancestor); - if ($existing) { - foreach ($existing->getOperations() as $operation) { - if ($operation instanceof ModelQuery) { - $hasReadableAncestor = true; - break 2; - } - } - } + if ($useUnions) { + InheritanceUnionBuilder::create($schema) + ->createUnions($type) + ->applyUnionsToQueries($type); + } else { + $interfaces->applyInterfacesToQueries($type); } - - return !$hasReadableAncestor; - } - - /** - * @param Schema $schema - * @param string $baseClass - */ - private static function touchNode(Schema $schema, string $baseClass): void - { - $key = md5($schema->getSchemaKey() . $baseClass); - self::$touchedNodes[$key] = true; - } - - /** - * @param Schema $schema - * @param string $baseClass - * @return bool - */ - private static function isTouched(Schema $schema, string $baseClass): bool - { - $key = md5($schema->getSchemaKey() . $baseClass); - return isset(self::$touchedNodes[$key]); } } diff --git a/src/Schema/DataObject/Plugin/InheritedPlugins.php b/src/Schema/DataObject/Plugin/InheritedPlugins.php index a692a112f..2d926228a 100644 --- a/src/Schema/DataObject/Plugin/InheritedPlugins.php +++ b/src/Schema/DataObject/Plugin/InheritedPlugins.php @@ -36,6 +36,7 @@ public function getIdentifier(): string */ public function apply(ModelType $type, Schema $schema, array $config = []): void { + return; $sourceClass = $type->getModel()->getSourceClass(); Schema::invariant( is_subclass_of($sourceClass, DataObject::class), diff --git a/src/Schema/DataObject/Plugin/Paginator.php b/src/Schema/DataObject/Plugin/Paginator.php index 65577979f..96987344a 100644 --- a/src/Schema/DataObject/Plugin/Paginator.php +++ b/src/Schema/DataObject/Plugin/Paginator.php @@ -3,6 +3,7 @@ namespace SilverStripe\GraphQL\Schema\DataObject\Plugin; use GraphQL\Type\Definition\ResolveInfo; +use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\ORM\Limitable; use SilverStripe\GraphQL\Schema\Plugin\PaginationPlugin; use Closure; @@ -36,13 +37,19 @@ public static function paginate(array $context): Closure if ($list === null) { return null; } + if (!$list instanceof Limitable) { - return static::createPaginationResult($list, $list, $maxLimit, 0); + Schema::invariant( + !isset($list['nodes']), + 'List on field %s has already been paginated. Was the plugin executed twice?', + $info->fieldName + ); + return static::createPaginationResult(count($list), $list, $maxLimit, 0); } + $total = $list->count(); $offset = $args['offset']; $limit = $args['limit']; - $total = $list->count(); $limit = min($limit, $maxLimit); diff --git a/src/Schema/DataObject/Plugin/QueryCollector.php b/src/Schema/DataObject/Plugin/QueryCollector.php new file mode 100644 index 000000000..53b2250e0 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryCollector.php @@ -0,0 +1,82 @@ +schema = $schema; + } + + /** + * @return Generator + * @throws SchemaBuilderException + */ + public function collectQueries(): array + { + $cached = $this->schema->getState()->get([static::class, 'queries']); + if ($cached) { + return $cached; + } + $queries = []; + foreach ($this->schema->getQueryType()->getFields() as $field) { + if ($field instanceof ModelQuery) { + $queries[] = $field; + } + } + foreach (array_merge($this->schema->getModels(), $this->schema->getTypes()) as $type) { + foreach ($type->getFields() as $field) { + if ($field instanceof ModelField && $field->getModelType()) { + $queries[] = $field; + } + } + } + foreach ($this->schema->getInterfaces() as $interface) { + if (!$interface instanceof ModelInterfaceType) { + continue; + } + foreach ($interface->getFields() as $field) { + if ($field instanceof ModelField && $field->getModelType()) { + $queries[] = $field; + } + } + } + $this->schema->getState()->set([static::class, 'queries'], $queries); + + return $queries; + } + + /** + * @param ModelType $type + * @return Generator + * @throws SchemaBuilderException + */ + public function collectQueriesForType(ModelType $type): Generator + { + /* @var Field $query */ + foreach ($this->collectQueries() as $query) { + if ($query->getNamedType() === $type->getName()) { + yield $query; + } + } + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php index b757766ef..a87115452 100644 --- a/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php +++ b/src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php @@ -7,7 +7,7 @@ use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Type\Type; use SilverStripe\Core\Injector\Injector; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\Schema\Field\Field; use SilverStripe\GraphQL\Schema\Field\ModelField; use SilverStripe\GraphQL\Schema\Field\ModelQuery; @@ -90,15 +90,18 @@ public static function filter(array $context) if ($list === null) { return null; } - $schemaContext = SchemaContextProvider::get($context); + $schemaContext = SchemaConfigProvider::get($context); if (!$schemaContext) { throw new Exception(sprintf( 'No schemaContext was present in the resolver context. Make sure the %s class is added to the query handler', - SchemaContextProvider::class + SchemaConfigProvider::class )); } $filterArgs = $args[$fieldName] ?? []; + if (empty($filterArgs)) { + return $list; + } /* @var FilterRegistryInterface $registry */ $registry = Injector::inst()->get(FilterRegistryInterface::class); $paths = NestedInputBuilder::buildPathsFromArgs($filterArgs); diff --git a/src/Schema/DataObject/Plugin/QuerySort.php b/src/Schema/DataObject/Plugin/QuerySort.php index 4ac78d76e..e5c89c677 100644 --- a/src/Schema/DataObject/Plugin/QuerySort.php +++ b/src/Schema/DataObject/Plugin/QuerySort.php @@ -5,7 +5,7 @@ use SilverStripe\GraphQL\Schema\DataObject\FieldAccessor; use SilverStripe\GraphQL\Schema\Type\Type; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Field\Field; use SilverStripe\GraphQL\Schema\Field\ModelField; @@ -108,12 +108,12 @@ public static function sort(array $context): closure } $filterArgs = $args[$fieldName] ?? []; $paths = NestedInputBuilder::buildPathsFromArgs($filterArgs); - $schemaContext = SchemaContextProvider::get($context); + $schemaContext = SchemaConfigProvider::get($context); if (!$schemaContext) { throw new Exception(sprintf( 'No schemaContext was present in the resolver context. Make sure the %s class is added to the query handler', - SchemaContextProvider::class + SchemaConfigProvider::class )); } diff --git a/src/Schema/DataObject/Resolver.php b/src/Schema/DataObject/Resolver.php index 0f69843a6..dc2253da6 100644 --- a/src/Schema/DataObject/Resolver.php +++ b/src/Schema/DataObject/Resolver.php @@ -4,13 +4,11 @@ namespace SilverStripe\GraphQL\Schema\DataObject; use GraphQL\Type\Definition\ResolveInfo; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; -use SilverStripe\GraphQL\Schema\SchemaConfig; use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\FieldType\DBField; -use Closure; use SilverStripe\ORM\SS_List; /** @@ -26,12 +24,21 @@ class Resolver * @return array|bool|int|mixed|DataList|DataObject|DBField|SS_List|string|null * @throws SchemaBuilderException */ - public static function resolve($obj, array $args = [], array $context = [], ?ResolveInfo $info = null) + public static function resolve($obj, $args = [], $context = [], ?ResolveInfo $info = null) { $fieldName = $info->fieldName; - $context = SchemaContextProvider::get($context); - $fieldName = $context->mapFieldByClassName(get_class($obj), $fieldName); - $result = $fieldName ? FieldAccessor::singleton()->accessField($obj, $fieldName[1]) : null; + $context = SchemaConfigProvider::get($context); + $class = get_class($obj); + $resolvedField = null; + while (!$resolvedField && $class !== DataObject::class) { + $resolvedField = $context->mapFieldByClassName($class, $fieldName); + $class = get_parent_class($class); + } + + if (!$resolvedField) { + return null; + } + $result = FieldAccessor::singleton()->accessField($obj, $resolvedField[1]); if ($result instanceof DBField) { return $result->getValue(); } @@ -60,4 +67,24 @@ public static function baseResolve($obj, $args = [], $context = [], ?ResolveInfo return $result; } + /** + * Just the basic ViewableData field accessor bit, without all the property mapping + * overhead. Useful for custom dataobject types that circumvent the model layer. + * + * @param DataObject $obj + * @param array $args + * @param array $context + * @param ResolveInfo|null $info + * @return array|bool|int|mixed|DataList|DataObject|DBField|SS_List|string|null + */ + public static function baseResolve($obj, $args = [], $context = [], ?ResolveInfo $info = null) + { + $fieldName = $info->fieldName; + $result = FieldAccessor::singleton()->accessField($obj, $fieldName); + if ($result instanceof DBField) { + return $result->getValue(); + } + + return $result; + } } diff --git a/src/Schema/DataObject/UpdateCreator.php b/src/Schema/DataObject/UpdateCreator.php index ba894c05f..d9f7a5537 100644 --- a/src/Schema/DataObject/UpdateCreator.php +++ b/src/Schema/DataObject/UpdateCreator.php @@ -8,7 +8,7 @@ use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\QueryHandler\QueryHandler; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\QueryHandler\UserContextProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Field\ModelMutation; @@ -80,12 +80,12 @@ public static function resolve(array $resolverContext = []): Closure if (!$dataClass) { return null; } - $schema = SchemaContextProvider::get($context); + $schema = SchemaConfigProvider::get($context); Schema::invariant( $schema, 'Could not access schema in resolver for %s. Did you not add the %s context provider?', __CLASS__, - SchemaContextProvider::class + SchemaConfigProvider::class ); $fieldName = FieldAccessor::formatField('ID'); $input = $args['input']; @@ -143,7 +143,7 @@ public function provideInputTypes(ModelType $modelType, array $config = []): arr if (!$fieldObj) { continue; } - $type = $fieldObj->getType(); + $type = $fieldObj->getNamedType(); // No nested input types... yet if ($type && Schema::isInternalType($type)) { $fieldMap[$fieldName] = $type; diff --git a/src/Schema/Exception/ResolverFailure.php b/src/Schema/Exception/ResolverFailure.php index 095126958..5c7d49c58 100644 --- a/src/Schema/Exception/ResolverFailure.php +++ b/src/Schema/Exception/ResolverFailure.php @@ -21,19 +21,19 @@ public function __construct( $args = $resolverArgs[1] ?? null; $info = $resolverArgs[3] ?? null; $message = sprintf( - 'Failed to resolve field %s on %s.\n\n - Path: %s)\n\n + 'Failed to resolve field %s returning %s.\n\n + Got error: %s\n\n + Path: %s\n\n Resolver %s failed in execution chain:\n\n %s\n\n - Args: %s\n\n - Got error: %s', + Args: %s\n\n', $this->fieldName($info), $this->returnType($info), + $error, $this->path($info), $this->resolver($callable), $this->executionChain($info), - $this->args($args), - $error + $this->args($args) ); parent::__construct($message); } diff --git a/src/Schema/Field/Field.php b/src/Schema/Field/Field.php index 71ac9a40a..6b31144e9 100644 --- a/src/Schema/Field/Field.php +++ b/src/Schema/Field/Field.php @@ -379,9 +379,23 @@ public function getNamedType(): string return $this->getTypeRef()->getNamedType(); } + /** + * [MyType!]! becomes [MyNewType!]! + * @param string $name + * @return $this + * @throws SchemaBuilderException + */ + public function setNamedType(string $name): self + { + $currentType = $this->getType(); + $newType = preg_replace('/[A-Za-z_0-9]+/', $name, $currentType); + return $this->setType($newType); + } + /** * @param string|null $typeName * @return EncodedResolver + * @throws SchemaBuilderException */ public function getEncodedResolver(?string $typeName = null): EncodedResolver { diff --git a/src/Schema/Field/ModelField.php b/src/Schema/Field/ModelField.php index e2e010dd4..deeaf0654 100644 --- a/src/Schema/Field/ModelField.php +++ b/src/Schema/Field/ModelField.php @@ -94,6 +94,10 @@ public function applyConfig(array $config) */ public function getModelType(): ?ModelType { + $type = $this->getNamedType(); + if (Schema::isInternalType($type)) { + return null; + } $model = $this->getModel()->getModelTypeForField($this->getName()); if ($model) { $config = []; diff --git a/src/Schema/Interfaces/BaseFieldsProvider.php b/src/Schema/Interfaces/BaseFieldsProvider.php new file mode 100644 index 000000000..89807f4b1 --- /dev/null +++ b/src/Schema/Interfaces/BaseFieldsProvider.php @@ -0,0 +1,16 @@ +addArg($this->getFieldName(), $builder->getRootType()->getName()); + $canonicalType = $schema->getCanonicalType($query->getNamedType()); + $rootType = $canonicalType ? $canonicalType->getName() : $query->getNamedType(); $query->addResolverAfterware( $this->getResolver($config), [ 'fieldName' => $this->getFieldName(), - 'rootType' => $query->getNamedType(), + 'rootType' => $rootType, ] ); } diff --git a/src/Schema/Plugin/AbstractQuerySortPlugin.php b/src/Schema/Plugin/AbstractQuerySortPlugin.php index 4d13a9f25..109494b0c 100644 --- a/src/Schema/Plugin/AbstractQuerySortPlugin.php +++ b/src/Schema/Plugin/AbstractQuerySortPlugin.php @@ -47,11 +47,13 @@ public function apply(ModelQuery $query, Schema $schema, array $config = []): vo return; } $query->addArg($this->getFieldName(), $builder->getRootType()->getName()); + $canonicalType = $schema->getCanonicalType($query->getNamedType()); + $rootType = $canonicalType ? $canonicalType->getName() : $query->getNamedType(); $query->addResolverAfterware( $this->getResolver($config), [ 'fieldName' => $this->getFieldName(), - 'rootType' => $query->getNamedType(), + 'rootType' => $rootType, ] ); } diff --git a/src/Schema/Plugin/PluginConsumer.php b/src/Schema/Plugin/PluginConsumer.php index f4a27a415..ea3db1fe5 100644 --- a/src/Schema/Plugin/PluginConsumer.php +++ b/src/Schema/Plugin/PluginConsumer.php @@ -195,7 +195,7 @@ public function loadPlugins(): Generator * @throws CircularDependencyException * @throws ElementNotFoundException */ - protected function getSortedPlugins(): array + public function getSortedPlugins(): array { $dependencies = []; $beforeAll = []; diff --git a/src/Schema/Plugin/SortPlugin.php b/src/Schema/Plugin/SortPlugin.php index 5768388e1..b8d8a6184 100644 --- a/src/Schema/Plugin/SortPlugin.php +++ b/src/Schema/Plugin/SortPlugin.php @@ -5,7 +5,7 @@ use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\Schema\DataObject\FieldAccessor; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Field\Field; diff --git a/src/Schema/Resolver/JSONResolver.php b/src/Schema/Resolver/JSONResolver.php new file mode 100644 index 000000000..67c9865b9 --- /dev/null +++ b/src/Schema/Resolver/JSONResolver.php @@ -0,0 +1,34 @@ +value; + } +} diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index 8b6d929ae..63ea5fd83 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -24,7 +24,9 @@ use SilverStripe\GraphQL\Schema\Type\Enum; use SilverStripe\GraphQL\Schema\Type\InputType; use SilverStripe\GraphQL\Schema\Type\InterfaceType; +use SilverStripe\GraphQL\Schema\Type\ModelInterfaceType; use SilverStripe\GraphQL\Schema\Type\ModelType; +use SilverStripe\GraphQL\Schema\Type\ModelUnionType; use SilverStripe\GraphQL\Schema\Type\Scalar; use SilverStripe\GraphQL\Schema\Type\Type; use SilverStripe\GraphQL\Schema\Type\TypeReference; @@ -412,7 +414,8 @@ private function applySchemaUpdatesFromSet(array $componentSet): void } /* @var SchemaUpdater $builder */ - foreach ($schemaUpdates as $class) { + foreach ($schemaUpdates as $spec) { + list ($class) = $spec; $class::updateSchema($this); } } @@ -437,7 +440,7 @@ private function applyComponentUpdatesFromSet(array $componentSet) private function getTypeComponents(): array { return [ - 'types' => $this->types, + 'types' => array_merge($this->types, $this->interfaces), 'models' => $this->models, 'queries' => $this->queryType->getFields(), 'mutations' => $this->mutationType->getFields(), @@ -458,13 +461,13 @@ private function getFieldComponents(): array $pluggedFields = array_filter($type->getFields(), function (Field $field) use ($type) { return !empty($field->getPlugins()); }); - $allTypeFields = array_merge($allTypeFields, $pluggedFields); + $allTypeFields = array_merge($allTypeFields, array_values($pluggedFields)); } foreach ($this->models as $model) { - $pluggedFields = array_filter($model->getFields(), function (ModelField $field) { + $pluggedFields = array_filter(array_values($model->getFields()), function (ModelField $field) { return !empty($field->getPlugins()); }); - $allModelFields = array_merge($allModelFields, $pluggedFields); + $allModelFields = array_merge($allModelFields, array_values($pluggedFields)); } return [ @@ -522,9 +525,9 @@ private function collectSchemaUpdaters(array $components): array $schemaUpdates = []; foreach ($components as $component) { foreach ($component->loadPlugins() as $data) { - list ($plugin) = $data; + list ($plugin, $config) = $data; if ($plugin instanceof SchemaUpdater) { - $schemaUpdates[get_class($plugin)] = get_class($plugin); + $schemaUpdates[get_class($plugin)] = [get_class($plugin), $config]; } } } @@ -661,6 +664,17 @@ public function addType(Type $type, ?callable $callback = null): Schema return $this; } + /** + * @param string $type + * @return $this + */ + public function removeType(string $type): Schema + { + unset($this->types[$type]); + + return $this; + } + /** * @param string $name * @return Type|null @@ -686,6 +700,45 @@ public function findOrMakeType(string $name): Type return $this->getType($name); } + /** + * Given a type name, try to resolve it to any model-implementing component + * + * @param string $typeName + * @return Type|null + */ + public function getCanonicalType(string $typeName): ?Type + { + $type = $this->getTypeOrModel($typeName); + if ($type) { + return $type; + } + + $union = $this->getUnion($typeName); + if ($union instanceof ModelUnionType) { + return $union->getCanonicalModel(); + } + + $interface = $this->getInterface($typeName); + if ($interface instanceof ModelInterfaceType) { + return $interface->getCanonicalModel(); + } + + return null; + } + + /** + * Gets all the models that were generated from a given ancestor, e.g. DataObject + * @param string $class + * @return ModelType[] + */ + public function getModelTypesFromClass(string $class): array + { + return array_filter($this->getModels(), function (ModelType $modelType) use ($class) { + $source = $modelType->getModel()->getSourceClass(); + return $source === $class || is_subclass_of($source, $class); + }); + } + /** * @return Type[] */ @@ -731,6 +784,17 @@ public function addEnum(Enum $enum): self return $this; } + /** + * @param string $name + * @return $this + */ + public function removeEnum(string $name): self + { + unset($this->enums[$name]); + + return $this; + } + /** * @return Enum[] */ @@ -776,6 +840,17 @@ public function addScalar(Scalar $scalar): self return $this; } + /** + * @param string $name + * @return $this + */ + public function removeScalar(string $name): self + { + unset($this->scalars[$name]); + + return $this; + } + /** * @param ModelType $modelType * @param callable|null $callback @@ -835,6 +910,30 @@ public function addModelbyClassName(string $class, ?callable $callback = null): return $this->addModel($model); } + /** + * @param string $class + * @return $this + */ + public function removeModelByClassName(string $class): self + { + if ($model = $this->getModelByClassName($class)) { + $this->removeModel($model->getName()); + } + + return $this; + } + + /** + * @param string $name + * @return $this + */ + public function removeModel(string $name): self + { + unset($this->models[$name]); + + return $this; + } + /** * @param string $class * @return ModelType|null @@ -850,6 +949,32 @@ public function getModelByClassName(string $class): ?ModelType return null; } + /** + * Some types must be eagerly loaded into the schema if they cannot be discovered through introspection. + * This may include types that do not appear in any queries. + * @param string $name + * @return $this + * @throws SchemaBuilderException + */ + public function eagerLoad(string $name): self + { + $this->getConfig()->set("eagerLoadTypes.$name", $name); + + return $this; + } + + /** + * @param string $name + * @return $this + * @throws SchemaBuilderException + */ + public function lazyLoad(string $name): self + { + $this->getConfig()->unset("eagerLoadTypes.$name"); + + return $this; + } + /** * @param string $class * @param array $config @@ -909,6 +1034,17 @@ public function addInterface(InterfaceType $type, ?callable $callback = null): s return $this; } + /** + * @param string $name + * @return $this + */ + public function removeInterface(string $name): self + { + unset($this->interfaces[$name]); + + return $this; + } + /** * @param string $name * @return InterfaceType|null @@ -926,6 +1062,14 @@ public function getInterfaces(): array return $this->interfaces; } + public function getImplementorsOf(string $interfaceName): array + { + $search = array_merge($this->getTypes(), $this->getModels()); + return array_filter($search, function (Type $type) use ($interfaceName) { + return $type->implements($interfaceName); + }); + } + /** * @param UnionType $union * @param callable|null $callback @@ -942,6 +1086,17 @@ public function addUnion(UnionType $union, ?callable $callback = null): self return $this; } + /** + * @param string $name + * @return $this + */ + public function removeUnion(string $name): self + { + unset($this->unions[$name]); + + return $this; + } + /** * @param string $name * @return UnionType|null diff --git a/src/Schema/SchemaBuilder.php b/src/Schema/SchemaBuilder.php index f115294e9..cd7ab961d 100644 --- a/src/Schema/SchemaBuilder.php +++ b/src/Schema/SchemaBuilder.php @@ -91,7 +91,7 @@ public function build(Schema $schema, $clear = false): GraphQLSchema $store->persistSchema($schema->createStoreableSchema()); Dispatcher::singleton()->trigger( - 'graphqlSchemaBuild.' . $schema->getSchemaKey(), + 'graphqlSchemaBuild', Event::create($schema->getSchemaKey(), [ 'schema' => $schema ]) @@ -108,6 +108,7 @@ public function build(Schema $schema, $clear = false): GraphQLSchema * @return GraphQLSchema * @throws SchemaBuilderException * @throws SchemaNotFoundException + * @throws EmptySchemaException */ public function buildByName(string $key, $clear = false): GraphQLSchema { @@ -135,7 +136,6 @@ public function buildByName(string $key, $clear = false): GraphQLSchema public function boot(string $key): Schema { $schemaObj = Schema::create($key); - $schemas = $schemaObj->config()->get('schemas') ?: []; if (!array_key_exists($key, $schemas)) { diff --git a/src/Schema/SchemaConfig.php b/src/Schema/SchemaConfig.php index fa8441412..ae9225b37 100644 --- a/src/Schema/SchemaConfig.php +++ b/src/Schema/SchemaConfig.php @@ -149,6 +149,16 @@ public function setFieldMapping(array $fields): self return $this->set('fieldMapping', $fields); } + /** + * @param string $class + * @return bool + * @throws SchemaBuilderException + */ + public function hasModel(string $class): bool + { + return (bool) $this->get(['typeMapping', $class]); + } + /** * @param string $class diff --git a/src/Schema/Services/NestedInputBuilder.php b/src/Schema/Services/NestedInputBuilder.php index 12c4e314d..b902fd2be 100644 --- a/src/Schema/Services/NestedInputBuilder.php +++ b/src/Schema/Services/NestedInputBuilder.php @@ -9,7 +9,9 @@ use SilverStripe\GraphQL\Schema\Field\Field; use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\GraphQL\Schema\Type\InputType; +use SilverStripe\GraphQL\Schema\Type\ModelInterfaceType; use SilverStripe\GraphQL\Schema\Type\ModelType; +use SilverStripe\GraphQL\Schema\Type\ModelUnionType; use SilverStripe\GraphQL\Schema\Type\Type; use SilverStripe\GraphQL\Schema\Type\TypeReference; use SilverStripe\ORM\ArrayLib; @@ -93,7 +95,7 @@ public function __construct(Field $root, Schema $schema, $fields = Schema::ALL) public function populateSchema() { $typeName = TypeReference::create($this->root->getType())->getNamedType(); - $type = $this->schema->getTypeOrModel($typeName); + $type = $this->schema->getCanonicalType($typeName); Schema::invariant( $type, 'Could not find type for query that uses %s. Were plugins applied before the schema was done loading?', @@ -128,7 +130,7 @@ protected function buildAllFieldsConfig(Type $type): array continue; } $namedType = $fieldObj->getNamedType(); - $nestedType = $this->schema->getTypeOrModel($namedType); + $nestedType = $this->schema->getCanonicalType($namedType); if ($nestedType) { $seen = $this->schema->getState()->get([ static::class, @@ -207,21 +209,22 @@ protected function addInputTypesToSchema( } $fieldType = $fieldObj->getNamedType(); - $nestedType = $this->schema->getTypeOrModel($fieldType); + $nestedType = $this->schema->getCanonicalType($fieldType); if ($data === self::SELF_REFERENTIAL) { $inputType->addField($fieldName, $inputType->getName()); - } elseif (!is_array($data)) { + } elseif (!is_array($data) && !$nestedType && Schema::isInternalType($fieldType)) { // Regular field, e.g. scalar - if (!$nestedType && Schema::isInternalType($fieldType)) { - $inputType->addField( - $fieldName, - $this->getLeafNodeType($fieldType) - ); - } + $inputType->addField( + $fieldName, + $this->getLeafNodeType($fieldType) + ); } + // Make sure the input type got at least one field if ($inputType->exists()) { + // Optimistically add the type to the schema $this->schema->addType($inputType); + // If we're in recursion, apply the nested input type to the parent if ($parentType && $parentField) { $parentType->addField($parentField, $inputType->getName()); } @@ -342,6 +345,8 @@ private function getLeafNodeType(string $fieldName): string return $fieldName; } + + /** * @param string $key * @param $value diff --git a/src/Schema/Storage/CodeGenerationStore.php b/src/Schema/Storage/CodeGenerationStore.php index 3f2152798..717d5e724 100644 --- a/src/Schema/Storage/CodeGenerationStore.php +++ b/src/Schema/Storage/CodeGenerationStore.php @@ -4,7 +4,7 @@ use Exception; use GraphQL\Type\Schema as GraphQLSchema; -use GraphQL\Type\SchemaConfig as GraphqLSchemaConfig; +use GraphQL\Type\SchemaConfig as GraphQLSchemaConfig; use Psr\SimpleCache\CacheInterface; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Injector\Injectable; @@ -80,6 +80,11 @@ class CodeGenerationStore implements SchemaStorageInterface */ private $cachedConfig; + /** + * @var GraphQLSchema|null + */ + private $graphqlSchema; + /** * @param string $name * @param CacheInterface $cache @@ -267,17 +272,20 @@ public function persistSchema(StorableSchema $schema): void /** * @return GraphQLSchema + * @var bool $useCache * @throws SchemaNotFoundException */ - public function getSchema(): GraphQLSchema + public function getSchema($useCache = true): GraphQLSchema { - if (!file_exists($this->getSchemaFilename())) { + if (!$this->exists()) { throw new SchemaNotFoundException(sprintf( 'Schema "%s" has not been built', $this->name )); } - + if ($useCache && $this->graphqlSchema) { + return $this->graphqlSchema; + } require_once($this->getSchemaFilename()); $registryClass = $this->getClassName(self::TYPE_CLASS_NAME); @@ -290,7 +298,22 @@ public function getSchema(): GraphQLSchema $callback = call_user_func([$registryClass, Schema::MUTATION_TYPE]); $schemaConfig->setMutation($callback); } - return new GraphQLSchema($schemaConfig); + // Add eager loaded types + $typeNames = array_filter( + $this->getConfig()->get('eagerLoadTypes', []), + function (string $name) use ($registryClass) { + return method_exists($registryClass, $name); + } + ); + $typeObjs = array_map(function (string $typeName) use ($registryClass) { + return call_user_func([$registryClass, $typeName]); + }, $typeNames); + + $schemaConfig->setTypes($typeObjs); + + $this->graphqlSchema = new GraphQLSchema($schemaConfig); + + return $this->graphqlSchema; } /** @@ -322,12 +345,7 @@ public function clear(): void */ public function exists(): bool { - try { - $this->getSchema(); - return true; - } catch (SchemaNotFoundException $e) { - return false; - } + return file_exists($this->getSchemaFilename()); } /** diff --git a/src/Schema/Storage/templates/interface.inc.php b/src/Schema/Storage/templates/interface.inc.php index d23468d90..4f412ec95 100644 --- a/src/Schema/Storage/templates/interface.inc.php +++ b/src/Schema/Storage/templates/interface.inc.php @@ -22,6 +22,13 @@ public function __construct() }, getDescription())) : ?> 'description' => 'getDescription()); ?>', + + getInterfaces())) : ?> + 'interfaces' => function () { + return array_map(function ($interface) { + return call_user_func([__NAMESPACE__ . '\\', $interface]); + }, getEncodedInterfaces(); ?>); + }, 'fields' => function () { return [ diff --git a/src/Schema/Type/CanonicalModelAware.php b/src/Schema/Type/CanonicalModelAware.php new file mode 100644 index 000000000..522d6ad97 --- /dev/null +++ b/src/Schema/Type/CanonicalModelAware.php @@ -0,0 +1,31 @@ +canonicalModel; + } + + /** + * @param ModelType $modelType + * @return $this + */ + public function setCanonicalModel(ModelType $modelType): self + { + $this->canonicalModel = $modelType; + + return $this; + } +} diff --git a/src/Schema/Type/ModelInterfaceType.php b/src/Schema/Type/ModelInterfaceType.php new file mode 100644 index 000000000..b6b97fe89 --- /dev/null +++ b/src/Schema/Type/ModelInterfaceType.php @@ -0,0 +1,29 @@ +setCanonicalModel($modelType); + parent::__construct($name, $config); + } +} diff --git a/src/Schema/Type/ModelType.php b/src/Schema/Type/ModelType.php index 6b0302115..b83793943 100644 --- a/src/Schema/Type/ModelType.php +++ b/src/Schema/Type/ModelType.php @@ -7,6 +7,7 @@ use SilverStripe\GraphQL\Schema\Field\Field; use SilverStripe\GraphQL\Schema\Field\ModelAware; use SilverStripe\GraphQL\Schema\Field\ModelField; +use SilverStripe\GraphQL\Schema\Interfaces\BaseFieldsProvider; use SilverStripe\GraphQL\Schema\Interfaces\DefaultFieldsProvider; use SilverStripe\GraphQL\Schema\Interfaces\ExtraTypeProvider; use SilverStripe\GraphQL\Schema\Interfaces\InputTypeProvider; @@ -55,7 +56,6 @@ public function __construct(SchemaModelInterface $model, array $config = []) { $this->setModel($model); $type = $this->getModel()->getTypeName(); - Schema::invariant( $type, 'Could not determine type for model %s', @@ -84,7 +84,7 @@ public function applyConfig(array $config) if ($fieldConfig === Schema::ALL) { $this->addAllFields(); } else { - $fields = array_merge($this->getBaseFields(), $fieldConfig); + $fields = array_merge($this->getInitialFields(), $fieldConfig); Schema::assertValidConfig($fields); foreach ($fields as $fieldName => $data) { @@ -142,6 +142,13 @@ public function addField(string $fieldName, $fieldConfig = true, ?callable $call } } else { $fieldObj = ModelField::create($fieldName, $fieldConfig, $this->getModel()); + Schema::invariant( + $fieldObj->getType(), + 'Field %s on type %s could not infer a type. Check to see if the field exists on the model + or provide an explicit type if necessary.', + $fieldObj->getName(), + $this->getName() + ); } } Schema::invariant( @@ -191,15 +198,15 @@ public function addFields(array $fields): self */ public function addAllFields(): self { - /* @var SchemaModelInterface&DefaultFieldsProvider $model */ - $model = $this->getModel(); - $defaultFields = $model instanceof DefaultFieldsProvider ? $model->getDefaultFields() : []; - foreach ($defaultFields as $fieldName => $fieldType) { + $initialFields = $this->getInitialFields(); + foreach ($initialFields as $fieldName => $fieldType) { $this->addField($fieldName, $fieldType); } $allFields = $this->getModel()->getAllFields(); foreach ($allFields as $fieldName) { - $this->addField($fieldName, $this->getModel()->getField($fieldName)); + if (!$this->getFieldByName($fieldName)) { + $this->addField($fieldName, $this->getModel()->getField($fieldName)); + } } return $this; } @@ -421,14 +428,35 @@ public function getExtraTypes(): array return $extraTypes; } + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + if ($this->getModel() instanceof BaseFieldsProvider) { + foreach ($this->getModel()->getBaseFields() as $fieldName => $data) { + Schema::invariant( + $this->getFieldByName($fieldName), + 'Required base field %s was not on type %s', + $fieldName, + $this->getName() + ); + } + } + parent::validate(); + } + /** * @return array */ - private function getBaseFields(): array + private function getInitialFields(): array { $model = $this->getModel(); /* @var SchemaModelInterface&DefaultFieldsProvider $model */ - return $model instanceof DefaultFieldsProvider ? $model->getDefaultFields() : []; + $default = $model instanceof DefaultFieldsProvider ? $model->getDefaultFields() : []; + $base = $model instanceof BaseFieldsProvider ? $model->getBaseFields() : []; + + return array_merge($default, $base); } /** diff --git a/src/Schema/Type/ModelUnionType.php b/src/Schema/Type/ModelUnionType.php new file mode 100644 index 000000000..6d11a3c61 --- /dev/null +++ b/src/Schema/Type/ModelUnionType.php @@ -0,0 +1,27 @@ +setCanonicalModel($canonicalModel); + parent::__construct($name, $config); + } +} diff --git a/src/Schema/Type/Type.php b/src/Schema/Type/Type.php index 74962e16e..3dfd7b547 100644 --- a/src/Schema/Type/Type.php +++ b/src/Schema/Type/Type.php @@ -222,7 +222,14 @@ public function mergeWith(Type $type): self $this->mergePlugins($type->getPlugins()); - $this->setInterfaces(array_merge($this->interfaces, $type->getInterfaces())); + $this->setInterfaces( + array_unique( + array_merge( + $this->interfaces, + $type->getInterfaces() + ) + ) + ); return $this; } @@ -299,6 +306,15 @@ public function addInterface(string $name): self return $this; } + /** + * @param string $interfaceName + * @return bool + */ + public function implements(string $interfaceName): bool + { + return in_array($interfaceName, $this->interfaces); + } + /** * @return bool */ diff --git a/src/Schema/Type/UnionType.php b/src/Schema/Type/UnionType.php index cbe260e0e..51d1df6bf 100644 --- a/src/Schema/Type/UnionType.php +++ b/src/Schema/Type/UnionType.php @@ -175,6 +175,26 @@ public function setDescription(?string $description): UnionType return $this; } + /** + * @param UnionType $existing + * @throws SchemaBuilderException + */ + public function mergeWith(UnionType $existing) + { + $this->setName($existing->getName()); + $this->setTypes(array_unique( + array_merge( + $this->getTypes(), + $existing->getTypes() + ) + )); + if ($existing->getTypeResolver()) { + $this->setTypeResolver($existing->getTypeResolver()); + } + + return $this; + } + /** * @throws SchemaBuilderException */ diff --git a/tests/Fake/Inheritance/A.php b/tests/Fake/Inheritance/A.php new file mode 100644 index 000000000..2d452ad70 --- /dev/null +++ b/tests/Fake/Inheritance/A.php @@ -0,0 +1,22 @@ + 'Varchar', + ]; + + private static $table_name = 'A_test'; + + public function getAllTheB(): DataList + { + return B::get(); + } +} diff --git a/tests/Fake/Inheritance/A1.php b/tests/Fake/Inheritance/A1.php new file mode 100644 index 000000000..56915e5a5 --- /dev/null +++ b/tests/Fake/Inheritance/A1.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'A1_test'; +} diff --git a/tests/Fake/Inheritance/A1a.php b/tests/Fake/Inheritance/A1a.php new file mode 100644 index 000000000..6348ad8e5 --- /dev/null +++ b/tests/Fake/Inheritance/A1a.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'A1a_test'; +} diff --git a/tests/Fake/Inheritance/A1b.php b/tests/Fake/Inheritance/A1b.php new file mode 100644 index 000000000..e6b5743ef --- /dev/null +++ b/tests/Fake/Inheritance/A1b.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'A1b_test'; +} diff --git a/tests/Fake/Inheritance/A2.php b/tests/Fake/Inheritance/A2.php new file mode 100644 index 000000000..a174d0c40 --- /dev/null +++ b/tests/Fake/Inheritance/A2.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'A2_test'; +} diff --git a/tests/Fake/Inheritance/A2a.php b/tests/Fake/Inheritance/A2a.php new file mode 100644 index 000000000..c4154dffe --- /dev/null +++ b/tests/Fake/Inheritance/A2a.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'A2a_test'; +} diff --git a/tests/Fake/Inheritance/B.php b/tests/Fake/Inheritance/B.php new file mode 100644 index 000000000..c93eb1491 --- /dev/null +++ b/tests/Fake/Inheritance/B.php @@ -0,0 +1,16 @@ + 'Varchar', + ]; + + private static $table_name = 'B_test'; +} diff --git a/tests/Fake/Inheritance/B1.php b/tests/Fake/Inheritance/B1.php new file mode 100644 index 000000000..87942be9a --- /dev/null +++ b/tests/Fake/Inheritance/B1.php @@ -0,0 +1,12 @@ + 'Varchar', + ]; + + private static $table_name = 'B1a_test'; +} diff --git a/tests/Fake/Inheritance/B1b.php b/tests/Fake/Inheritance/B1b.php new file mode 100644 index 000000000..df2e32e8e --- /dev/null +++ b/tests/Fake/Inheritance/B1b.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'B1b_test'; +} diff --git a/tests/Fake/Inheritance/B2.php b/tests/Fake/Inheritance/B2.php new file mode 100644 index 000000000..961ccd9af --- /dev/null +++ b/tests/Fake/Inheritance/B2.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'B2_test'; +} diff --git a/tests/Fake/Inheritance/C.php b/tests/Fake/Inheritance/C.php new file mode 100644 index 000000000..5d15634a6 --- /dev/null +++ b/tests/Fake/Inheritance/C.php @@ -0,0 +1,16 @@ + 'Varchar', + ]; + + private static $table_name = 'C_test'; +} diff --git a/tests/Fake/Inheritance/C1.php b/tests/Fake/Inheritance/C1.php new file mode 100644 index 000000000..f2b58b71d --- /dev/null +++ b/tests/Fake/Inheritance/C1.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'C1_test'; +} diff --git a/tests/Fake/Inheritance/C2.php b/tests/Fake/Inheritance/C2.php new file mode 100644 index 000000000..bd0613a22 --- /dev/null +++ b/tests/Fake/Inheritance/C2.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'C2_test'; +} diff --git a/tests/Fake/Inheritance/C2a.php b/tests/Fake/Inheritance/C2a.php new file mode 100644 index 000000000..5f6db95bd --- /dev/null +++ b/tests/Fake/Inheritance/C2a.php @@ -0,0 +1,13 @@ + 'Varchar', + ]; + + private static $table_name = 'C2a_test'; +} diff --git a/tests/Schema/DataObject/FakeInheritanceBuilder.php b/tests/Schema/DataObject/FakeInheritanceBuilder.php new file mode 100644 index 000000000..2acff4949 --- /dev/null +++ b/tests/Schema/DataObject/FakeInheritanceBuilder.php @@ -0,0 +1,24 @@ +getName()] = true; + } + + public function fillDescendants(ModelType $modelType): void + { + static::$descendantCalls[$modelType->getName()] = true; + } +} diff --git a/tests/Schema/DataObject/FakeInheritanceUnionBuilder.php b/tests/Schema/DataObject/FakeInheritanceUnionBuilder.php new file mode 100644 index 000000000..3b69fd8e4 --- /dev/null +++ b/tests/Schema/DataObject/FakeInheritanceUnionBuilder.php @@ -0,0 +1,32 @@ +getName()] = true; + return $this; + } + + public function applyUnionsToQueries(ModelType $type): InheritanceUnionBuilder + { + static::$applyCalls[$type->getName()] = true; + return $this; + } +} diff --git a/tests/Schema/DataObject/FakeInterfaceBuilder.php b/tests/Schema/DataObject/FakeInterfaceBuilder.php new file mode 100644 index 000000000..dc663d5ba --- /dev/null +++ b/tests/Schema/DataObject/FakeInterfaceBuilder.php @@ -0,0 +1,40 @@ +getName()] = true; + return $this; + } + + public function applyBaseInterface(): InterfaceBuilder + { + static::$baseCalled = true; + return $this; + } + + public function applyInterfacesToQueries(ModelType $type): InterfaceBuilder + { + self::$applyCalls[$type->getName()] = true; + return $this; + } +} diff --git a/tests/Schema/DataObject/InheritanceBuilderTest.php b/tests/Schema/DataObject/InheritanceBuilderTest.php new file mode 100644 index 000000000..6e1dadeaf --- /dev/null +++ b/tests/Schema/DataObject/InheritanceBuilderTest.php @@ -0,0 +1,317 @@ +applyConfig([ + 'models' => [ + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + + $builder = new InheritanceBuilder($schema); + $this->assertTrue($builder->isBaseModel(A1a::class)); + $this->assertFalse($builder->isBaseModel(A1::class)); + $this->assertFalse($builder->isBaseModel(A::class)); + + $schema->applyConfig([ + 'models' => [ + A1::class => [ + 'fields' => [ + 'A1Field' => true, + ], + ], + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertTrue($builder->isBaseModel(A1::class)); + $this->assertFalse($builder->isBaseModel(A1a::class)); + $this->assertFalse($builder->isBaseModel(A::class)); + + $schema->applyConfig([ + 'models' => [ + A::class => [ + 'fields' => [ + 'AField' => true, + ], + ], + A1::class => [ + 'fields' => [ + 'A1Field' => true, + ], + ], + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertTrue($builder->isBaseModel(A::class)); + $this->assertFalse($builder->isBaseModel(A1::class)); + $this->assertFalse($builder->isBaseModel(Aa::class)); + } + + public function testLeafModel() + { + $schema = new TestSchema(); + + $schema->applyConfig([ + 'models' => [ + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + + $builder = new InheritanceBuilder($schema); + $this->assertTrue($builder->isLeafModel(A1a::class)); + $this->assertFalse($builder->isLeafModel(A1::class)); + $this->assertFalse($builder->isLeafModel(A::class)); + + $schema->applyConfig([ + 'models' => [ + A1::class => [ + 'fields' => [ + 'A1Field' => true, + ], + ], + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertTrue($builder->isLeafModel(A1a::class)); + $this->assertFalse($builder->isLeafModel(A1::class)); + $this->assertFalse($builder->isLeafModel(A::class)); + + $schema->applyConfig([ + 'models' => [ + A::class => [ + 'fields' => [ + 'AField' => true, + ], + ], + A1::class => [ + 'fields' => [ + 'A1Field' => true, + ], + ], + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertTrue($builder->isLeafModel(A1a::class)); + $this->assertFalse($builder->isLeafModel(A1::class)); + $this->assertFalse($builder->isLeafModel(A::class)); + } + + public function testFillAncestry() + { + $schema = new TestSchema(); + + $schema->applyConfig([ + 'models' => [ + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + ], + 'operations' => ['read' => true], + ], + ], + ]); + $schema->createStoreableSchema(); + + $builder = new InheritanceBuilder($schema); + $builder->fillAncestry($schema->getModelByClassName(A1a::class)); + + $a1a = $schema->getModelByClassName(A1a::class); + $this->assertNotNull($a1a); + $this->assertFields(['A1aField', 'AField', 'id'], $a1a); + + $a1 = $schema->getModelByClassName(A1::class); + $this->assertNotNull($a1); + $this->assertFields(['AField', 'id'], $a1); + + $a = $schema->getModelByClassName(A::class); + $this->assertNotNull($a); + $this->assertFields(['AField', 'id'], $a); + } + + public function testFillDescendants() + { + $schema = new TestSchema(); + $schema->applyConfig([ + 'models' => [ + A::class => [ + 'fields' => [ + 'AField' => true, + ], + ], + A1::class => [ + 'fields' => [ + 'A1Field' => true, + ], + ], + B::class => [ + 'fields' => [ + 'BField' => true, + ], + ], + B1a::class => [ + 'fields' => [ + 'B1aField' => true, + ], + ], + C::class => [ + 'fields' => [ + 'CField' => true, + ], + ], + C2a::class => [ + 'fields' => [ + 'C2aField' => true, + 'C2Field' => true, + ], + ], + + ], + ]); + $schema->createStoreableSchema(); + + $builder = new InheritanceBuilder($schema); + $builder->fillDescendants($schema->getModelByClassName(A::class)); + + $a = $schema->getModelByClassName(A::class); + $this->assertNotNull($a); + $this->assertFields(['AField', 'id'], $a); + $a1 = $schema->getModelByClassName(A1::class); + $this->assertNotNull($a1); + $this->assertFields(['A1Field', 'AField', 'id'], $a1); + + // This descendant wasn't explicitly exposed. + $a1a = $schema->getModelByClassName(A1a::class); + $this->assertNull($a1a); + + + // B + $builder->fillDescendants($schema->getModelByClassName(B::class)); + + $b = $schema->getModelByClassName(B::class); + $this->assertNotNull($b); + $this->assertFields(['BField', 'id'], $b); + + // This descendant wasn't explicitly exposed. + $b1 = $schema->getModelByClassName(B1::class); + $this->assertNull($b1); + + // But this one was. In practice, B1 would be exposed by fillAncestry() + $b1a = $schema->getModelByClassName(B1a::class); + $this->assertNotNull($b1a); + // Only has its native fields. fillAncestry() would take care of the others in practice. + $this->assertFields(['B1aField', 'id'], $b1a); + + // C + $builder->fillDescendants($schema->getModelByClassName(C::class)); + + $c = $schema->getModelByClassName(C::class); + $this->assertNotNull($c); + $this->assertFields(['CField', 'id'], $c); + + // Not explicitly exposed + $this->assertNull($schema->getModelByClassName(C1::class)); + + // Not explicitly exposed, but would be in practice by fillAncestry(), due to C2a + $c2 = $schema->getModelByClassName(C2::class); + $this->assertNull($c2); + + $c2a = $schema->getModelByClassName(C2a::class); + $this->assertNotNull($c2a); + // Only has what was explicitly added. fillAncestry() would take care of the others in practice. + $this->assertFields(['C2aField', 'C2Field', 'id'], $c2a); + } + + /** + * @param array $fields + * @param Type $type + */ + private function assertFields(array $fields, Type $type) + { + $expected = array_map('strtolower', $fields); + $compare = array_map('strtolower', array_keys($type->getFields())); + + $this->assertEmpty(array_diff($expected, $compare)); + $this->assertEmpty(array_diff($compare, $expected)); + } +} diff --git a/tests/Schema/DataObject/InheritanceUnionBuilderTest.php b/tests/Schema/DataObject/InheritanceUnionBuilderTest.php new file mode 100644 index 000000000..79365f1ed --- /dev/null +++ b/tests/Schema/DataObject/InheritanceUnionBuilderTest.php @@ -0,0 +1,173 @@ +addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + }); + } + $schema->createStoreableSchema(); + $builder = new InheritanceUnionBuilder($schema); + foreach (static::$extra_dataobjects as $class) { + $builder->createUnions($schema->getModelByClassName($class)); + } + + $union = $schema->getUnion('AInheritanceUnion'); + $this->assertNotNull($union); + $this->assertTypes(['A', 'A1', 'A2', 'A2a', 'A1a', 'A1b'], $union); + + $union = $schema->getUnion('A1InheritanceUnion'); + $this->assertNotNull($union); + $this->assertTypes(['A1', 'A1a', 'A1b'], $union); + + $union = $schema->getUnion('A2InheritanceUnion'); + $this->assertNotNull($union); + $this->assertTypes(['A2', 'A2a'], $union); + + $union = $schema->getUnion('A1aInheritanceUnion'); + $this->assertNull($union); + + $union = $schema->getUnion('A2aInheritanceUnion'); + $this->assertNull($union); + + $schema = new TestSchema(); + foreach (static::$extra_dataobjects as $class) { + $schema->addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + }); + } + + // Try removing something from the chain + $schema->removeModelByClassName(A1::class); + $schema->createStoreableSchema(); + $builder = new InheritanceUnionBuilder($schema); + foreach (static::$extra_dataobjects as $class) { + $type = $schema->getModelByClassName($class); + if ($type) { + $builder->createUnions($type); + } + } + ; + $union = $schema->getUnion('AInheritanceUnion'); + $this->assertNotNull($union); + $this->assertTypes(['A', 'A2', 'A2a', 'A1a', 'A1b'], $union); + + $union = $schema->getUnion('A1InheritanceUnion'); + $this->assertNull($union); + + $union = $schema->getUnion('A1aInheritanceUnion'); + $this->assertNull($union); + + // Sanity check + $union = $schema->getUnion('A2aInheritanceUnion'); + $this->assertNull($union); + } + + public function testApplyUnions() + { + $schema = new TestSchema(); + foreach (static::$extra_dataobjects as $class) { + $schema->addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + $model->addOperation('read'); + }); + } + $schema->getModelByClassName(A::class)->addField('allTheB', '[B]'); + $a = $schema->getModel('A'); + $interface = new ModelInterfaceType($a, 'AInterface'); + $modelQuery = clone $a->getFieldByName('allTheB'); + $interface->addField('allTheB', $modelQuery); + $schema->addInterface($interface); + $a->addInterface('AInterface'); + + $schema->createStoreableSchema(); + $builder = new InheritanceUnionBuilder($schema); + + foreach (static::$extra_dataobjects as $class) { + $type = $schema->getModelByClassName($class); + if (!$type) { + continue; + } + $builder->createUnions($type); + $builder->applyUnionsToQueries($type); + } + + + $query = $schema->getQueryType()->getFieldByName('readAs'); + $this->assertNotNull($query); + $this->assertEquals('AInheritanceUnion', $query->getNamedType()); + + $query = $schema->getQueryType()->getFieldByName('readA1s'); + $this->assertNotNull($query); + $this->assertEquals('A1InheritanceUnion', $query->getNamedType()); + + $type = $schema->getModelByClassName(A::class); + $nestedQuery = $type->getFieldByName('allTheB'); + $this->assertNotNull($nestedQuery); + $this->assertEquals('BInheritanceUnion', $nestedQuery->getNamedType()); + + + $interface = $schema->getInterface('AInterface'); + $this->assertEquals('BInheritanceUnion', $interface->getFieldByName('allTheB')->getNamedType()); + } + + public function testUnionName() + { + $schema = new TestSchema(); + $this->assertEquals('FooInheritanceUnion', InheritanceUnionBuilder::unionName('Foo', $schema->getConfig())); + $schema->applyConfig([ + 'config' => [ + 'inheritanceUnionBuilder' => [ + 'name_formatter' => 'strrev', + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertEquals('ooF', InheritanceUnionBuilder::unionName('Foo', $schema->getConfig())); + } + + /** + * @param array $types + * @param UnionType $union + */ + private function assertTypes(array $types, UnionType $union) + { + $expected = array_map('strtolower', $types); + $compare = array_map('strtolower', $union->getTypes()); + + $this->assertEmpty(array_diff($expected, $compare)); + $this->assertEmpty(array_diff($compare, $expected)); + } +} diff --git a/tests/Schema/DataObject/InterfaceBuilderTest.php b/tests/Schema/DataObject/InterfaceBuilderTest.php new file mode 100644 index 000000000..de6c897d3 --- /dev/null +++ b/tests/Schema/DataObject/InterfaceBuilderTest.php @@ -0,0 +1,181 @@ +addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + }); + } + $schema->createStoreableSchema(); + $builder = new InterfaceBuilder($schema); + $builder->createInterfaces($schema->getModelByClassName(A::class)); + $interface = $schema->getInterface('AInterface'); + $this->assertNotNull($interface); + $this->assertFields(['LastEdited', 'ClassName', 'Created', 'AField', 'ID'], $interface); + + $interface = $schema->getInterface('A1Interface'); + $this->assertNotNull($interface); + $this->assertFields(['LastEdited', 'ClassName', 'Created', 'AField', 'A1Field', 'ID'], $interface); + + $interface = $schema->getInterface('A1aInterface'); + $this->assertNotNull($interface); + $this->assertFields(['LastEdited', 'ClassName', 'Created', 'A1Field', 'AField', 'A1aField', 'ID'], $interface); + + $schema = new TestSchema(); + $schema->applyConfig([ + 'models' => [ + A::class => [ + 'fields' => ['AField' => true], + ], + A1::class => [ + 'fields' => ['AField' => true], + ], + A1a::class => [ + 'fields' => [ + 'A1aField' => true, + 'AField' => true, + 'A1Field' => true, + ], + ], + + ] + ]); + $schema->createStoreableSchema(); + + $builder = new InterfaceBuilder($schema); + $builder->createInterfaces($schema->getModelByClassName(A::class)); + $interface = $schema->getInterface('AInterface'); + $this->assertNotNull($interface); + $this->assertFields(['AField', 'ID'], $interface); + + // A1 never exposed any of its own fields, so it's just a copy of A + $interface = $schema->getInterface('A1Interface'); + $this->assertNotNull($interface); + $this->assertFields(['AField', 'ID'], $interface); + + $interface = $schema->getInterface('A1aInterface'); + $this->assertNotNull($interface); + $this->assertFields(['AField', 'A1aField', 'A1Field', 'ID'], $interface); + + $model = $schema->getModelByClassName(A::class); + $this->assertNotNull($model); + + $this->assertInterfaces(['AInterface'], $model); + + $model = $schema->getModelByClassName(A1::class); + $this->assertNotNull($model); + + $this->assertInterfaces(['A1Interface', 'AInterface'], $model); + + $model = $schema->getModelByClassName(A1a::class); + $this->assertNotNull($model); + + $this->assertInterfaces(['A1Interface', 'AInterface', 'A1aInterface'], $model); + } + + public function testBaseInterface() + { + $schema = new TestSchema(); + foreach (static::$extra_dataobjects as $class) { + $schema->addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + }); + } + $schema->createStoreableSchema(); + $builder = new InterfaceBuilder($schema); + $builder->applyBaseInterface(); + $interface = $schema->getInterface(InterfaceBuilder::BASE_INTERFACE_NAME); + $this->assertNotNull($interface); + $this->assertFields(['ID'], $interface); + + $schema = new TestSchema(); + foreach (static::$extra_dataobjects as $class) { + $schema->addModelbyClassName($class, function (ModelType $model) { + $model->addAllFields(); + }); + } + $schema->applyConfig([ + 'config' => [ + 'modelConfig' => [ + 'DataObject' => [ + 'base_fields' => [ + 'id' => 'ID', + 'lastEdited' => 'String', + 'className' => 'String', + ] + ] + ] + ] + ]); + $schema->createStoreableSchema(); + $builder = new InterfaceBuilder($schema); + $builder->applyBaseInterface(); + $interface = $schema->getInterface(InterfaceBuilder::BASE_INTERFACE_NAME); + $this->assertNotNull($interface); + $this->assertFields(['ID', 'LastEdited', 'ClassName'], $interface); + } + + public function testInterfaceName() + { + $schema = new TestSchema(); + $this->assertEquals('FooInterface', InterfaceBuilder::interfaceName('Foo', $schema->getConfig())); + $schema->applyConfig([ + 'config' => [ + 'interfaceBuilder' => [ + 'name_formatter' => 'strrev', + ], + ], + ]); + $schema->createStoreableSchema(); + $this->assertEquals('ooF', InterfaceBuilder::interfaceName('Foo', $schema->getConfig())); + } + + /** + * @param array $fields + * @param Type $type + */ + private function assertFields(array $fields, Type $type) + { + $expected = array_map('strtolower', $fields); + $compare = array_map('strtolower', array_keys($type->getFields())); + + $this->assertEmpty(array_diff($expected, $compare)); + $this->assertEmpty(array_diff($compare, $expected)); + } + + /** + * @param array $fields + * @param Type $type + */ + private function assertInterfaces(array $fields, Type $type) + { + $expected = array_map('strtolower', $fields); + $compare = array_map('strtolower', $type->getInterfaces()); + + $this->assertEmpty(array_diff($expected, $compare)); + $this->assertEmpty(array_diff($compare, $expected)); + } +} diff --git a/tests/Schema/DataObject/Plugin/InheritanceTest.php b/tests/Schema/DataObject/Plugin/InheritanceTest.php new file mode 100644 index 000000000..81f901591 --- /dev/null +++ b/tests/Schema/DataObject/Plugin/InheritanceTest.php @@ -0,0 +1,163 @@ +addModelbyClassName($class, function (ModelType $type) { + $type->addAllFields(); + }); + } + $schema->createStoreableSchema(); + + $allModels = array_map(function ($class) use ($schema) { + return $schema->getConfig()->getTypeNameForClass($class); + }, static::$extra_dataobjects); + + + FakeInheritanceUnionBuilder::reset(); + FakeInterfaceBuilder::reset(); + + Injector::inst()->load([ + InheritanceBuilder::class => [ + 'class' => FakeInheritanceBuilder::class, + ], + InterfaceBuilder::class => [ + 'class' => FakeInterfaceBuilder::class, + ], + InheritanceUnionBuilder::class => [ + 'class' => FakeInheritanceUnionBuilder::class, + ], + ]); + $schema->getConfig()->set('useUnionQueries', $unions); + + Inheritance::updateSchema($schema); + $this->assertTrue(FakeInterfaceBuilder::$baseCalled); + + $inheritance = new Inheritance(); + foreach (static::$extra_dataobjects as $class) { + $inheritance->apply($schema->getModelByClassName($class), $schema); + } + + + if ($unions) { + $this->assertCalls( + $allModels, + FakeInheritanceUnionBuilder::$applyCalls + ); + $this->assertCalls( + $allModels, + FakeInheritanceUnionBuilder::$createCalls + ); + $this->assertEmpty(FakeInterfaceBuilder::$applyCalls); + } else { + $this->assertEmpty(FakeInheritanceUnionBuilder::$createCalls); + $this->assertEmpty(FakeInheritanceUnionBuilder::$applyCalls); + $this->assertCalls( + $allModels, + FakeInterfaceBuilder::$applyCalls + ); + $this->assertCalls( + ['A', 'B', 'C'], + FakeInterfaceBuilder::$createCalls + ); + } + + $this->assertCalls( + ['A1a', 'A1b', 'A2a', 'B1a', 'B1b', 'B2', 'C1', 'C2a'], + FakeInheritanceBuilder::$ancestryCalls + ); + $this->assertCalls( + ['A', 'B', 'C'], + FakeInheritanceBuilder::$descendantCalls + ); + + $this->assertCalls( + ['A', 'B', 'C'], + FakeInterfaceBuilder::$createCalls + ); + } + + /** + * @param array $expected + * @param array $actual + */ + private function assertCalls(array $expected, array $actual) + { + $expected = array_map('strtolower', $expected); + $compare = array_map('strtolower', array_keys($actual)); + + $this->assertEmpty(array_diff($expected, $compare)); + $this->assertEmpty(array_diff($compare, $expected)); + } + + public function provideUnionOption() + { + return [ + [true], + [false], + ]; + } +} diff --git a/tests/Schema/DataObject/TestSchema.php b/tests/Schema/DataObject/TestSchema.php new file mode 100644 index 000000000..bd32b1551 --- /dev/null +++ b/tests/Schema/DataObject/TestSchema.php @@ -0,0 +1,30 @@ + [ ModelCreator::class ], + 'modelConfig' => [ + 'DataObject' => [ + 'base_fields' => ['ID' => 'ID'], + 'operations' => [ + 'read' => [ + 'class' => ReadCreator::class, + ], + ], + ], + ] + ])); + } +} diff --git a/tests/Schema/IntegrationTest.php b/tests/Schema/IntegrationTest.php index c4e363c92..ec68be1dc 100644 --- a/tests/Schema/IntegrationTest.php +++ b/tests/Schema/IntegrationTest.php @@ -9,7 +9,7 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\SapphireTest; use SilverStripe\GraphQL\QueryHandler\QueryHandler; -use SilverStripe\GraphQL\QueryHandler\SchemaContextProvider; +use SilverStripe\GraphQL\QueryHandler\SchemaConfigProvider; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Exception\SchemaNotFoundException; use SilverStripe\GraphQL\Schema\Field\Query; @@ -174,11 +174,6 @@ public function testModelPlugins() $this->assertSchemaNotHasType($schema, 'FakePageVersion'); } - public function testInheritance() - { - $this->markTestSkipped(); - } - public function testPluginOverride() { $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__])); @@ -198,11 +193,15 @@ public function testPluginOverride() query { readFakePages { nodes { - title + ... on FakePageInterface { + title + } } edges { node { - title + ... on FakePageInterface { + title + } } } } @@ -265,7 +264,7 @@ public function testFieldInclusion() $query = << [ DataObjectFake::class => [ 'fields' => [ - 'id' => false, + 'myInt' => false, 'myField' => true, ], 'operations' => [ @@ -290,7 +289,7 @@ public function testFieldInclusion() $schema = $this->createSchema($factory); $result = $this->querySchema($schema, $query); $this->assertFailure($result); - $this->assertMissingField($result, 'id'); + $this->assertMissingField($result, 'myInt'); $factory = new TestSchemaBuilder(); $factory->extraConfig = [ @@ -1021,7 +1020,7 @@ private function querySchema(Schema $schema, string $query, array $variables = [ $graphQLSchena = $builder->fetchSchema($schema); $handler = new QueryHandler(); $schemaContext = $builder->getConfig($schema->getSchemaKey()); - $handler->addContextProvider(SchemaContextProvider::create($schemaContext)); + $handler->addContextProvider(SchemaConfigProvider::create($schemaContext)); try { return $handler->query($graphQLSchena, $query, $variables); } catch (Exception $e) { diff --git a/tests/Schema/_testFieldInclusion/models.yml b/tests/Schema/_testFieldInclusion/models.yml index e39adbc5a..22d8764ba 100644 --- a/tests/Schema/_testFieldInclusion/models.yml +++ b/tests/Schema/_testFieldInclusion/models.yml @@ -1,5 +1,6 @@ SilverStripe\GraphQL\Tests\Fake\DataObjectFake: fields: myField: true + myInt: true operations: readOne: true diff --git a/tests/Schema/_testModelPlugins/models.yml b/tests/Schema/_testModelPlugins/models.yml index eb5535a8d..cffd50881 100644 --- a/tests/Schema/_testModelPlugins/models.yml +++ b/tests/Schema/_testModelPlugins/models.yml @@ -2,3 +2,5 @@ SilverStripe\GraphQL\Tests\Fake\DataObjectFake: fields: '*' SilverStripe\GraphQL\Tests\Fake\FakeSiteTree: fields: '*' +SilverStripe\GraphQL\Tests\Fake\FakePage: + fields: '*'