From 1c4e62364b8cf4237b077604116262b23d6e0ec7 Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Thu, 8 Oct 2020 11:01:25 +1300 Subject: [PATCH] API GraphQL v4: Schemageddon (#266) Co-authored-by: Ingo Schommer Co-authored-by: Gene Dower Co-authored-by: Maxime Rainville Co-authored-by: Andre Kiste --- README.md | 2606 +---------------- _config/assets.yml | 24 +- _config/config.yml | 69 +- _config/dataobject.yml | 18 + _config/dbtypes.yml | 18 + _config/default-schema.yml | 12 + _config/dev.yml | 19 +- _config/filters.yml | 39 +- _config/middlewares.yml | 30 + _config/model.yml | 16 + _config/plugins.yml | 14 + composer.json | 4 +- examples/README.md | 7 +- examples/_config/config.yml | 59 - examples/code/Comment.php | 34 - examples/code/CommentsResolver.php | 21 - examples/code/CreateMemberMutationCreator.php | 42 - examples/code/GroupTypeCreator.php | 24 - examples/code/LatestPostResolver.php | 14 - examples/code/MemberTypeCreator.php | 45 - .../code/PaginatedReadMembersQueryCreator.php | 42 - examples/code/Post.php | 147 - examples/code/ReadMembersQueryCreator.php | 50 - examples/code/ReadResolver.php | 20 - examples/code/UpdatePostResolver.php | 21 - src/Config/ModelConfiguration.php | 151 + src/Controller.php | 214 +- src/DataObjectInterfaceTypeCreator.php | 51 - src/Dev/Benchmark.php | 55 + src/Dev/Build.php | 89 + src/Dev/DevelopmentAdmin.php | 42 + src/Extensions/DevBuildExtension.php | 35 + src/Extensions/IntrospectionProvider.php | 3 +- src/Extensions/QueryRecorderExtension.php | 66 + src/FieldCreator.php | 155 - src/InterfaceTypeCreator.php | 59 - src/Manager.php | 621 ---- src/Middleware/CSRFMiddleware.php | 58 +- src/Middleware/HTTPMethodMiddleware.php | 20 +- src/Middleware/Middleware.php | 18 + src/Middleware/MiddlewareConsumer.php | 69 + src/Middleware/QueryCachingMiddleware.php | 176 ++ src/Middleware/QueryMiddleware.php | 22 - src/MutationCreator.php | 13 - src/OperationResolver.php | 24 - src/Pagination/Connection.php | 460 --- src/Pagination/PageInfoTypeCreator.php | 64 - src/Pagination/PaginatedQueryCreator.php | 60 - src/Pagination/SortDirectionTypeCreator.php | 52 - src/Pagination/SortInputTypeCreator.php | 114 - src/Permission/CanViewPermissionChecker.php | 46 - src/Permission/MemberAware.php | 38 + src/Permission/MemberContextProvider.php | 24 + src/Permission/PermissionCheckerAware.php | 31 - src/Permission/QueryPermissionChecker.php | 26 - src/PersistedQuery/PersistedQueryProvider.php | 17 + src/QueryCreator.php | 13 - src/QueryFilter/DataObjectQueryFilter.php | 436 --- src/QueryFilter/FieldFilterInterface.php | 33 - src/QueryFilter/FilterRegistryInterface.php | 25 - src/QueryFilter/Filters/ContainsFilter.php | 25 - src/QueryFilter/Filters/EndsWithFilter.php | 25 - src/QueryFilter/Filters/EqualToFilter.php | 25 - src/QueryFilter/Filters/GreaterThanFilter.php | 25 - .../Filters/GreaterThanOrEqualFilter.php | 25 - src/QueryFilter/Filters/InFilter.php | 25 - src/QueryFilter/Filters/LessThanFilter.php | 25 - .../Filters/LessThanOrEqualFilter.php | 25 - src/QueryFilter/Filters/StartsWithFilter.php | 25 - src/QueryFilter/QueryFilterAware.php | 56 - src/QueryHandler/QueryHandler.php | 282 ++ src/QueryHandler/QueryHandlerInterface.php | 26 + .../Extensions/TypeCreatorExtension.php | 111 - src/Scaffolding/Interfaces/CRUDInterface.php | 11 - .../Interfaces/ConfigurationApplier.php | 14 - .../Interfaces/ManagerMutatorInterface.php | 16 - .../Interfaces/ResolverInterface.php | 14 - .../Interfaces/ScaffolderInterface.php | 17 - .../Interfaces/ScaffoldingProvider.php | 16 - .../Interfaces/TypeParserInterface.php | 20 - .../Scaffolders/ArgumentScaffolder.php | 172 -- src/Scaffolding/Scaffolders/CRUD/Create.php | 139 - src/Scaffolding/Scaffolders/CRUD/Delete.php | 100 - src/Scaffolding/Scaffolders/CRUD/Read.php | 172 -- src/Scaffolding/Scaffolders/CRUD/ReadOne.php | 138 - src/Scaffolding/Scaffolders/CRUD/Update.php | 161 - .../Scaffolders/DataObjectScaffolder.php | 735 ----- .../Scaffolders/InheritanceScaffolder.php | 146 - .../Scaffolders/ItemQueryScaffolder.php | 59 - .../Scaffolders/ListQueryScaffolder.php | 260 -- .../Scaffolders/MutationScaffolder.php | 99 - .../Scaffolders/OperationScaffolder.php | 523 ---- .../Scaffolders/PaginationScaffolder.php | 85 - .../Scaffolders/QueryScaffolder.php | 100 - .../Scaffolders/SchemaScaffolder.php | 389 --- .../Scaffolders/UnionScaffolder.php | 118 - src/Scaffolding/StaticSchema.php | 327 --- src/Scaffolding/Traits/Chainable.php | 35 - .../Traits/DataObjectTypeTrait.php | 82 - src/Scaffolding/Util/ArrayTypeParser.php | 78 - src/Scaffolding/Util/OperationList.php | 128 - src/Scaffolding/Util/StringTypeParser.php | 143 - src/Schema/DataObject/CreateCreator.php | 161 + src/Schema/DataObject/DataObjectModel.php | 361 +++ src/Schema/DataObject/DeleteCreator.php | 100 + src/Schema/DataObject/FieldAccessor.php | 258 ++ src/Schema/DataObject/FieldReconciler.php | 53 + src/Schema/DataObject/InheritanceChain.php | 223 ++ src/Schema/DataObject/ModelCreator.php | 36 + .../Plugin/AbstractCanViewPermission.php | 41 + .../DataObject/Plugin/CanViewPermission.php | 153 + src/Schema/DataObject/Plugin/FirstResult.php | 51 + src/Schema/DataObject/Plugin/Inheritance.php | 168 ++ .../DataObject/Plugin/InheritedPlugins.php | 87 + src/Schema/DataObject/Plugin/Paginator.php | 53 + .../QueryFilter/FieldFilterInterface.php | 26 + .../QueryFilter/FieldFilterRegistry.php | 22 +- .../QueryFilter/FilterRegistryInterface.php | 29 + .../QueryFilter/Filters/ContainsFilter.php | 28 + .../QueryFilter/Filters/EndsWithFilter.php | 29 + .../QueryFilter/Filters/EqualToFilter.php | 29 + .../QueryFilter/Filters/GreaterThanFilter.php | 29 + .../Filters/GreaterThanOrEqualFilter.php | 29 + .../Plugin/QueryFilter/Filters/InFilter.php | 29 + .../QueryFilter/Filters/LessThanFilter.php | 29 + .../Filters/LessThanOrEqualFilter.php | 29 + .../QueryFilter/Filters/NotEqualFilter.php | 30 + .../QueryFilter/Filters/StartsWithFilter.php | 29 + .../QueryFilter/ListFieldFilterInterface.php | 2 +- .../Plugin/QueryFilter/QueryFilter.php | 121 + src/Schema/DataObject/Plugin/QuerySort.php | 146 + src/Schema/DataObject/ReadCreator.php | 68 + src/Schema/DataObject/ReadOneCreator.php | 52 + src/Schema/DataObject/Resolver.php | 69 + src/Schema/DataObject/UpdateCreator.php | 166 ++ src/Schema/Exception/MutationException.php | 13 + src/Schema/Exception/PermissionsException.php | 14 + .../Exception/SchemaBuilderException.php | 14 + src/Schema/Field/Argument.php | 203 ++ src/Schema/Field/Field.php | 632 ++++ src/Schema/Field/ModelAware.php | 37 + src/Schema/Field/ModelField.php | 169 ++ src/Schema/Field/ModelMutation.php | 56 + src/Schema/Field/ModelQuery.php | 56 + src/Schema/Field/Mutation.php | 26 + src/Schema/Field/Query.php | 32 + .../Interfaces/ConfigurationApplier.php | 16 + src/Schema/Interfaces/ContextProvider.php | 24 + .../Interfaces/DefaultFieldsProvider.php | 16 + src/Schema/Interfaces/Encoder.php | 12 + src/Schema/Interfaces/ExtraTypeProvider.php | 18 + src/Schema/Interfaces/FieldPlugin.php | 21 + src/Schema/Interfaces/InputTypeProvider.php | 25 + src/Schema/Interfaces/ModelBlacklist.php | 12 + .../Interfaces/ModelConfigurationProvider.php | 20 + src/Schema/Interfaces/ModelFieldPlugin.php | 19 + src/Schema/Interfaces/ModelMutationPlugin.php | 17 + src/Schema/Interfaces/ModelOperation.php | 12 + src/Schema/Interfaces/ModelQueryPlugin.php | 16 + src/Schema/Interfaces/ModelTypePlugin.php | 22 + src/Schema/Interfaces/MutationPlugin.php | 20 + src/Schema/Interfaces/OperationCreator.php | 22 + src/Schema/Interfaces/OperationProvider.php | 24 + src/Schema/Interfaces/PluginInterface.php | 23 + src/Schema/Interfaces/PluginValidator.php | 21 + src/Schema/Interfaces/QueryPlugin.php | 20 + src/Schema/Interfaces/ResolverProvider.php | 25 + src/Schema/Interfaces/SchemaComponent.php | 13 + .../SchemaModelCreatorInterface.php | 23 + .../Interfaces/SchemaModelInterface.php | 61 + .../Interfaces/SchemaStorageCreator.php | 16 + .../Interfaces/SchemaStorageInterface.php | 36 + src/Schema/Interfaces/SchemaUpdater.php | 16 + src/Schema/Interfaces/SchemaValidator.php | 18 + src/Schema/Interfaces/SignatureProvider.php | 14 + src/Schema/Interfaces/TypePlugin.php | 21 + .../Plugin/AbstractNestedInputPlugin.php | 384 +++ .../Plugin/AbstractQueryFilterPlugin.php | 91 + src/Schema/Plugin/AbstractQuerySortPlugin.php | 76 + src/Schema/Plugin/PaginationPlugin.php | 170 ++ src/Schema/Plugin/PluginConsumer.php | 260 ++ src/Schema/Registry/PluginRegistry.php | 55 + src/Schema/Registry/ResolverRegistry.php | 100 + .../Registry/SchemaModelCreatorRegistry.php | 121 + src/Schema/Resolver/ComposedResolver.php | 45 + src/Schema/Resolver/DefaultResolver.php | 49 + .../Resolver/DefaultResolverProvider.php | 78 + src/Schema/Resolver/EncodedResolver.php | 170 ++ src/Schema/Resolver/ResolverReference.php | 77 + .../Resolver/templates/_manifest_exclude | 0 .../Resolver/templates/resolver.inc.php | 22 + src/Schema/Schema.php | 1052 +++++++ src/Schema/Storage/AbstractTypeRegistry.php | 91 + src/Schema/Storage/CodeGenerationStore.php | 434 +++ .../Storage/CodeGenerationStoreCreator.php | 24 + src/Schema/Storage/Encoder.php | 61 + .../Storage/templates/_manifest_exclude | 0 src/Schema/Storage/templates/enum.inc.php | 32 + .../Storage/templates/interface.inc.php | 52 + src/Schema/Storage/templates/registry.inc.php | 26 + src/Schema/Storage/templates/scalar.inc.php | 22 + src/Schema/Storage/templates/type.inc.php | 59 + src/Schema/Storage/templates/union.inc.php | 31 + src/Schema/Type/EncodedType.php | 65 + src/Schema/Type/Enum.php | 151 + src/Schema/Type/InputType.php | 15 + src/Schema/Type/InterfaceType.php | 111 + src/Schema/Type/ModelType.php | 478 +++ src/Schema/Type/Scalar.php | 202 ++ src/Schema/Type/Type.php | 369 +++ src/Schema/Type/TypeReference.php | 120 + src/Schema/Type/UnionType.php | 212 ++ src/TypeCreator.php | 195 -- src/Util/CaseInsensitiveFieldAccessor.php | 167 -- tests/ConnectionTest.php | 263 -- tests/FieldCreatorTest.php | 23 - tests/ManagerTest.php | 245 -- tests/Middleware/DummyResponseMiddleware.php | 8 +- .../Middleware/MiddlewareProcessTestBase.php | 4 +- .../Extensions/TypeCreatorExtensionTest.php | 76 - .../Scaffolders/ArgumentScaffolderTest.php | 70 - .../Scaffolders/CRUD/CreateTest.php | 154 - .../Scaffolders/CRUD/DeleteTest.php | 136 - .../Scaffolders/CRUD/ReadOneTest.php | 82 - .../Scaffolding/Scaffolders/CRUD/ReadTest.php | 82 - .../Scaffolders/CRUD/UpdateTest.php | 154 - .../Scaffolders/DataObjectScaffolderTest.php | 508 ---- .../Scaffolders/InheritanceScaffolderTest.php | 73 - .../Scaffolders/ItemQueryScaffolderTest.php | 94 - .../Scaffolders/ListQueryScaffolderTest.php | 218 -- .../Scaffolders/MutationScaffolderTest.php | 50 - .../Scaffolders/OperationScaffolderTest.php | 249 -- .../Scaffolders/SchemaScaffolderTest.php | 373 --- .../Scaffolders/UnionScaffolderTest.php | 71 - tests/Scaffolding/StaticSchemaTest.php | 217 -- .../Scaffolding/Util/ArrayTypeParserTest.php | 54 - tests/Scaffolding/Util/OperationListTest.php | 35 - .../Scaffolding/Util/StringTypeParserTest.php | 75 - tests/TypeCreatorTest.php | 152 - .../Util/CaseInsensitiveFieldAccessorTest.php | 107 - 240 files changed, 11338 insertions(+), 14285 deletions(-) create mode 100644 _config/dataobject.yml create mode 100644 _config/dbtypes.yml create mode 100644 _config/default-schema.yml create mode 100644 _config/middlewares.yml create mode 100644 _config/model.yml create mode 100644 _config/plugins.yml delete mode 100644 examples/_config/config.yml delete mode 100644 examples/code/Comment.php delete mode 100644 examples/code/CommentsResolver.php delete mode 100644 examples/code/CreateMemberMutationCreator.php delete mode 100644 examples/code/GroupTypeCreator.php delete mode 100644 examples/code/LatestPostResolver.php delete mode 100644 examples/code/MemberTypeCreator.php delete mode 100644 examples/code/PaginatedReadMembersQueryCreator.php delete mode 100644 examples/code/Post.php delete mode 100644 examples/code/ReadMembersQueryCreator.php delete mode 100644 examples/code/ReadResolver.php delete mode 100644 examples/code/UpdatePostResolver.php create mode 100644 src/Config/ModelConfiguration.php delete mode 100644 src/DataObjectInterfaceTypeCreator.php create mode 100644 src/Dev/Benchmark.php create mode 100644 src/Dev/Build.php create mode 100644 src/Dev/DevelopmentAdmin.php create mode 100644 src/Extensions/DevBuildExtension.php create mode 100644 src/Extensions/QueryRecorderExtension.php delete mode 100644 src/FieldCreator.php delete mode 100644 src/InterfaceTypeCreator.php delete mode 100644 src/Manager.php create mode 100644 src/Middleware/Middleware.php create mode 100644 src/Middleware/MiddlewareConsumer.php create mode 100644 src/Middleware/QueryCachingMiddleware.php delete mode 100644 src/Middleware/QueryMiddleware.php delete mode 100644 src/MutationCreator.php delete mode 100644 src/OperationResolver.php delete mode 100644 src/Pagination/Connection.php delete mode 100644 src/Pagination/PageInfoTypeCreator.php delete mode 100644 src/Pagination/PaginatedQueryCreator.php delete mode 100644 src/Pagination/SortDirectionTypeCreator.php delete mode 100644 src/Pagination/SortInputTypeCreator.php delete mode 100644 src/Permission/CanViewPermissionChecker.php create mode 100644 src/Permission/MemberAware.php create mode 100644 src/Permission/MemberContextProvider.php delete mode 100644 src/Permission/PermissionCheckerAware.php delete mode 100644 src/Permission/QueryPermissionChecker.php create mode 100644 src/PersistedQuery/PersistedQueryProvider.php delete mode 100644 src/QueryCreator.php delete mode 100644 src/QueryFilter/DataObjectQueryFilter.php delete mode 100644 src/QueryFilter/FieldFilterInterface.php delete mode 100644 src/QueryFilter/FilterRegistryInterface.php delete mode 100644 src/QueryFilter/Filters/ContainsFilter.php delete mode 100644 src/QueryFilter/Filters/EndsWithFilter.php delete mode 100644 src/QueryFilter/Filters/EqualToFilter.php delete mode 100644 src/QueryFilter/Filters/GreaterThanFilter.php delete mode 100644 src/QueryFilter/Filters/GreaterThanOrEqualFilter.php delete mode 100644 src/QueryFilter/Filters/InFilter.php delete mode 100644 src/QueryFilter/Filters/LessThanFilter.php delete mode 100644 src/QueryFilter/Filters/LessThanOrEqualFilter.php delete mode 100644 src/QueryFilter/Filters/StartsWithFilter.php delete mode 100644 src/QueryFilter/QueryFilterAware.php create mode 100644 src/QueryHandler/QueryHandler.php create mode 100644 src/QueryHandler/QueryHandlerInterface.php delete mode 100644 src/Scaffolding/Extensions/TypeCreatorExtension.php delete mode 100644 src/Scaffolding/Interfaces/CRUDInterface.php delete mode 100644 src/Scaffolding/Interfaces/ConfigurationApplier.php delete mode 100644 src/Scaffolding/Interfaces/ManagerMutatorInterface.php delete mode 100644 src/Scaffolding/Interfaces/ResolverInterface.php delete mode 100644 src/Scaffolding/Interfaces/ScaffolderInterface.php delete mode 100644 src/Scaffolding/Interfaces/ScaffoldingProvider.php delete mode 100644 src/Scaffolding/Interfaces/TypeParserInterface.php delete mode 100644 src/Scaffolding/Scaffolders/ArgumentScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/CRUD/Create.php delete mode 100644 src/Scaffolding/Scaffolders/CRUD/Delete.php delete mode 100644 src/Scaffolding/Scaffolders/CRUD/Read.php delete mode 100644 src/Scaffolding/Scaffolders/CRUD/ReadOne.php delete mode 100644 src/Scaffolding/Scaffolders/CRUD/Update.php delete mode 100644 src/Scaffolding/Scaffolders/DataObjectScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/InheritanceScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/ItemQueryScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/ListQueryScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/MutationScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/OperationScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/PaginationScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/QueryScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/SchemaScaffolder.php delete mode 100644 src/Scaffolding/Scaffolders/UnionScaffolder.php delete mode 100644 src/Scaffolding/StaticSchema.php delete mode 100644 src/Scaffolding/Traits/Chainable.php delete mode 100644 src/Scaffolding/Traits/DataObjectTypeTrait.php delete mode 100644 src/Scaffolding/Util/ArrayTypeParser.php delete mode 100644 src/Scaffolding/Util/OperationList.php delete mode 100644 src/Scaffolding/Util/StringTypeParser.php create mode 100644 src/Schema/DataObject/CreateCreator.php create mode 100644 src/Schema/DataObject/DataObjectModel.php create mode 100644 src/Schema/DataObject/DeleteCreator.php create mode 100644 src/Schema/DataObject/FieldAccessor.php create mode 100644 src/Schema/DataObject/FieldReconciler.php create mode 100644 src/Schema/DataObject/InheritanceChain.php create mode 100644 src/Schema/DataObject/ModelCreator.php create mode 100644 src/Schema/DataObject/Plugin/AbstractCanViewPermission.php create mode 100644 src/Schema/DataObject/Plugin/CanViewPermission.php create mode 100644 src/Schema/DataObject/Plugin/FirstResult.php create mode 100644 src/Schema/DataObject/Plugin/Inheritance.php create mode 100644 src/Schema/DataObject/Plugin/InheritedPlugins.php create mode 100644 src/Schema/DataObject/Plugin/Paginator.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/FieldFilterInterface.php rename src/{ => Schema/DataObject/Plugin}/QueryFilter/FieldFilterRegistry.php (66%) create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/FilterRegistryInterface.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/ContainsFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/EndsWithFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/EqualToFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanOrEqualFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/InFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanOrEqualFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/NotEqualFilter.php create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/Filters/StartsWithFilter.php rename src/{ => Schema/DataObject/Plugin}/QueryFilter/ListFieldFilterInterface.php (66%) create mode 100644 src/Schema/DataObject/Plugin/QueryFilter/QueryFilter.php create mode 100644 src/Schema/DataObject/Plugin/QuerySort.php create mode 100644 src/Schema/DataObject/ReadCreator.php create mode 100644 src/Schema/DataObject/ReadOneCreator.php create mode 100644 src/Schema/DataObject/Resolver.php create mode 100644 src/Schema/DataObject/UpdateCreator.php create mode 100644 src/Schema/Exception/MutationException.php create mode 100644 src/Schema/Exception/PermissionsException.php create mode 100644 src/Schema/Exception/SchemaBuilderException.php create mode 100644 src/Schema/Field/Argument.php create mode 100644 src/Schema/Field/Field.php create mode 100644 src/Schema/Field/ModelAware.php create mode 100644 src/Schema/Field/ModelField.php create mode 100644 src/Schema/Field/ModelMutation.php create mode 100644 src/Schema/Field/ModelQuery.php create mode 100644 src/Schema/Field/Mutation.php create mode 100644 src/Schema/Field/Query.php create mode 100644 src/Schema/Interfaces/ConfigurationApplier.php create mode 100644 src/Schema/Interfaces/ContextProvider.php create mode 100644 src/Schema/Interfaces/DefaultFieldsProvider.php create mode 100644 src/Schema/Interfaces/Encoder.php create mode 100644 src/Schema/Interfaces/ExtraTypeProvider.php create mode 100644 src/Schema/Interfaces/FieldPlugin.php create mode 100644 src/Schema/Interfaces/InputTypeProvider.php create mode 100644 src/Schema/Interfaces/ModelBlacklist.php create mode 100644 src/Schema/Interfaces/ModelConfigurationProvider.php create mode 100644 src/Schema/Interfaces/ModelFieldPlugin.php create mode 100644 src/Schema/Interfaces/ModelMutationPlugin.php create mode 100644 src/Schema/Interfaces/ModelOperation.php create mode 100644 src/Schema/Interfaces/ModelQueryPlugin.php create mode 100644 src/Schema/Interfaces/ModelTypePlugin.php create mode 100644 src/Schema/Interfaces/MutationPlugin.php create mode 100644 src/Schema/Interfaces/OperationCreator.php create mode 100644 src/Schema/Interfaces/OperationProvider.php create mode 100644 src/Schema/Interfaces/PluginInterface.php create mode 100644 src/Schema/Interfaces/PluginValidator.php create mode 100644 src/Schema/Interfaces/QueryPlugin.php create mode 100644 src/Schema/Interfaces/ResolverProvider.php create mode 100644 src/Schema/Interfaces/SchemaComponent.php create mode 100644 src/Schema/Interfaces/SchemaModelCreatorInterface.php create mode 100644 src/Schema/Interfaces/SchemaModelInterface.php create mode 100644 src/Schema/Interfaces/SchemaStorageCreator.php create mode 100644 src/Schema/Interfaces/SchemaStorageInterface.php create mode 100644 src/Schema/Interfaces/SchemaUpdater.php create mode 100644 src/Schema/Interfaces/SchemaValidator.php create mode 100644 src/Schema/Interfaces/SignatureProvider.php create mode 100644 src/Schema/Interfaces/TypePlugin.php create mode 100644 src/Schema/Plugin/AbstractNestedInputPlugin.php create mode 100644 src/Schema/Plugin/AbstractQueryFilterPlugin.php create mode 100644 src/Schema/Plugin/AbstractQuerySortPlugin.php create mode 100644 src/Schema/Plugin/PaginationPlugin.php create mode 100644 src/Schema/Plugin/PluginConsumer.php create mode 100644 src/Schema/Registry/PluginRegistry.php create mode 100644 src/Schema/Registry/ResolverRegistry.php create mode 100644 src/Schema/Registry/SchemaModelCreatorRegistry.php create mode 100644 src/Schema/Resolver/ComposedResolver.php create mode 100644 src/Schema/Resolver/DefaultResolver.php create mode 100644 src/Schema/Resolver/DefaultResolverProvider.php create mode 100644 src/Schema/Resolver/EncodedResolver.php create mode 100644 src/Schema/Resolver/ResolverReference.php create mode 100644 src/Schema/Resolver/templates/_manifest_exclude create mode 100644 src/Schema/Resolver/templates/resolver.inc.php create mode 100644 src/Schema/Schema.php create mode 100644 src/Schema/Storage/AbstractTypeRegistry.php create mode 100644 src/Schema/Storage/CodeGenerationStore.php create mode 100644 src/Schema/Storage/CodeGenerationStoreCreator.php create mode 100644 src/Schema/Storage/Encoder.php create mode 100644 src/Schema/Storage/templates/_manifest_exclude create mode 100644 src/Schema/Storage/templates/enum.inc.php create mode 100644 src/Schema/Storage/templates/interface.inc.php create mode 100644 src/Schema/Storage/templates/registry.inc.php create mode 100644 src/Schema/Storage/templates/scalar.inc.php create mode 100644 src/Schema/Storage/templates/type.inc.php create mode 100644 src/Schema/Storage/templates/union.inc.php create mode 100644 src/Schema/Type/EncodedType.php create mode 100644 src/Schema/Type/Enum.php create mode 100644 src/Schema/Type/InputType.php create mode 100644 src/Schema/Type/InterfaceType.php create mode 100644 src/Schema/Type/ModelType.php create mode 100644 src/Schema/Type/Scalar.php create mode 100644 src/Schema/Type/Type.php create mode 100644 src/Schema/Type/TypeReference.php create mode 100644 src/Schema/Type/UnionType.php delete mode 100644 src/TypeCreator.php delete mode 100644 src/Util/CaseInsensitiveFieldAccessor.php delete mode 100644 tests/ConnectionTest.php delete mode 100644 tests/FieldCreatorTest.php delete mode 100644 tests/ManagerTest.php delete mode 100644 tests/Scaffolding/Extensions/TypeCreatorExtensionTest.php delete mode 100644 tests/Scaffolding/Scaffolders/ArgumentScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/CRUD/CreateTest.php delete mode 100644 tests/Scaffolding/Scaffolders/CRUD/DeleteTest.php delete mode 100644 tests/Scaffolding/Scaffolders/CRUD/ReadOneTest.php delete mode 100644 tests/Scaffolding/Scaffolders/CRUD/ReadTest.php delete mode 100644 tests/Scaffolding/Scaffolders/CRUD/UpdateTest.php delete mode 100644 tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/InheritanceScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/ItemQueryScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/ListQueryScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/MutationScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/OperationScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/SchemaScaffolderTest.php delete mode 100644 tests/Scaffolding/Scaffolders/UnionScaffolderTest.php delete mode 100644 tests/Scaffolding/StaticSchemaTest.php delete mode 100644 tests/Scaffolding/Util/ArrayTypeParserTest.php delete mode 100644 tests/Scaffolding/Util/OperationListTest.php delete mode 100644 tests/Scaffolding/Util/StringTypeParserTest.php delete mode 100644 tests/TypeCreatorTest.php delete mode 100644 tests/Util/CaseInsensitiveFieldAccessorTest.php diff --git a/README.md b/README.md index b23b42143..ffd5a240a 100644 --- a/README.md +++ b/README.md @@ -20,2608 +20,6 @@ Require the [composer](http://getcomposer.org) package in your `composer.json` composer require silverstripe/graphql ``` -## Table of contents +## Documentation - - [Usage](#usage) - - [Examples](#examples) - - [Configuration](#configuration) - - [Define types](#define-types) - - [Define queries](#define-queries) - - [Pagination](#pagination) - - [Setting pagination and sorting options](#setting-pagination-and-sorting-options) - - [Nested connections](#nested-connections) - - [Adding search params](#adding-search-params) - - [Define Mutations](#define-mutations) - - [Scaffolding DataObjects into the schema](#scaffolding-dataobjects-into-the-schema) - - [Our example](#our-example) - - [Scaffolding through the config layer](#scaffolding-through-the-config-layer) - - [Scaffolding through procedural code](#scaffolding-through-procedural-code) - - [Exposing a DataObject to GraphQL](#exposing-a-dataobject-to-graphql) - - [Available operations](#available-operations) - - [Scaffolding search params](#adding-search-params-read-operations-only) - - [Setting field and operation descriptions](#setting-field-and-operation-descriptions) - - [Setting field descriptions](#setting-field-descriptions) - - [Wildcarding and whitelisting fields](#wildcarding-and-whitelisting-fields) - - [Adding arguments](#adding-arguments) - - [Argument definition shorthand](#argument-definition-shorthand) - - [Adding more definition to arguments](#adding-more-definition-to-arguments) - - [Using a custom resolver](#using-a-custom-resolver) - - [Configuring pagination and sorting](#configuring-pagination-and-sorting) - - [Adding related objects](#adding-related-objects) - - [Adding arbitrary queries and mutations](#adding-arbitrary-queries-and-mutations) - - [Dealing with inheritance](#dealing-with-inheritance) - - [Querying types that have descendants](#querying-types-that-have-descendants) - - [Customising the names of types and operations](#customising-the-names-of-types-and-operations) - - [Versioned content](#versioned-content) - - [Version-specific-operations](#version-specific-operations) - - [Version-specific arguments](#version-specific-arguments) - - [Version-specific fields](#version-specific-fields) - - [Define interfaces](#define-interfaces) - - [Define input types](#define-input-types) - - [Extending](#extending) - - [Adding/removing fields from thirdparty code](#adding-removing-fields-from-thirdparty-code) - - [Updating the core operations](#updating-the-core-operations) - - [Adding new operations](#adding-new-operations) - - [Changing behaviour with Middleware](#changing-behaviour-with-middleware) - - [Testing/debugging queries and mutations](#testingdebugging-queries-and-mutations) - - [Authentication](#authentication) - - [Default authentication](#default-authentication) - - [HTTP basic authentication](#http-basic-authentication) - - [In GraphiQL](#in-graphiql) - - [Defining your own authenticators](#defining-your-own-authenticators) - - [CSRF tokens (required for mutations)](#csrf-tokens-required-for-mutations) - - [Cross-Origin Resource Sharing (CORS)](#cross-origin-resource-sharing-cors) - - [Sample Custom CORS Config](#sample-custom-cors-config) - - [Persisting Queries](#persisting-queries) - - [Schema introspection](#schema-introspection) - - [Setting up a new GraphQL schema](#setting-up-a-new-graphql-schema) - - [Strict HTTP Method Checking](#strict-http-method-checking) - - [TODO](#todo) - - - - - -## Usage - -GraphQL is used through a single route, typically `/graphql`. You need -to define *Types* and *Queries* to expose your data via this endpoint. While this recommended -route is left open for you to configure on your own, the modules contained in the [CMS recipe](https://github.com/silverstripe/recipe-cms), - (e.g. `asset-admin`, `campaign-admin`) run off a separate GraphQL server with its own endpoint - (`admin/graphql`) with its own permissions and schema. - -These separate endpoints have their own identifiers. `default` refers to the GraphQL server -in the user space (e.g. `/graphql`) while `admin` refers to the GraphQL server used by CMS modules -(`admin/graphql`). You can also [set up a new schema](#setting-up-a-new-graphql-schema) if you wish. - -By default, this module does not route any GraphQL servers. To activate the default, -public-facing GraphQL server that ships with the module, just add a rule to `Director`. - -```yaml -SilverStripe\Control\Director: - rules: - 'graphql': '%$SilverStripe\GraphQL\Controller.default' -``` - -## Examples - -Code examples can be found in the `examples/` folder (built out from the -configuration docs below). - -## Configuration - -### The manager - -The primary class used to define your schema is `SilverStripe\GraphQL\Manager`, which is a container -for types and queries which get negotiated and transformed into a schema on a just-in-time -basis for every request. All types, queries, mutations, interfaces, and fragments must be -registered in a `Manager` instance. - -### Define types - -Types describe your data. While your data could be any arbitrary structure, in -a SilverStripe project a GraphQL type usually relates to a `DataObject`. -GraphQL uses this information to validate queries and allow GraphQL clients to -introspect your API capabilities. The GraphQL type system is hierarchical, so -the `fields()` definition declares object properties as scalar types within -your complex type. Refer to the -[graphql-php type definitions](http://webonyx.github.io/graphql-php/type-system/) -for available types. - -```php - 'member' - ]; - } - - public function fields() - { - return [ - 'ID' => ['type' => Type::nonNull(Type::id())], - 'Email' => ['type' => Type::string()], - 'FirstName' => ['type' => Type::string()], - 'Surname' => ['type' => Type::string()], - ]; - } -} - -``` - -Each type class needs to be registered with a unique name against the schema -through YAML configuration: - -```yml -SilverStripe\GraphQL\Manager: - schemas: - default: - types: - member: 'MyProject\GraphQL\MemberTypeCreator' -``` - - -### Define queries - -Types can be exposed via "queries". These queries are in charge of retrieving -data through the SilverStripe ORM. The response itself is handled by the -underlying GraphQL PHP library, which loops through the resulting `DataList` -and accesses fields based on the referred "type" definition. - -**Note:** This will return ALL records. See below for a paginated example. - -```php - 'readMembers' - ]; - } - - public function args() - { - return [ - 'Email' => ['type' => Type::string()] - ]; - } - - public function type() - { - return Type::listOf($this->manager->getType('member')); - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $member = Member::singleton(); - if (!$member->canView($context['currentUser'])) { - throw new \InvalidArgumentException(sprintf( - '%s view access not permitted', - Member::class - )); - } - $list = Member::get(); - - // Optional filtering by properties - if (isset($args['Email'])) { - $list = $list->filter('Email', $args['Email']); - } - - return $list; - } -} -``` - -We'll register the query with a unique name through YAML configuration: - -```yml -SilverStripe\GraphQL\Manager: - schemas: - default: - queries: - readMembers: 'MyProject\GraphQL\ReadMembersQueryCreator' -``` - -You can query data with the following URL: - -``` -/graphql/?query={readMembers{ID+FirstName+Email}} -``` - -The query contained in the `query` parameter can be reformatted as follows: - -```graphql -{ - readMembers { - edges { - node { - ID - FirstName - Email - } - } - } -} -``` - -You can apply the `Email` filter in the above example like so: - -```graphql -query ($Email: String) { - readMembers(Email: $Email) { - edges { - node { - ID - FirstName - Email - } - } - } -} -``` - -And add a query variable: - -```json -{ - "Email": "john@example.com" -} -``` - -You could express this query inline as a single query as below: - -```graphql -{ - readMembers(Email: "john@example.com") { - edges { - node { - ID - FirstName - Email - } - } - } -} -``` - -### Pagination - -The GraphQL module also provides a wrapper to return paginated and sorted -records using offset based pagination. - -> This module currently does not support Relay (cursor based) pagination. -> [This blog post](https://dev-blog.apollodata.com/understanding-pagination-rest-graphql-and-relay-b10f835549e7#.kg5qkwvuz) -> describes the differences. - -To have a `Query` return a page-able list of records queries should extend the -`PaginatedQueryCreator` class and return a `Connection` instance. - -```php -setConnectionType($this->manager->getType('member')) - ->setArgs([ - 'Email' => [ - 'type' => Type::string() - ] - ]) - ->setSortableFields(['ID', 'FirstName', 'Email']) - ->setConnectionResolver(function ($object, array $args, $context, ResolveInfo $info) { - $member = Member::singleton(); - if (!$member->canView($context['currentUser'])) { - throw new \InvalidArgumentException(sprintf( - '%s view access not permitted', - Member::class - )); - } - $list = Member::get(); - - // Optional filtering by properties - if (isset($args['Email'])) { - $list = $list->filter('Email', $args['Email']); - } - - return $list; - }); - } -} - -``` - -You will need to add a new unique query alias to your configuration: - -```yml -SilverStripe\GraphQL\Manager: - schemas: - default: - queries: - paginatedReadMembers: 'MyProject\GraphQL\PaginatedReadMembersQueryCreator' -``` - -Using a `Connection` the GraphQL server will return the results wrapped under -the `edges` result type. `Connection` supports the following arguments: - -* `limit` -* `offset` -* `sortBy` - -Additional arguments can be added by providing the `setArgs` function (such as -`Email` in the previous example). Each argument must be given a specific type. - -Pagination information is provided under the `pageInfo` type. This object type -supports the following fields: - -* `totalCount` returns the total number of items in the list, -* `hasNextPage` returns whether more records are available. -* `hasPreviousPage` returns whether more records are available by decreasing -the offset. - -You can query paginated data with the following URL: - -``` -/graphql/?query=query+Members{paginatedReadMembers(limit:1,offset:0){edges{node{ID+FirstName+Email}}pageInfo{hasNextPage+hasPreviousPage+totalCount}}} -``` - -The query contained in the `query` parameter can be reformatted as follows: - -```graphql -query Members { - paginatedReadMembers(limit: 1, offset: 0) { - edges { - node { - ID - FirstName - Email - } - } - pageInfo { - hasNextPage - hasPreviousPage - totalCount - } - } -} - -``` - -#### Setting pagination and sorting options - -To limit the ability for users to perform searching and ordering as they wish, -`Collection` instances can define their own limits and defaults. - -* `setSortableFields` an array of allowed sort columns. -* `setDefaultLimit` integer for the default page length (default 100) -* `setMaximumLimit` integer for the maximum `limit` records per page to prevent -excessive load trying to load millions of records (default 100) - -```php -return Connection::create('paginatedReadMembers') - // ... - ->setDefaultLimit(10) - ->setMaximumLimit(100); // prevents users requesting more than 100 records -``` - -### Nested connections - -`Connection` can be used to return related objects such as `has_many` and -`many_many` models. - -```php - 'member' - ]; - } - - public function fields() - { - $groupsConnection = Connection::create('Groups') - ->setConnectionType($this->manager->getType('group')) - ->setDescription('A list of the users groups') - ->setSortableFields(['ID', 'Title']); - - return [ - 'ID' => ['type' => Type::nonNull(Type::id())], - 'Email' => ['type' => Type::string()], - 'FirstName' => ['type' => Type::string()], - 'Surname' => ['type' => Type::string()], - 'Groups' => [ - 'type' => $groupsConnection->toType(), - 'args' => $groupsConnection->args(), - 'resolve' => function($object, array $args, $context, ResolveInfo $info) use ($groupsConnection) { - return $groupsConnection->resolveList( - $object->Groups(), - $args, - $context - ); - } - ] - ]; - } -} -``` - -```graphql -query Members { - paginatedReadMembers(limit: 10) { - edges { - node { - ID - FirstName - Email - Groups(sortBy: [{field: Title, direction: DESC}]) { - edges { - node { - ID - Title - Description - } - } - pageInfo { - hasNextPage - hasPreviousPage - totalCount - } - } - } - } - pageInfo { - hasNextPage - hasPreviousPage - totalCount - } - } -} -``` - -### Adding search params - -You can add search parameters your query to filter results using the `DataObjectQueryFilter` class. -Much like pagination, this is done using a reusable service that wraps your existing queries. - -The end result will allow you to do something like this: - -```graphql -query readBlogs( - Filter: { - Title__contains: "food", - CommentCount__gt: 5, - Categories__Title__in: ["Recipes", "Cooking tips"] - }, - Exclude: { - Hidden__eq: true - } -) { - ID - Title -} -``` - -So how do we do it? If you're using `QueryCreator` classes, a good approach is to add an instance of the query filter -in your constructor. - -```php -$this->queryFilter = DataObjectQueryFilter::create(MyDataObject::class); -``` - -You can then add filters to fields of the dataobject. - -```php -$this->queryFilter - ->addFilteredField('Title', 'contains') - ->addFilteredField('CommentCount', 'gt') - ->addFilteredField('Categories__Title', 'in') - ->addFilteredField('Hidden', 'eq'); -``` - -Don't worry about the filter keys (`contains`, `gt`, `eq`, etc) for now. That will be explained [further down](#the-filter-registry). - -Now that we have composed a `DataObjectQueryFilter`, we can now use it to create input types. - -```php -public function args() -{ - return [ - 'Filter' => $this->queryFilter->getInputType('MyDataObjectFilterInputType'), - 'Exclude' => $this->queryFilter->getInputType('MyDataObjectExcludeInputType'), - ]; -} -``` - -> Make sure your argument names match the names configured in `DataObjectQueryFilter`. By default, -they are `Filter` and `Exclude`. If you want to use different names, use `setFilterKey()` and -`setExcludeKey()`. - -Lastly, let's update the resolver to apply the filters. - -```php -public function resolve($obj, $args = [], $context = [], ResolveInfo $info) -{ - $list = MyDataObject::get(); - $list = $this->queryFilter->applyArgsToList($list, $args); - - return $list; -} -``` - -#### Shortcuts (for the 80% case) - -All `SilverStripe\ORM\DBField` instances are configured to have a set of "default" filters (see `filters.yml`). -For instance, most string and text fields just use `eq`, `contains`, and `in`, and omit integer-specific -filters like `gt` and `lt`. - -To make things easier, you can simply add the default filters for each field. - -```php -$this->queryFilter->addDefaultFilters('MyTextField'); -$this->queryFilter->addDefaultFilters('MyInt'); -``` - -Even better, if you want all fields on your object filterable, just use `addAllFilters` to add -all the default filters for each field (including inherited) on your model. - -```php -$this->queryFilter->addAllFilters(); -``` - -#### The filter registry - -The reason why we're able to use `contains`, `gt`, etc. as filters is because they have been added -to the filter registry, a singleton that is composed through the config yaml. (See `filters.yml`). - -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\QueryFilter\FilterRegistryInterface: - class: SilverStripe\GraphQL\QueryFilter\FieldFilterRegistry - constructor: - contains: '%$SilverStripe\GraphQL\QueryFilter\Filters\ContainsFilter' - eq: '%$SilverStripe\GraphQL\QueryFilter\Filters\EqualToFilter' - # etc... -``` - -You can add your own filters. You just need to implement the `SilverStripe\GraphQL\QueryFilter\FieldFilterInterface` -interface, which requires methods for `applyInclusion()` and `applyExclusion()`. It also must declare -a unique identifier (e.g. `contains`). Once you've defined a class, just add it to the registry via config. - -> If you want your filter to accept an array of values, implement ``SilverStripe\GraphQL\QueryFilter\ListFieldFilterInterface`` -instead. - -#### Default filters - -| Identifier | ORM Mapping | Classname | -|------------|--------------------|-------------------------------------------------------------------| -| eq | ExactMatch | SilverStripe\GraphQL\QueryFilter\Filters\EqualToFilter | -| contains | PartialMatch | SilverStripe\GraphQL\QueryFilter\Filters\ContainsFilter | -| gt | GreaterThan | SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanFilter | -| lt | LessThan | SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter | -| gte | GreaterThanOrEqual | SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanOrEqualFilter | -| lte | LessThanOrEqual | SilverStripe\GraphQL\QueryFilter\Filters\LessThanOrEqualFilter | -| startswith | StartsWith | SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter | -| endswith | EndsWith | SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter | -| in | ExactMatch (array) | SilverStripe\GraphQL\QueryFilter\Filters\InFilter | - -#### Custom filters - -For some queries, you may want to use a default filter identifier (e.g. `eq`) but with a custom -implementation of its filtering mechanism. For this, you can use `addFieldFilter` method. - -One example might be searching by date, where the provided date does not have to be an exact -match on the full timestamp to satisfy the filter. - -```php -$this->queryFilter->addFieldFilter('PublishedDate', new FuzzyDateFilter()); -``` - -Where `FuzzyDateFilter` is an implementation of `FieldFilterInterface. - -```php -class MyCustomFieldFilter implements FieldFilterInterface -{ - public function getIdentifier() - { - return 'eq'; - } - - public function applyInclusion(DataList $list, $fieldName, $value) - { - return $list->addWhere([ - 'DATE(PublishedDate) = ?' => $value - ]); - } -} -``` - -You can now query all posts for a given day with: - -``` -query readMyPosts(Filter: { - PublishedDate__eq: "2018-01-29" -}) { - Title -} -``` - -### Define mutations - -A "mutation" is a specialised GraphQL query which has side effects on your data, -such as create, update or delete. Each of these operations would be expressed -as its own mutation class. Returning an object from the `resolve()` method -will automatically include it in the response. - -```php - 'createMember', - 'description' => 'Creates a member without permissions or group assignments' - ]; - } - - public function type() - { - return $this->manager->getType('member'); - } - - public function args() - { - return [ - 'Email' => ['type' => Type::nonNull(Type::string())], - 'FirstName' => ['type' => Type::string()], - 'LastName' => ['type' => Type::string()], - ]; - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - if (!singleton(Member::class)->canCreate($context['currentUser'])) { - throw new \InvalidArgumentException('Member creation not allowed'); - } - - return (new Member($args))->write(); - } -} - -``` - -We'll register this mutation through YAML configuration: - -```yml -SilverStripe\GraphQL\Manager: - schemas: - default: - mutations: - createMember: 'MyProject\GraphQL\CreateMemberMutationCreator' -``` - -You can run a mutation with the following query: - -```graphql -mutation ($Email: String!) { - createMember(Email: $Email) { - ID - } -} -``` - -This will create a new member with an email address, which you can pass in as -query variables: `{"Email": "test@test.com"}`. It'll return the new `ID` -property of the created member. - -## Scaffolding DataObjects into the schema - -Making a DataObject accessible through the GraphQL API involves quite a bit of boilerplate. In the above example, we can -see that creating endpoints for a query and a mutation requires creating three new classes, along with an update to the -configuration, and we haven't even dealt with data relations yet. For applications that require a lot of business logic -and specific functionality, an architecture like this affords the developer a lot of control, but for developers who -just want to make a given model accessible through GraphQL with some basic create, read, update, and delete operations, -scaffolding them can save a lot of time and reduce the clutter in your project. - -Scaffolding DataObjects can be achieved in two non-exclusive ways: - -* Via executable code (procedurally) -* Via the config layer (declaratively) - -Examples of both methods will be demonstrated below. - -### Our example - -For these examples, we'll imagine we have the following model: - -```php -namespace MyProject; - -use MyProject\Comment; -use SilverStripe\Assets\File; -use SilverStripe\Security\Member; - -class Post extends DataObject -{ - private static $db = [ - 'Title' => 'Varchar', - 'Content' => 'HTMLText' - ]; - - private static $has_one = [ - 'Author' => Member::class - ]; - - private static $has_many = [ - 'Comments' => Comment::class - ]; - - private static $many_many = [ - 'Files' => File::class - ]; -} -``` - -### Scaffolding through the Config layer - -Many of the declarations you make through procedural code can be done via YAML. If you don't have any logic in your -scaffolding, using YAML is a simple approach to adding scaffolding. - -We'll need to define a `scaffolding` node in the `SilverStripe\GraphQL\Manager.schemas.mySchemaKey` setting. - -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - ## scaffolding will go here - -``` - -### Scaffolding through procedural code - -Alternatively, for more complex requirements, you can create the scaffolding with code. The GraphQL `Manager` class will -bootstrap itself with any scaffolders that are registered in its config. These scaffolders must implement the -`ScaffoldingProvider` interface. A logical place to add this code may be in your DataObject, but it could be anywhere. - -As a `ScaffoldingProvider`, the class must now offer the `provideGraphQLScaffolding()` method. - -```php -namespace MyProject; - -use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider; -use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder; -use SilverStripe\ORM\DataObject; - -class Post extends DataObject implements ScaffoldingProvider -{ - //... - public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder) - { - // update the scaffolder here - } -} -``` - -In order to register the scaffolding provider with the manager, we'll need to make an update to the config. - -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding_providers: - - MyProject\Post -``` - -### Exposing a DataObject to GraphQL - -Let's now expose the `Post` type. We'll choose the fields we want to offer, along with a simple query and mutation. To -resolve queries and mutations, we'll need to specify the name of a resolver class. This class -must implement the `SilverStripe\GraphQL\OperationResolver`. (More on this below). - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content] - operations: - read: true - create: true -``` - - -**...Or with code**: - -```php -namespace MyProject; - -use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider; -use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder; -use SilverStripe\ORM\DataObject; - -class Post extends DataObject implements ScaffoldingProvider -{ - //... - public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder) - { - $scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content']) - ->operation(SchemaScaffolder::READ) - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->end(); - - return $scaffolder; - } -} -``` - -By declaring these two operations, we have automatically added a new query and -mutation to the GraphQL schema, using naming conventions derived from -the operation type and the `singular_name` or `plural_name` of the DataObject. - -```graphql -query { - readPosts { - edges { - node { - Title - Content - } - } - } -} -``` - -```graphql -mutation CreatePost($Input: PostCreateInputType!) { - createPost(Input: $Input) { - Title - } -} -``` - -```json -{ - "Input": { - "Title": "My Title" - } -} -``` - -Permission constraints (in this case `canView()` and `canCreate()`) are enforced -by the operation resolvers. - -#### Available operations -For each type, all the basic `CRUD` operations are afforded to you by default (`create`, `read`, `update`, `delete`), -plus an operation for `readoOne`, which retrieves a record by ID. Each operation can be activated by setting their -identifier to `true` in YAML. - -``` -... - operations: - read: true - update: true - create: true - delete: true - readOne: true -``` - -To add configuration to these operations, define a map rather than assigning a boolean. - -``` -... - operations: - read: - paginate: false -``` - -Alternatively, when using procedural code, just call `opertation($identifier)`, where `$identifier` -is a constant on the `SchemaScaffolder` class definition. - -```php -$scaffolder->type(MyDataObject::class) - ->operation(SchemaScaffolder::READ) - ->setUsePagination(false) - ->end(); -``` -#### Adding search params (read operations only) - -You can add all default filters for every field on your dataobject with `filters: '*'`. - -```yaml -read: - filters: '*' -``` -> Note: "every field" means every field exposed by `searchable_fields` on the dataobject -- not just those exposed on its GraphQL type. - -To be more granular, break it up into a list of specific fields. - -```yaml -read: - filters: - MyField: true # All default filters for this field type - MyInt: - gt: true # Greater than - gte: true # Greater than or equal -``` - -**Or with procedural code**... -```php -$scaffolder->type(MyDataObject::class) - ->operation(SchemaScaffolder::READ) - ->queryFilter() - ->addDefaultFields('MyField') - ->addFieldFilter('MyInt', 'gt') - ->addFieldFilter('MyInt', 'gte') - ->end() - ->end(); -``` -These filter options are also available on`readOne` operations, but be aware that they are mutually -exclusive with its `ID` parameter. - -#### Setting field and operation descriptions - -Adding field descriptions is a great way to maintain a well-documented API. To do this, -use a map of `FieldName: 'Your description'` instead of an enumerated list of field names. - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: - ID: The unique identifier of the post - Title: The title of the post - Content: The main body of the post (HTML) - operations: - read: - description: Reads all posts - create: true -``` - -**...Or with code**: - -```php -namespace MyProject; - -use SilverStripe\GraphQL\Scaffolding\Interfaces\ScaffoldingProvider; -use SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder; -use SilverStripe\ORM\DataObject; - -class Post extends DataObject implements ScaffoldingProvider -{ - //... - public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder) - { - $scaffolder - ->type(Post::class) - ->addFields([ - 'ID' => 'The unique identifier of the post', - 'Title' => 'The title of the post', - 'Content' => 'The main body of the post (HTML)' - ]) - ->operation(SchemaScaffolder::READ) - ->setDescription('Reads all posts') - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->end(); - - return $scaffolder; - } -} -``` - -#### Wildcarding and whitelisting fields - -If you have a type you want to be fairly well exposed, it can be tedious to add each -field piecemeal. As a shortcut, you can use `addAllFields()` (code) or `fields: "*"` (YAML). -If you have specific fields you want omitted from that list, you can use -`addAllFieldsExcept()` (code) or `excludeFields` (YAML). - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: "*" - excludeFields: [SecretThing] -``` - -**... Or with code**: -```php -$scaffolder - ->type(Post::class) - ->addAllFieldsExcept(['SecretThing']) - ->end() -``` - - -#### Adding arguments - -You can add arguments to basic crud operations, but keep in mind you'll need to use your own -resolver, as the default resolvers will not be aware of any custom arguments you've allowed. - -Using YAML, simply use a map of options instead of `true`. - -**Via YAML** -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content] - operations: - read: - args: - Title: String - resolver: MyProject\ReadPostResolver - create: true -``` - -**... Or with code** -```php -$scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content']) - ->operation(SchemaScaffolder::READ) - ->addArgs([ - 'Title' => 'String' - ]) - ->setResolver(function($object, array $args, $context, ResolveInfo $info) { - if (!singleton(Post::class)->canView($context['currentUser'])) { - throw new \Exception('Cannot view Post'); - } - $list = Post::get(); - if (isset($args['Title'])) { - $list = $list->filter('Title:PartialMatch', $args['Title']); - } - - return $list; - }) - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->end(); -``` - -**GraphQL** -```graphql -query { - readPosts(Title: "Barcelona") { - edges { - node { - Title - Content - } - } - } -} -``` - -#### Argument definition shorthand - -You can make your scaffolding delcaration a bit more expressive by using argument shorthand. -* `String!`: A required string -* `Int!(50)`: A required integer with a default value of 50 -* `Boolean(0)`: A boolean defaulting to false -* `String`: An optional string, defaulting to null - -#### Adding more definition to arguments - -To add descriptions, and to use a more granular level of control over your arguments, -you can use a more long-form syntax. - -**Via YAML** -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content] - operations: - read: - args: - Title: String! - MinimumCommentCount: - type: Int - default: 5 - description: 'Use this parameter to specify the minimum number of comments per post' - resolver: MyProject\ReadPostResolver - create: true -``` - -**... Or with code** -```php -$scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content']) - ->operation(SchemaScaffolder::READ) - ->addArgs([ - 'Title' => 'String!', - 'MinimumCommentCount' => 'Int' - ]) - ->setArgDefaults([ - 'MinimumCommentCount' => 5 - ]) - ->setArgDescriptions([ - 'MinimumCommentCount' => 'Use this parameter to specify the minimum number of comments per post' - ]) - ->setResolver(function($object, array $args, $context, ResolveInfo $info) { - if (!singleton(Post::class)->canView($context['currentUser'])) { - throw new \Exception('Cannot view Post'); - } - $list = Post::get(); - if (isset($args['Title'])) { - $list = $list->filter('Title:PartialMatch', $args['Title']); - } - - return $list; - }) - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->end(); -``` - -#### Using a custom resolver - -As seen in the code example above, the simplest way to add a resolver is via an anonymous function -via the `setResolver()` method. In YAML, you can't define such functions, so resolvers be names or instances of -classes that implement the `OperationResolver`. - -**When using the YAML approach, custom resolver classes are compulsory**, since you can't define closures in YAML. - -```php -namespace MyProject\GraphQL; - -use GraphQL\Type\Definition\ResolveInfo; -use SilverStripe\GraphQL\OperationResolver; - -class MyResolver implements OperationResolver -{ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $post = Post::get()->byID($args['ID']); - $post->Title = $args['NewTitle']; - $post->write(); - } -} -``` - -This resolver class may now be assigned as either an instance, or a string to the query or mutation definition. - -```php -$scaffolder - ->type(Post::class) - ->operation(SchemaScaffolder::UPDATE) - ->setResolver(MyResolver::class) - /* Or... - ->setResolver(new MyResolver()) - */ - ->end(); -``` - -#### Configuring pagination and sorting - -By default, all queries are paginated and have no sortable fields. Both of these settings are -configurable. - -**Via YAML** -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content] - operations: - read: - args: - Title: String - resolver: MyProject\ReadPostResolver - sortableFields: [Title] - create: true - MyProject\Comment: - fields: [Comment, Author] - operations: - read: - paginate: false -``` - -**... Or with code** -```php -$scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content']) - ->operation(SchemaScaffolder::READ) - ->addArgs([ - 'Title' => 'String' - ]) - ->setResolver(function ($object, array $args, $context, ResolveInfo $info) { - if (!singleton(Post::class)->canView($context['currentUser'])) { - throw new \Exception('Cannot view Post'); - } - $list = Post::get(); - if (isset($args['Title'])) { - $list = $list->filter('Title:PartialMatch', $args['Title']); - } - - return $list; - }) - ->addSortableFields(['Title']) - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->end() - ->type(Comment::class) - ->addFields(['Comment', 'Author']) - ->operation(SchemaScaffolder::READ) - ->setUsePagination(false) - ->end(); -``` - -**GraphQL** -```graphql -query readPosts(Title: "Japan", sortBy: [{field:Title, direction:DESC}]) { - edges { - node { - Title - } - } -} -``` - -```graphql -query readComments { - edges { - node { - Author - Comment - } - } -} -``` - -#### Adding related objects - -The `Post` type we're using has a `$has_one` relation to `Author` (Member), and plural relationships -to `File` and `Comment`. Let's expose both of those to the query. - -For the `$has_one`, the relationship can simply be declared as a field. For `$has_many`, `$many_many`, -and any custom getter that returns a `DataList`, we can set up a nested query using `nestedQueries`: - - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content, Author] - operations: - read: - args: - Title: String - resolver: MyProject\ReadPostResolver - sortableFields: [Title] - create: true - nestedQueries: - Comments: true - Files: true - MyProject\Comment: - fields: [Comment, Author] - operations: - read: - paginate: false - -``` - -**... Or with code**: -```php -$scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content', 'Author']) - ->operation(SchemaScaffolder::READ) - ->addArgs([ - 'Title' => 'String' - ]) - ->setResolver(function ($object, array $args, $context, ResolveInfo $info) { - if (!singleton(Post::class)->canView($context['currentUser'])) { - throw new \Exception('Cannot view Post'); - } - $list = Post::get(); - if (isset($args['Title'])) { - $list = $list->filter('Title:PartialMatch', $args['Title']); - } - - return $list; - }) - ->addSortableFields(['Title']) - ->end() - ->operation(SchemaScaffolder::UPDATE) - ->end() - ->nestedQuery('Comments') - ->end() - ->nestedQuery('Files') - ->end() - ->end() - ->type(Comment::class) - ->addFields(['Comment', 'Author']) - ->operation(SchemaScaffolder::READ) - ->setUsePagination(false) - ->end(); -``` - -**GraphQL** - -```graphql -query { - readPosts(Title: "Texas") { - edges { - node { - Title - Content - Date - Author { - ID - } - Comments { - edges { - node { - Comment - } - } - } - Files(limit: 2) { - edges { - node { - ID - } - } - } - } - } - } -} -``` - -Notice that we can only query the `ID` field of `Files` and `Author`, our new related fields. -This is because the types are implicitly created by the configuration, but only to the point that -they exist in the schema. They won't eagerly add fields. That's still up to you. By default, you'll only -get the `ID` field, as configured in `SilverStripe\GraphQL\Scaffolding\Scaffolders\DataObjectScaffolder.default_fields`. - -Let's add some more fields. - -**Via YAML*: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - ## ... - MyProject\Comment: - ## ... - SilverStripe\Security\Member: - fields: [FirstName, Surname, Name, Email] - SilverStripe\Assets\File: - fields: [Filename, URL] -``` - -**... Or with code** -```php -$scaffolder - ->type(Post::class) - //... - ->type(Member::class) - ->addFields(['FirstName', 'Surname', 'Name', 'Email']) - ->end() - ->type(File::class) - ->addFields(['Filename', 'URL']) - ->end(); -``` - -Notice that we can freely use the custom getter `Name` on the `Member` record. Fields and `$db` are not one-to-one. - -Nested queries can be configured just like operations. - -**Via YAML*: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - ## ... - nestedQueries: - Comments: - args: - OnlyToday: Boolean - resolver: MyProject\CommentResolver - ##... -``` - -**... Or with code** -```php -$scaffolder - ->type(Post::class) - ->nestedQuery('Comments') - ->addArgs([ - 'OnlyToday' => 'Boolean' - ]) - ->setResolver(function($object, array $args, $context, ResolveInfo $info) { - if (!singleton(Comment::class)->canView($context['currentUser'])) { - throw new \Exception('Cannot view Comment'); - } - $comments = $object->Comments(); - if (isset($args['OnlyToday']) && $args['OnlyToday']) { - $comments = $comments->where('DATE(Created) = DATE(NOW())'); - } - - return $comments; - }) - ->end() - //... - //... -``` - - -**GraphQL** - -```graphql -query { - readPosts(Title: "Sydney") { - edges { - node { - Title - Content - Date - Author { - Name - Email - } - Comments(OnlyToday: true) { - edges { - node { - Comment - } - } - } - Files(limit: 2) { - edges { - node { - Filename - URL - } - } - } - } - } - } -} -``` - -#### Adding arbitrary queries and mutations - -Not every operation maps to simple CRUD. For this, you can define custom queries and mutations -in your schema, so long as they map to an existing type. - -**Via YAML** -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - ##... - mutations: - updatePostTitle: - type: MyProject\Post - args: - ID: ID! - NewTitle: String! - resolver: MyProject\UpdatePostResolver - queries: - latestPost: - type: MyProject\Post - paginate: false - resolver: MyProject\LatestPostResolver -``` - -**... Or with code**: -```php -$scaffolder - ->type(Post::class) - //... - ->mutation('updatePostTitle', Post::class) - ->addArgs([ - 'ID' => 'ID!', - 'NewTitle' => 'String!' - ]) - ->setResolver(function ($object, array $args, $context, ResolveInfo $info) { - $post = Post::get()->byID($args['ID']); - if ($post->canEdit($context['currentUser'])) { - $post->Title = $args['NewTitle']; - $post->write(); - } - - return $post; - }) - ->end() - ->query('latestPost', Post::class) - ->setUsePagination(false) - ->setResolver(function ($object, array $args, $context, ResolveInfo $info) { - if (singleton(Post::class)->canView($context['currentUser'])) { - return Post::get()->sort('Date', 'DESC')->first(); - } - }) - ->end() -``` - -**GraphQL** -```graphql -mutation updatePostTitle($ID: 123, $NewTitle: 'Foo') { - Title -} -``` - -```graphql -query latestPost { - Title -} -``` - -Alternatively, if you want to customise a nested query, you can specify `QueryScaffolder` subclass, which -will allow you to write your own resolver and build your own set of arguments. This is particularly -useful if your nested query does not return a `DataList`, from which a dataobject class can be -inferred. - -```php -class MyCustomListQueryScaffolder extends ListQueryScaffolder -{ - public function resolve ($object, array $args, $context, ResolveInfo $info) - { - // .. custom query code - } - - protected function createArgs(Manager $manager) - { - return [ - 'SpecialArg' => [ - 'type' => $manager->getType('SpecialInputType') - ] - ]; - } -} -``` -**Via YAML**: -```yaml -... - nestedQueries: - MyCustomList: My\Project\Scaffolders\MyCustomListQueryScaffolder -``` - - -**... Or with code**: -```php - $scaffolder->type(MyObject::class) - ->nestedQuery( - 'MyNestedField', // the name of the field on the parent object - new MyCustomListQueryScaffolder( - 'customOperation', // The name of the operation. Must be unique. - 'MyCustomType' // The type the query will return. Make sure it's been registered. - ) - ); -``` -#### Dealing with inheritance - -Adding any given type will implicitly add all of its ancestors, all the way back to `DataObject`. -Any fields you've listed on a descendant that are available on those ancestors will be exposed on the ancestors -as well. For CRUD operations, each ancestor gets its own set of operations and input types. - -When reading types that have exposed descendants (e.g. reading Page, when RedirectorPage is also exposed), -the return type is a *union* of the base type and all exposed descendants. This union type takes on the name -`{BaseType}WithDescendants`. - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - ##... - SilverStripe\CMS\Model\RedirectorPage: - fields: [ID, ExternalURL, Content] - Page: - fields: [MyCustomField] - operations: - read: true - create: true -``` - -**... Or with code**: -```php -$scaffolder - ->type('SilverStripe\CMS\Model\RedirectorPage') - ->addFields(['ID', 'ExternalURL', 'Content']) - ->end() - ->type('Page') - ->addFields(['MyCustomField']) - ->operation(SchemaScaffolder::READ) - ->setName('readRedirectors') - ->end() - ->operation(SchemaScaffolder::CREATE) - ->setName('createRedirector') - ->end() - ->end(); -``` - -We now have the following added to our schema: - -```graphql -type RedirectorPage { - ID: Int - ExternalURL: String - Content: String - MyCustomField: String -} - -type Page { - ID: Int - Content: String - MyCustomField: String -} - -type SiteTree { - ID: Int - Content: String -} - -type PageWithDescendants { - Page | RedirectorPage -} - -type SiteTreeWithDescendants { - SiteTree | Page | RedirectorPage -} - -input PageCreateInputType { - MyCustomField: String - Content: String - # all other fields from Page and SiteTree -} - -query readPages { - PageWithDescendants -} - -mutation createPage { - PageCreateInputType -} -``` - -By default, types are created for the full inheritance tree. -In certain situations, you may also want to create operations for all these ancestors and descendants. -For example, `createPage` only allows you to create an object of type `Page`. -In order to define a `createRedirectorPage` equivalent to create an object of type `RedirectorPage`, -you need to opt in via the `cloneable` configuration setting: - - -**Via YAML**: -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - scaffolding: - types: - MyProject\Post: - ##... - SilverStripe\CMS\Model\RedirectorPage: - fields: [ID, ExternalURL, Content] - Page: - fields: [MyCustomField] - operations: - read: true - create: - cloneable: true -``` - -**... Or with code**: -```php -$scaffolder - ->type('SilverStripe\CMS\Model\RedirectorPage') - ->addFields(['ID', 'ExternalURL', 'Content']) - ->end() - ->type('Page') - ->addFields(['MyCustomField']) - ->operation(SchemaScaffolder::READ) - ->setName('readRedirectors') - ->setCloneable(true) - ->end() - ->operation(SchemaScaffolder::CREATE) - ->setName('createRedirector') - ->end() - ->end(); -``` - -This will add the following fields to your schema: - -```graphql -input RedirectorPageCreateInputType { - ExternalURL: String - RedirectionType: String - MyCustomField: String - Content: String - # all other fields from RedirectorPage, Page and SiteTree -} - -input SiteTreeCreateInputType { - # all fields from SiteTree -} - -mutation createRedirector { - RedirectorPageCreateInputType -} - -mutation createSiteTree { - SiteTreeCreateInputType -} -``` - -#### Querying types that have descendants - -Keep in mind that when querying a base class that has descendant types exposed (e.g. querying `Page` -when `RedirectorPage` is also exposed), a union is returned, and you will need to resolve it -with the `...on {type}` GraphQL syntax. - -```graphql -query readPages { - readPages { - edges { - node { - __typename - ...on Page { - ID - } - ...on RedirectorPage { - ID - RedirectionType - } - } - } - } -} -``` - -#### Customising the names of types and operations - -By default, the scaffolder will generate a type name for you based on the dataobject's `$table_name` -setting and the output of its `singular_name()` method. Often times, these are poor proxies for -a canonical name, e.g. `readMy_Really_Long_NameSpaced_BlogPosts`. To customise the type name, simply map a name to it in the `SilverStripe\GraphQL\Scaffolding\Schema` -class. - -```yaml -SilverStripe\GraphQL\Manager: - schemas: - default: - typeNames: - My\Really\Long\Namespaced\BlogPost: Blog -``` - -Note that `typeNames` is the mapping of dataobjects to the graphql types, whereas the `types` -config is the list of type creators for non-scaffolded types, backed by php classes. -`typeNames` is also used (and required by) scaffolding, whether via PHP or YML. - -Operations names are expressed using the type name of the dataobject they serve. That type name -may be customised or computed automatically, as described above. For a deeper level of control, you can -name the operation using the `name` property. - -```yaml -... - operations: - read: - name: currentBlogs -``` - -The name of the operation has been fully customised to `currentBlogs`, returning the type `Blog`. - -```yaml -... - operations: - read: true -``` - -Otherwise, the name of the read operation, given the `Schema` config above, will be `readBlogs`. - - -### Versioned content - -If the `silverstripe/versioned` module is installed in your project (as it is with a default CMS install), -a series of schema updates specific to versioning will be provided to all types that use the `Versioned` extension. -These include: - -#### Version-specific operations -* `publish(ID: Int!)` -* `unpublish(ID: Int!)` -* `copyToStage(Input: CopyToStageInputType { ID: Int, FromStage: String, ToStage: String, Version: Int })` - -```yaml -... - operations: - publish: true - unpublish: true - copyToStage: true -``` - -#### Version-specific arguments -Types that use the `Versioned` extension will also have their `read` operations extended to accept -a `Versioning` parameter which allows you define very specifically what versioning filters to apply -to the result set. - -**The "Versioning" input** - - - - - - - - - - - - - - - - - -
FieldDescription
Mode -

One of:

-
    -
  • ARCHIVE (Read from a specific date in the archive)
  • -
  • LIVE (Read from the live stage)
  • -
  • DRAFT (Read from the draft stage)
  • -
  • LATEST (Read the latest version from each record)
  • -
  • STATUS (Filter records by their status. Must supply a `Status` parameter)
  • -
-
ArchiveDateThe date, in YYYY-MM-DD format to use when in ARCHIVE mode.
[Status] -

A list of statuses that records must match. Options:

-
    -
  • PUBLISHED (Include published records)
  • -
  • DRAFT (Include draft records)
  • -
  • MODIFIED (Include records that have draft changes)
  • -
  • ARCHIVED (Include records that have been deleted from stage)
  • -
-
- - -**GraphQL** -``` -readBlogPosts(Versioning: { - Mode: "DRAFT" -}) { - Title -} - -readBlogPosts(Versioning: { - Mode: "ARCHIVE", - ArchiveDate: "2016-11-08" -}) { - Title -} - -readBlogPosts(Versioning: { - Mode: "STATUS", - Status: [DRAFT, MODIFIED] -}) { - Title -} -``` - -`readOne` operations also allow a `Version` parameter, which allows you to read a specific version. - -**GraphQL** -``` -readBlogPosts(Version: 5, ID: 100) { - Title -} -``` - -#### Version-specific fields - -Types that use the `Versioned` extension will also benefit from two new fields: -* `Version:Int` The version number of the record -* `Versions:[Version]` A paginated list of all the previous versions of this record. The type -returned by this query contains a few additional fields: `Author:Member`, `Publisher:Member`, and `Published:Boolean`. - -**GraphQL** -``` -readBlogPosts { - Title - Version - Versions(limit: 5) { - Title - Author { - FirstName - } - Publisher { - Email - } - Published - } -} -``` - -### Define interfaces - -TODO - -### Define input types - -TODO - -## Extending - -Many of the scaffolding classes use the `Extensible` trait, allowing you to influence the scaffolding process -with custom needs. - -### Adding/removing fields from thirdparty code -Suppose you have a module that adds new fields to dataobjects that use your extension. You can write -and extension for `DataObjectScaffolder` to update the scaffolding before it is sent to the `Manager`. - -```php -class MyDataObjectScaffolderExtension extends Extension -{ - - public function onBeforeAddToManager(Manager $manager) - { - if ($this->owner->getDataObjectInstance()->hasExtension(MyExtension::class)) { - $this->owner->addField('MyField'); - } - } -} -``` - -### Updating the core operations -The basic `CRUD` operations that come with the module are all extensible with`updateArgs` and `augmentMutation` (or `updateList` for read operations). - -```php -class MyCreateExtension extends Extension -{ - - public function updateArgs(&$args, Manager $manager) - { - $args['SendEmail'] = ['type' => Type::bool()]; - } - - - public function augmentMutation($object, array $args, $context, ResolveInfo $info) - { - if ($args['SendEmail']) { - MyService::inst()->sendEmail(); - $object->EmailSent = true; - } - } -} -```` - -### Adding new operations -If you have a custom operation, in addition to `read`, `update`, `delete`, etc., that you want available -to some or all types, you can write one and register it with the scaffolder. Let's suppose we have -an ecommerce module that wants to offer an `addToCart` mutation to any dataobject that implements -the `Product` interface. - -```php -class AddToCartOperation extends MutationScaffolder -{ - - public function __construct($dataObjectClass) - { - parent::__construct($this->createOperationName(), $dataObjectClass); - if (!$this->getDataObjectInstance() instanceof ProductInterace) { - throw new InvalidArgumentException( - 'addToCart operation is only for implementors of ProductInterface' - ); - } - $this->setResolver(function($object, array $args, $context, ResolveInfo $info) { - $record = DataObject::get_by_id($this->dataObjectClass, $args['ID']); - if (!$record) { - throw new Exception('ID not found'); - } - - $record->addToCart(); - - return $record; - }); - } - - - protected function createArgs(Manager $manager) - { - return [ - 'ID' => ['type' => Type::nonNull(Type::id())] - ]; - } - - - protected function createOperationName() - { - return 'add' . ucfirst($this->typeName()) . 'ToCart'; - } - -} -``` - -Now, register it as an opt-in operation. - -```yaml -SilverStripe\GraphQL\Scaffolding\Scaffolders\OperationScaffolder: - operations: - addToCart: My\Project\AddToCartOperation -``` - -### Changing behaviour with Middleware - -You can influence behaviour based on query data or passed-in parameters. -This can be useful for features which would be too cumbersome on a resolver level, -for example logging and caching. - -Note this is separate from [HTTP Middleware](https://docs.silverstripe.org/en/developer_guides/controllers/middlewares/) -which are more suited to influence behaviour based on HTTP request introspection -(for example, by enforcing authentication for any request matching `/graphql`). - -Example middleware to log all mutations (but not queries): - -```php -log('Executed mutation: ' . $query); - } - - return $next($schema, $query, $context, $params); - } - - protected function log($str) - { - // ... - } -} -``` - -```yml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Manager: - properties: - Middlewares: - MyMutationLoggingMiddleware: '%$MyMutationLoggingMiddleware' -``` - -If you want to use middleware to cache responses, -here's a more [comprehensive caching middleware example](https://gist.github.com/tractorcow/fe6571d2d00340a311ca31a677a05a29). - -## Testing/debugging queries and mutations - -An in-browser IDE for the GraphQL server is available via the [silverstripe-graphql-devtools](https://github.com/silverstripe/silverstripe-graphql-devtools) module. - -As an alternative, a [desktop version](https://github.com/skevy/graphiql-app) of this application is also available. (OSX only) - -## Authentication - -Some SilverStripe resources have permission requirements to perform CRUD operations -on, for example the `Member` object in the previous examples. - -If you are logged into the CMS and performing a request from the same session then -the same Member session is used to authenticate GraphQL requests, however if you -are performing requests from an anonymous/external application you may need to -authenticate before you can complete a request. - -Please note that when implementing GraphQL resources it is the developer's -responsibility to ensure that permission checks are implemented wherever -resources are accessed. - -### Default authentication - -The `MemberAuthenticator` class is configured as the default option for authentication, -and will attempt to use the current CMS `Member` session for authentication context. - -**If you are using the default session-based authentication, please be sure that you have -the [CSRF Middleware](#csrf-tokens-required-for-mutations) enabled. (It is by default).** - -### HTTP basic authentication - -Silverstripe has built in support for [HTTP basic authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). -There is a `BasicAuthAuthenticator` which is configured for GraphQL by default, but -will only activate when required. It is kept separate from the SilverStripe CMS -authenticator because GraphQL needs to use the successfully authenticated member -for CMS permission filtering, whereas the global `BasicAuth` does not log the -member in or use it for model security. - -When using HTTP basic authentication, you can feel free to remove the [CSRF Middleware](#csrf-tokens-required-for-mutations), -as it just adds unnecessary overhead to the request. - -#### In GraphiQL - -If you want to add basic authentication support to your GraphQL requests you can -do so by adding a custom `Authorization` HTTP header to your GraphiQL requests. - -If you are using the [GraphiQL macOS app](https://github.com/skevy/graphiql-app) -this can be done from "Edit HTTP Headers". The `/dev/graphiql` implementation -does not support custom HTTP headers at this point. - -Your custom header should follow the following format: - -``` -# Key: Value -Authorization: Basic aGVsbG86d29ybGQ= -``` - -`Basic` is followed by a [base64 encoded](https://en.wikipedia.org/wiki/Base64) -combination of your username, colon and password. The above example is `hello:world`. - -**Note:** Authentication credentials are transferred in plain text when using HTTP -basic authentication. We strongly recommend using TLS for non-development use. - -Example: - -```shell -php -r 'echo base64_encode("hello:world");' -# aGVsbG86d29ybGQ= -``` - -### Defining your own authenticators - -You will need to define the class under `SilverStripe\GraphQL\Auth\Handlers.authenticators`. -You can optionally provide a `priority` number if you want to control which -Authenticator is used when multiple are defined (higher priority returns first). - -Authenticator classes will need to implement the `SilverStripe\GraphQL\Auth\AuthenticatorInterface` -interface, which requires you to define an `authenticate` method to return a Member, or false, and -and `isApplicable` method which tells the `Handler` whether or not this authentication method -is applicable in the current request context (provided as an argument). - -Here's an example for implementing HTTP basic authentication (note that basic auth is enabled by default anyway): - -```yaml -SilverStripe\GraphQL\Auth\Handler: - authenticators: - - class: SilverStripe\GraphQL\Auth\BasicAuthAuthenticator - priority: 10 -``` -## CSRF tokens (required for mutations) - -Even if your graphql endpoints are behind authentication, it is still possible for unauthorised -users to access that endpoint through a [CSRF exploitation](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)). This involves -forcing an already authenticated user to access an HTTP resource unknowingly (e.g. through a fake image), thereby hijacking the user's -session. - -In the absence of a token-based authentication system, like OAuth, the best countermeasure to this -is the use of a CSRF token for any requests that destroy or mutate data. - -By default, this module comes with a `CSRFMiddleware` implementation that forces all mutations to check -for the presence of a CSRF token in the request. That token must be applied to a header named` X-CSRF-TOKEN`. - -In SilverStripe, CSRF tokens are most commonly stored in the session as `SecurityID`, or accessed through -the `SecurityToken` API, using `SecurityToken::inst()->getValue()`. - -Queries do not require CSRF tokens. - -### Disabling CSRF protection (for token-based authentication only) - -If you are using HTTP basic authentication or a token-based system like OAuth or [JWT](https://github.com/Firesphere/silverstripe-graphql-jwt), -you will want to remove the CSRF protection, as it just adds unnecessary overhead. You can do this by setting -the middleware to `false`. - -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Manager.default: - properties: - Middlewares: - CSRFMiddleware: false -``` - -## Cross-Origin Resource Sharing (CORS) - -By default [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS) is disabled in the GraphQL Server. This can be easily enabled via YAML: - -```yaml -SilverStripe\GraphQL\Controller: - cors: - Enabled: true -``` - -Once you have enabled CORS you can then control four new headers in the HTTP Response. - -1. **Access-Control-Allow-Origin.** - - This lets you define which domains are allowed to access your GraphQL API. There are - 4 options: - - * **Blank**: - Deny all domains (except localhost) - - ```yaml - Allow-Origin: - ``` - - * **'\*'**: - Allow requests from all domains. - - ```yaml - Allow-Origin: '*' - ``` - - * **Single Domain**: - - Allow requests from one specific external domain. - - ```yaml - Allow-Origin: 'my.domain.com' - ``` - - * **Multiple Domains**: - - Allow requests from multiple specified external domains. - - ```yaml - Allow-Origin: - - 'my.domain.com' - - 'your.domain.org' - ``` - -2. **Access-Control-Allow-Headers.** - - Access-Control-Allow-Headers is part of a CORS 'pre-flight' request to identify - what headers a CORS request may include. By default, the GraphQL server enables the - `Authorization` and `Content-Type` headers. You can add extra allowed headers that - your GraphQL may need by adding them here. For example: - - ```yaml - Allow-Headers: 'Authorization, Content-Type, Content-Language' - ``` - - **Note** If you add extra headers to your GraphQL server, you will need to write a - custom resolver function to handle the response. - -3. **Access-Control-Allow-Methods.** - - This defines the HTTP request methods that the GraphQL server will handle. By - default this is set to `GET, PUT, OPTIONS`. Again, if you need to support extra - methods you will need to write a custom resolver to handle this. For example: - - ```yaml - Allow-Methods: 'GET, PUT, DELETE, OPTIONS' - ``` - -4. **Access-Control-Max-Age.** - - Sets the maximum cache age (in seconds) for the CORS pre-flight response. When - the client makes a successful OPTIONS request, it will cache the response - headers for this specified duration. If the time expires or the required - headers are different for a new CORS request, the client will send a new OPTIONS - pre-flight request to ensure it still has authorisation to make the request. - This is set to 86400 seconds (24 hours) by default but can be changed in YAML as - in this example: - - ```yaml - Max-Age: 600 - ``` - -5. **Access-Control-Allow-Credentials.** - - When a request's credentials mode (Request.credentials) is "include", browsers - will only expose the response to frontend JavaScript code if the - Access-Control-Allow-Credentials value is true. - - The Access-Control-Allow-Credentials header works in conjunction with the - XMLHttpRequest.withCredentials property or with the credentials option in the - Request() constructor of the Fetch API. For a CORS request with credentials, - in order for browsers to expose the response to frontend JavaScript code, both - the server (using the Access-Control-Allow-Credentials header) and the client - (by setting the credentials mode for the XHR, Fetch, or Ajax request) must - indicate that they’re opting in to including credentials. - - This is set to empty by default but can be changed in YAML as in this example: - - ```yaml - Allow-Credentials: 'true' - ``` - -### Apply a CORS config to all GraphQL endpoints - -```yaml -## CORS Config -SilverStripe\GraphQL\Controller: - cors: - Enabled: true - Allow-Origin: 'silverstripe.org' - Allow-Headers: 'Authorization, Content-Type' - Allow-Methods: 'GET, POST, OPTIONS' - Allow-Credentials: 'true' - Max-Age: 600 # 600 seconds = 10 minutes. -``` - -### Apply a CORS config to a single GraphQL endpoint - -```yaml -## CORS Config -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Controller.default - properties: - corsConfig: - Enabled: false -``` - - -## Persisting queries - -A common pattern in GraphQL APIs is to store queries on the server by an identifier. This helps save -on bandwidth, as the client need not put a fully expressed query in the request body, but rather a -simple identifier. Also, it allows you to whitelist only specific query IDs, and block all other ad-hoc, -potentially malicious queries, which adds an extra layer of security to your API, particularly if it's public. - -To implement persisted queries, you need an implementation of the -`SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider` interface. By default, three are provided, -which cover most use cases: - -* `FileProvider`: Store your queries in a flat JSON file on the local filesystem. -* `HTTPProvider`: Store your queries on a remote server and reference a JSON file by URL. -* `JSONStringProvider`: Store your queries as hardcoded JSON - -### Configuring query mapping providers - -All of these implementations can be configured through `Injector`. Note that each schema gets its -own set of persisted queries. In these examples, we're using the `default`schema. - -#### FileProvider - -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\FileProvider: - properties: - schemaMapping: - default: '/var/www/project/query-mapping.json' -``` - - -A flat file in the path `/var/www/project/query-mapping.json` should contain something like: - -```json -{"someUniqueID":"query{validateToken{Valid Message Code}}"} -``` -#### HTTPProvider - -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider: - properties: - schemaMapping: - default: 'http://example.com/myqueries.json' -``` - -A flat file at the URL `http://example.com/myqueries.json` should contain something like: - -```json -{"someUniqueID":"query{readMembers{Name+Email}}"} -``` - -#### JSONStringProvider - -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: - class: SilverStripe\GraphQL\PersistedQuery\HTTPProvider: - properties: - schemaMapping: - default: '{"myMutation":"mutation{createComment($comment:String!){Comment}}"}' -``` - -The queries are hardcoded into the configuration. - -### Requesting queries by identifier - -To access a persisted query, simply pass an `id` parameter in the request in lieu of `query`. - -`GET http://example.com/graphql?id=someID` - -Note that if you pass `query` along with `id`, an exception will be thrown. - -## Schema introspection -Some GraphQL clients such as [Apollo](http://apollographql.com) require some level of introspection -into the schema. While introspection is [part of the GraphQL spec](http://graphql.org/learn/introspection/), -this module provides a limited API for fetching it via non-graphql endpoints. By default, the `graphql/` -controller provides a `types` action that will return the type schema (serialised as JSON) dynamically. - -*GET http://example.com/graphql/types* -```js -{ - "data":{ - "__schema":{ - "types":[ - { - "kind":"OBJECT", - "name":"Query", - "possibleTypes":null - } - // etc ... - ] - } - } - -``` - -As your schema grows, introspecting it dynamically may have a performance hit. Alternatively, -if you have the `silverstripe/assets` module installed (as it is in the default SilverStripe installation), -GraphQL can cache your schema as a flat file in the `assets/` directory. To enable this, simply -set the `cache_types_in_filesystem` setting to `true` on `SilverStripe\GraphQL\Controller`. Once enabled, -a `types.graphql` file will be written to your `assets/` directory on `flush`. - -When `cache_types_in_filesystem` is enabled, it is recommended that you remove the extension that -provides the dynamic introspection endpoint. - -```php -use SilverStripe\GraphQL\Controller; -use SilverStripe\GraphQL\Extensions\IntrospectionProvider; - -Controller::remove_extension(IntrospectionProvider::class); -``` - -## Setting up a new GraphQL schema - -In addition to the default `/graphql` endpoint provided by this module by default, -along with the `admin/graphql` endpoint provided by the CMS modules (if they're installed), -you may want to set up another GraphQL server running on the same installation of SilverStripe. - -First, set up a new `Manager` implementation for your custom server. - -*app/_config/graphql.yml* -```yaml -SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Manager.myApp: - class: SilverStripe\GraphQL\Manager - constructor: - schemaKey: mySchema -``` - -The `schemaKey` setting is a bit of meta-configuration used to tell the Manager where to -look in the `SilverStripe\GraphQL\Manager.schemas` config for the schema information. - -Now let's setup a new controller to handle the requests. It will use our custom Manager instance. - -```yaml -SilverStripe\Core\Injector\Injector: - # ... - SilverStripe\GraphQL\Controller.myApp: - class: SilverStripe\GraphQL\Controller - constructor: - manager: %$SilverStripe\GraphQL\Manager.myApp - -``` - -Lastly, we'll need to route the controller. - -```yaml -SilverStripe\Control\Director: - rules: - 'my-graphql': '%$SilverStripe\GraphQL\Controller.myApp' -``` - -Now, configure your schema. - -```yaml -SilverStripe\GraphQL\Manager: - schemas: - myApp: - # Your config will go here.. -``` - -## Strict HTTP Method Checking - -According to GraphQL best practices, mutations should be done over `POST`, while queries have the option -to use either `GET` or `POST`. By default, this module enforces the `POST` request method for all mutations. - -To disable that requirement, you can remove the `HTTPMethodMiddleware` from your `Manager` implementation. - -```yaml - SilverStripe\GraphQL\Manager: - properties: - Middlewares: - HTTPMethodMiddleware: false -``` - -## TODO - - * Permission checks - * Input/constraint validation on mutations (with third-party validator) - * CSRF protection (or token-based auth) - * Create Enum GraphQL types from DBEnum - * Date casting - * Schema serialisation/caching (performance) - * Scaffolding description, deprecation attributes - * Remove operations/fields via YAML - * Refine CRUD operations +See [doc.silverstripe.org](https://doc.silverstripe.org/en/4/developer_guides/graphql/). diff --git a/_config/assets.yml b/_config/assets.yml index fe9b0ca68..2bcc9b9a1 100644 --- a/_config/assets.yml +++ b/_config/assets.yml @@ -3,20 +3,20 @@ Name: graphqlassets Only: moduleexists: 'silverstripe/assets' --- -## Assign the type to DBFile as a dependency +SilverStripe\GraphQL\Schema\Schema: + schemas: + '*': + types: + DBFile: + fields: + filename: String + hash: String + variant: String + url: String + SilverStripe\Assets\Storage\DBFile: - graphql_type: - Filename: String - Hash: String - Variant: String - URL: String - Width: Int - Height: Int + graphql_type: DBFile -## Register the types to the manager -SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder: - fixed_types: - - SilverStripe\Assets\Storage\DBFile SilverStripe\Assets\File: allowed_extensions: - graphql diff --git a/_config/config.yml b/_config/config.yml index 0db023fed..9b7f3cf2f 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -3,74 +3,39 @@ Name: graphqlconfig --- # Define the type parsers SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\Scaffolding\Interfaces\TypeParserInterface.string: - class: SilverStripe\GraphQL\Scaffolding\Util\StringTypeParser - SilverStripe\GraphQL\Scaffolding\Interfaces\TypeParserInterface.array: - class: SilverStripe\GraphQL\Scaffolding\Util\ArrayTypeParser + + SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface: + class: SilverStripe\GraphQL\QueryHandler\QueryHandler + SilverStripe\GraphQL\Middleware\QueryMiddleware.csrf: class: SilverStripe\GraphQL\Middleware\CSRFMiddleware + SilverStripe\GraphQL\Middleware\QueryMiddleware.httpMethod: class: SilverStripe\GraphQL\Middleware\HTTPMethodMiddleware + SilverStripe\GraphQL\PersistedQuery\PersistedQueryMappingProvider: class: SilverStripe\GraphQL\PersistedQuery\JSONStringProvider + SilverStripe\GraphQL\PersistedQuery\HTTPProvider: constructor: httpClient: '%$SilverStripe\GraphQL\PersistedQuery\GuzzleHTTPClient' - SilverStripe\GraphQL\Permission\QueryPermissionChecker.default: - class: SilverStripe\GraphQL\Permission\CanViewPermissionChecker - SilverStripe\GraphQL\Scaffolding\Scaffolders\ItemQueryScaffolder: - properties: - permissionChecker: '%$SilverStripe\GraphQL\Permission\QueryPermissionChecker.default' - SilverStripe\GraphQL\Scaffolding\Scaffolders\ListQueryScaffolder: - properties: - permissionChecker: '%$SilverStripe\GraphQL\Permission\QueryPermissionChecker.default' - SilverStripe\GraphQL\Pagination\Connection: - properties: - permissionChecker: '%$SilverStripe\GraphQL\Permission\QueryPermissionChecker.default' - # Set up a default endpoint that can be activated with a Director rule - SilverStripe\GraphQL\Manager.default: - class: SilverStripe\GraphQL\Manager - constructor: - schemaKey: default - properties: - Middlewares: - CSRFMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.csrf' - HTTPMethodMiddleware: '%$SilverStripe\GraphQL\Middleware\QueryMiddleware.httpMethod' - SilverStripe\GraphQL\Controller.default: - class: SilverStripe\GraphQL\Controller - constructor: - manager: '%$SilverStripe\GraphQL\Manager.default' + SilverStripe\GraphQL\Schema\Interfaces\SchemaStorageCreator: + class: 'SilverStripe\GraphQL\Schema\Storage\CodeGenerationStoreCreator' -# Assign each DBField subclass with an associated internal type -SilverStripe\ORM\FieldType\DBField: - extensions: - - SilverStripe\GraphQL\Scaffolding\Extensions\TypeCreatorExtension - graphql_type: String -SilverStripe\ORM\FieldType\DBInt: - graphql_type: Int -SilverStripe\ORM\FieldType\DBBoolean: - graphql_type: Boolean -SilverStripe\ORM\FieldType\DBFloat: - graphql_type: Float -SilverStripe\ORM\FieldType\DBPrimaryKey: - graphql_type: ID -SilverStripe\ORM\FieldType\DBForeignKey: - graphql_type: ID + Psr\SimpleCache\CacheInterface.FileSchemaStore: + factory: SilverStripe\Core\Cache\CacheFactory + constructor: + namespace: "GraphQLSchema" -# Register the CRUD -SilverStripe\GraphQL\Scaffolding\Scaffolders\OperationScaffolder: - operations: - create: SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Create - read: SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Read - readOne: SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\ReadOne - update: SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Update - delete: SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Delete + SilverStripe\GraphQL\Schema\Storage\CodeGenerationStore: + properties: + rootDir: '`BASE_PATH`' SilverStripe\GraphQL\Controller: extensions: - SilverStripe\GraphQL\Extensions\IntrospectionProvider -SilverStripe\GraphQL\Manager: +SilverStripe\GraphQL\Schema\Schema: schemas: [] --- Only: diff --git a/_config/dataobject.yml b/_config/dataobject.yml new file mode 100644 index 000000000..0dd5a3f8a --- /dev/null +++ b/_config/dataobject.yml @@ -0,0 +1,18 @@ +--- +Name: silverstripe-graphql-dataobject +--- +SilverStripe\GraphQL\Schema\DataObject\DataObjectModel: + operations: + read: 'SilverStripe\GraphQL\Schema\DataObject\ReadCreator' + readOne: 'SilverStripe\GraphQL\Schema\DataObject\ReadOneCreator' + delete: 'SilverStripe\GraphQL\Schema\DataObject\DeleteCreator' + update: 'SilverStripe\GraphQL\Schema\DataObject\UpdateCreator' + create: 'SilverStripe\GraphQL\Schema\DataObject\CreateCreator' +SilverStripe\ORM\DataObject: + graphql_blacklisted_fields: + ClassName: true + LinkTracking: true + FileTracking: true + extensions: + - SilverStripe\GraphQL\Extensions\DevBuildExtension + diff --git a/_config/dbtypes.yml b/_config/dbtypes.yml new file mode 100644 index 000000000..fe6a2cb9e --- /dev/null +++ b/_config/dbtypes.yml @@ -0,0 +1,18 @@ +--- +Name: graphql-dbtypes +--- +# Assign each DBField subclass with an associated internal type +SilverStripe\ORM\FieldType\DBField: + graphql_type: String +SilverStripe\ORM\FieldType\DBInt: + graphql_type: Int +SilverStripe\ORM\FieldType\DBBoolean: + graphql_type: Boolean +SilverStripe\ORM\FieldType\DBFloat: + graphql_type: Float +SilverStripe\ORM\FieldType\DBDecimal: + graphql_type: Float +SilverStripe\ORM\FieldType\DBPrimaryKey: + graphql_type: ID +SilverStripe\ORM\FieldType\DBForeignKey: + graphql_type: ID diff --git a/_config/default-schema.yml b/_config/default-schema.yml new file mode 100644 index 000000000..d8f7abedf --- /dev/null +++ b/_config/default-schema.yml @@ -0,0 +1,12 @@ +--- +Name: 'graphql-default-schema' +--- +SilverStripe\Core\Injector\Injector: + # Set up a default endpoint that can be activated with a Director rule + SilverStripe\GraphQL\Controller.default: + class: SilverStripe\GraphQL\Controller + constructor: + schema: default + # use a custom handler so it's easy to override/add middlewares in the default schema + handler: '%$SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default' + diff --git a/_config/dev.yml b/_config/dev.yml index 2bc1a9e39..a131db586 100644 --- a/_config/dev.yml +++ b/_config/dev.yml @@ -1,10 +1,13 @@ --- -Name: graphqltest -Before: - - '#sapphiretest' +Name: graphql-dev --- -SilverStripe\Core\Injector\Injector: - SilverStripe\Dev\State\SapphireTestState: - properties: - States: - disabletypecaching: '%$SilverStripe\GraphQL\Dev\State\DisableTypeCacheState' +SilverStripe\Dev\DevelopmentAdmin: + registered_controllers: + graphql: + controller: SilverStripe\GraphQL\Dev\DevelopmentAdmin + links: + build: 'Build/rebuild the GraphQL schema' +SilverStripe\GraphQL\Dev\DevelopmentAdmin: + registered_controllers: + build: + controller: SilverStripe\GraphQL\Dev\Build diff --git a/_config/filters.yml b/_config/filters.yml index e13968b1e..fa56d9abd 100644 --- a/_config/filters.yml +++ b/_config/filters.yml @@ -2,30 +2,17 @@ Name: graphql-filters --- SilverStripe\Core\Injector\Injector: - SilverStripe\GraphQL\QueryFilter\FilterRegistryInterface: - class: SilverStripe\GraphQL\QueryFilter\FieldFilterRegistry + SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\FilterRegistryInterface: + class: SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\FieldFilterRegistry constructor: - contains: '%$SilverStripe\GraphQL\QueryFilter\Filters\ContainsFilter' - eq: '%$SilverStripe\GraphQL\QueryFilter\Filters\EqualToFilter' - gt: '%$SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanFilter' - lt: '%$SilverStripe\GraphQL\QueryFilter\Filters\LessThanFilter' - gte: '%$SilverStripe\GraphQL\QueryFilter\Filters\GreaterThanOrEqualFilter' - lte: '%$SilverStripe\GraphQL\QueryFilter\Filters\LessThanOrEqualFilter' - in: '%$SilverStripe\GraphQL\QueryFilter\Filters\InFilter' - endswith: '%$SilverStripe\GraphQL\QueryFilter\Filters\EndsWithFilter' - startswith: '%$SilverStripe\GraphQL\QueryFilter\Filters\StartsWithFilter' - SilverStripe\GraphQL\QueryFilter\DataObjectQueryFilter: - properties: - filterRegistry: '%$SilverStripe\GraphQL\QueryFilter\FilterRegistryInterface' -SilverStripe\ORM\FieldType\DBField: - graphql_default_filters: [ eq, in ] -SilverStripe\ORM\FieldType\DBInt: - graphql_default_filters: [ gt, lt, gte, lte ] -SilverStripe\ORM\FieldType\DBDate: - graphql_default_filters: [ gt, lt, gte, lte ] -SilverStripe\ORM\FieldType\DBTime: - graphql_default_filters: [ gt, lt, gte, lte ] -SilverStripe\ORM\FieldType\DBFloat: - graphql_default_filters: [ gt, lt, gte, lte ] -SilverStripe\ORM\FieldType\DBString: - graphql_default_filters: [ contains ] + contains: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\ContainsFilter' + eq: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\EqualToFilter' + ne: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\EqualToFilter' + gt: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\GreaterThanFilter' + lt: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\LessThanFilter' + gte: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\GreaterThanOrEqualFilter' + lte: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\LessThanOrEqualFilter' + in: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\InFilter' + endswith: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\EndsWithFilter' + startswith: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\Filters\StartsWithFilter' + diff --git a/_config/middlewares.yml b/_config/middlewares.yml new file mode 100644 index 000000000..5e9a6ce42 --- /dev/null +++ b/_config/middlewares.yml @@ -0,0 +1,30 @@ +--- +Name: 'graphql-middlewares' +--- +SilverStripe\Core\Injector\Injector: + # default implementation + SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface: + class: SilverStripe\GraphQL\QueryHandler\QueryHandler + properties: + Middlewares: + csrf: '%$SilverStripe\GraphQL\Middleware\CSRFMiddleware' + httpMethod: '%$SilverStripe\GraphQL\Middleware\HTTPMethodMiddleware' + + # duplicate implementation do that the default schema has its own instance to + # configure without affecting other schemas + SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface.default: + class: SilverStripe\GraphQL\QueryHandler\QueryHandler + properties: + Middlewares: + csrf: '%$SilverStripe\GraphQL\Middleware\CSRFMiddleware' + httpMethod: '%$SilverStripe\GraphQL\Middleware\HTTPMethodMiddleware' + + Psr\SimpleCache\CacheInterface.graphql: + factory: SilverStripe\Core\Cache\CacheFactory + constructor: + namespace: "graphql" + defaultLifetime: 600 + + SilverStripe\GraphQL\Middleware\QueryCachingMiddleware: + properties: + Cache: '%$Psr\SimpleCache\CacheInterface.graphql' diff --git a/_config/model.yml b/_config/model.yml new file mode 100644 index 000000000..65237f1ef --- /dev/null +++ b/_config/model.yml @@ -0,0 +1,16 @@ +--- +Name: silverstripe-graphql-model +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\GraphQL\Schema\Registry\SchemaModelCreatorRegistry: + constructor: + dataobject: '%$SilverStripe\GraphQL\Schema\DataObject\ModelCreator' +SilverStripe\GraphQL\Schema\Schema: + schemas: + '*': + modelConfig: + DataObject: + type_formatter: [ 'SilverStripe\Core\ClassInfo', 'shortName' ] + type_prefix: '' + type_mapping: [] + nested_query_plugins: [] diff --git a/_config/plugins.yml b/_config/plugins.yml new file mode 100644 index 000000000..7998530ba --- /dev/null +++ b/_config/plugins.yml @@ -0,0 +1,14 @@ +--- +Name: graphql-plugins +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\GraphQL\Schema\Registry\PluginRegistry: + constructor: + paginator: '%$SilverStripe\GraphQL\Schema\Plugin\PaginationPlugin' + dataobjectPaginator: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\Paginator' + dataobjectQueryFilter: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QueryFilter\QueryFilter' + dataobjectQuerySort: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\QuerySort' + dataobjectInheritance: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\Inheritance' + canViewPermission: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\CanViewPermission' + firstResult: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\FirstResult' + inheritedPlugins: '%$SilverStripe\GraphQL\Schema\DataObject\Plugin\InheritedPlugins' diff --git a/composer.json b/composer.json index 8797b6755..48ef8179a 100755 --- a/composer.json +++ b/composer.json @@ -4,9 +4,9 @@ "type": "silverstripe-vendormodule", "license": "BSD-3-Clause", "require": { - "silverstripe/framework": "^5", + "silverstripe/framework": "^4", "silverstripe/vendor-plugin": "^1.0", - "webonyx/graphql-php": "~0.12.6" + "webonyx/graphql-php": "^14.0" }, "require-dev": { "phpunit/phpunit": "^8 || ^9", diff --git a/examples/README.md b/examples/README.md index 65adc3c32..97db85ba1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,4 +1,3 @@ -This folder contains a "pseudo-module" for SilverStripe -with some runnable code examples. In order to try them out yourself, -move the `examples` folder to the top level of your SilverStripe -project and remove the `_manifest_exclude` file. +## COMING SOON ## + +The v4 release of this module does not yet have any example code. diff --git a/examples/_config/config.yml b/examples/_config/config.yml deleted file mode 100644 index 7d2212293..000000000 --- a/examples/_config/config.yml +++ /dev/null @@ -1,59 +0,0 @@ -SilverStripe\GraphQL\Manager: - schemas: - default: - types: - member: 'MyProject\GraphQL\MemberTypeCreator' - group: 'MyProject\GraphQL\GroupTypeCreator' - queries: - readMembers: 'MyProject\GraphQL\ReadMembersQueryCreator' - paginatedReadMembers: 'MyProject\GraphQL\PaginatedReadMembersQueryCreator' - mutations: - createMember: 'MyProject\GraphQL\CreateMemberMutationCreator' - scaffolding_providers: - - MyProject\Post - scaffolding: - types: - MyProject\Post: - fields: [ID, Title, Content, Author, Date] - nestedQueries: - Comments: - args: - Today: Boolean - sortableFields: [Author] - resolver: MyProject\CommentsResolver - Files: true - operations: - create: true - read: - args: - StartingWith: String - resolver: MyProject\ReadResolver - SilverStripe\Security\Member: - fields: [Name, FirstName, Surname, Email] - SilverStripe\Assets\File: - fieldsExcept: [Content] - fields: [File] - MyProject\Comment: - fields: [Comment, Author] - SilverStripe\CMS\Model\RedirectorPage: - fields: [ExternalURL, Content] - operations: - read: true - create: true - mutations: - updatePostTitle: - type: MyProject\Post - args: - ID: ID! - NewTitle: String! - resolver: MyProject\UpdatePostResolver - queries: - latestPost: - type: MyProject\Post - paginate: false - resolver: MyProject\LatestPostResolver - - # Enforce HTTP basic authentication for GraphQL requests - authenticators: - class: SilverStripe\GraphQL\Auth\BasicAuthAuthenticator - priority: 10 diff --git a/examples/code/Comment.php b/examples/code/Comment.php deleted file mode 100644 index bd88b08b2..000000000 --- a/examples/code/Comment.php +++ /dev/null @@ -1,34 +0,0 @@ - 'Text', - 'Author' => 'Varchar' - ]; - - private static $has_one = [ - 'Post' => Post::class - ]; - - public function canView($member = null, $context = []) - { - return true; - } - public function canEdit($member = null, $context = []) - { - return true; - } - public function canCreate($member = null, $context = []) - { - return true; - } - public function canDelete($member = null, $context = []) - { - return true; - } -} diff --git a/examples/code/CommentsResolver.php b/examples/code/CommentsResolver.php deleted file mode 100644 index b2a3a1fed..000000000 --- a/examples/code/CommentsResolver.php +++ /dev/null @@ -1,21 +0,0 @@ -Comments(); - - if (isset($args['Today']) && $args['Today']) { - $comments = $comments->where('DATE(Created) = DATE(NOW())'); - } - - return $comments; - } -} diff --git a/examples/code/CreateMemberMutationCreator.php b/examples/code/CreateMemberMutationCreator.php deleted file mode 100644 index e6e923de3..000000000 --- a/examples/code/CreateMemberMutationCreator.php +++ /dev/null @@ -1,42 +0,0 @@ - 'createMember', - 'description' => 'Creates a member without permissions or group assignments' - ]; - } - - public function type() - { - return $this->manager->getType('member'); - } - - public function args() - { - return [ - 'Email' => ['type' => Type::nonNull(Type::string())], - 'FirstName' => ['type' => Type::string()], - 'LastName' => ['type' => Type::string()], - ]; - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - if (!singleton(Member::class)->canCreate($context['currentUser'])) { - throw new \InvalidArgumentException('Member creation not allowed'); - } - - return (new Member($args))->write(); - } -} diff --git a/examples/code/GroupTypeCreator.php b/examples/code/GroupTypeCreator.php deleted file mode 100644 index a3b2af5d0..000000000 --- a/examples/code/GroupTypeCreator.php +++ /dev/null @@ -1,24 +0,0 @@ - 'group' - ]; - } - - public function fields() - { - return [ - 'ID' => ['type' => Type::nonNull(Type::id())], - 'Title' => ['type' => Type::string()], - 'Description' => ['type' => Type::string()] - ]; - } -} diff --git a/examples/code/LatestPostResolver.php b/examples/code/LatestPostResolver.php deleted file mode 100644 index 8f679f61b..000000000 --- a/examples/code/LatestPostResolver.php +++ /dev/null @@ -1,14 +0,0 @@ -sort('Date', 'DESC')->first(); - } -} diff --git a/examples/code/MemberTypeCreator.php b/examples/code/MemberTypeCreator.php deleted file mode 100644 index 7c223dd8d..000000000 --- a/examples/code/MemberTypeCreator.php +++ /dev/null @@ -1,45 +0,0 @@ - 'member' - ]; - } - - public function fields() - { - $groupsConnection = Connection::create('Groups') - ->setConnectionType(function () { - return $this->manager->getType('group'); - }) - ->setDescription('A list of the users groups') - ->setSortableFields(['ID', 'Title']); - - return [ - 'ID' => ['type' => Type::nonNull(Type::id())], - 'Email' => ['type' => Type::string()], - 'FirstName' => ['type' => Type::string()], - 'Surname' => ['type' => Type::string()], - 'Groups' => [ - 'type' => $groupsConnection->toType(), - 'args' => $groupsConnection->args(), - 'resolve' => function ($obj, $args, $context) use ($groupsConnection) { - return $groupsConnection->resolveList( - $obj->Groups(), - $args, - $context - ); - } - ] - ]; - } -} diff --git a/examples/code/PaginatedReadMembersQueryCreator.php b/examples/code/PaginatedReadMembersQueryCreator.php deleted file mode 100644 index 3a7d7d281..000000000 --- a/examples/code/PaginatedReadMembersQueryCreator.php +++ /dev/null @@ -1,42 +0,0 @@ -setConnectionType(function () { - return $this->manager->getType('member'); - }) - ->setArgs([ - 'Email' => [ - 'type' => Type::string() - ] - ]) - ->setSortableFields(['ID', 'FirstName', 'Email']) - ->setConnectionResolver(function ($obj, $args, $context) { - $member = Member::singleton(); - if (!$member->canView($context['currentUser'])) { - throw new \InvalidArgumentException(sprintf( - '%s view access not permitted', - Member::class - )); - } - $list = Member::get(); - - // Optional filtering by properties - if (isset($args['Email'])) { - $list = $list->filter('Email', $args['Email']); - } - - return $list; - }); - } -} diff --git a/examples/code/Post.php b/examples/code/Post.php deleted file mode 100644 index a1b3d2bc5..000000000 --- a/examples/code/Post.php +++ /dev/null @@ -1,147 +0,0 @@ - 'Varchar', - 'Content' => 'HTMLText', - 'Date' => 'Datetime' - ]; - - private static $has_one = [ - 'Author' => Member::class - ]; - - private static $many_many = [ - 'Files' => File::class - ]; - - private static $has_many = [ - 'Comments' => Comment::class - ]; - - public function provideGraphQLScaffolding(SchemaScaffolder $scaffolder) - { - $scaffolder - ->type(Post::class) - ->addFields(['ID', 'Title', 'Content', 'Author', 'Date']) - // basic many_many nested query, no options - ->nestedQuery('Files') - ->end() - // more complex nested query - ->nestedQuery('Comments') - ->addArgs([ - 'Today' => 'Boolean' - ]) - ->addSortableFields(['Author']) - ->setResolver(function ($object, array $args, $context, ResolveInfo $info) { - /** @var Post $object */ - $comments = $object->Comments(); - if (isset($args['Today']) && $args['Today']) { - $comments = $comments->where('DATE(Created) = DATE(NOW())'); - } - - return $comments; - }) - ->end() - // basic crud operation, no options - ->operation(SchemaScaffolder::CREATE) - ->end() - // complex crud operation, with custom args - ->operation(SchemaScaffolder::READ) - ->addArgs([ - 'StartingWith' => 'String' - ]) - ->setResolver(function ($obj, $args) { - $list = Post::get(); - if (isset($args['StartingWith'])) { - $list = $list->filter('Title:StartsWith', $args['StartingWith']); - } - - return $list; - }) - ->end() - ->end() - // these types were all created implicitly above. Add some fields to them. - ->type(Member::class) - ->addFields(['Name', 'FirstName', 'Surname', 'Email']) - ->end() - ->type(File::class) - ->addAllFieldsExcept(['Content']) - ->addFields(['File']) - ->end() - ->type(Comment::class) - ->addFields(['Comment', 'Author']) - ->end() - // Arbitrary mutation - ->mutation('updatePostTitle', Post::class) - ->addArgs([ - 'ID' => 'ID!', - 'NewTitle' => 'String!' - ]) - ->setResolver(function ($obj, $args) { - $post = Post::get()->byID($args['ID']); - if ($post->canEdit()) { - $post->Title = $args['NewTitle']; - $post->write(); - } - - return $post; - }) - ->end() - // Arbitrary query - ->query('latestPost', Post::class) - ->setUsePagination(false) - ->setResolver(function ($obj, $args) { - return Post::get()->sort('Date', 'DESC')->first(); - }) - ->end() - ->type('SilverStripe\\CMS\\Model\\RedirectorPage') - ->addFields(['ExternalURL', 'Content']) - ->operation(SchemaScaffolder::READ) - ->end() - ->operation(SchemaScaffolder::CREATE) - ->end() - ->end(); - - - return $scaffolder; - } - - public function canView($member = null, $context = []) - { - return true; - } - - public function canEdit($member = null, $context = []) - { - return true; - } - - public function canCreate($member = null, $context = []) - { - return true; - } - - public function canDelete($member = null, $context = []) - { - return true; - } -} diff --git a/examples/code/ReadMembersQueryCreator.php b/examples/code/ReadMembersQueryCreator.php deleted file mode 100644 index 1532a6914..000000000 --- a/examples/code/ReadMembersQueryCreator.php +++ /dev/null @@ -1,50 +0,0 @@ - 'readMembers' - ]; - } - - public function args() - { - return [ - 'Email' => ['type' => Type::string()] - ]; - } - - public function type() - { - return Type::listOf($this->manager->getType('member')); - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $member = Member::singleton(); - if (!$member->canView($context['currentUser'])) { - throw new \InvalidArgumentException(sprintf( - '%s view access not permitted', - Member::class - )); - } - $list = Member::get(); - - // Optional filtering by properties - if (isset($args['Email'])) { - $list = $list->filter('Email', $args['Email']); - } - - return $list; - } -} diff --git a/examples/code/ReadResolver.php b/examples/code/ReadResolver.php deleted file mode 100644 index 8e65c1381..000000000 --- a/examples/code/ReadResolver.php +++ /dev/null @@ -1,20 +0,0 @@ -filter('Title:PartialMatch', $args['Title']); - } - - return $list; - } -} diff --git a/examples/code/UpdatePostResolver.php b/examples/code/UpdatePostResolver.php deleted file mode 100644 index a02f44d61..000000000 --- a/examples/code/UpdatePostResolver.php +++ /dev/null @@ -1,21 +0,0 @@ -byID($args['ID']); - - if ($post->canEdit()) { - $post->Title = $args['NewTitle']; - $post->write(); - } - - return $post; - } -} diff --git a/src/Config/ModelConfiguration.php b/src/Config/ModelConfiguration.php new file mode 100644 index 000000000..f813933a7 --- /dev/null +++ b/src/Config/ModelConfiguration.php @@ -0,0 +1,151 @@ +settings = $settings; + } + + /** + * @return callable|null + * @throws SchemaBuilderException + */ + public function getTypeFormatter(): ?callable + { + return $this->get('type_formatter'); + } + + /** + * @return string + * @throws SchemaBuilderException + */ + public function getTypePrefix(): string + { + return $this->get('type_prefix'); + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getNestedQueryPlugins(): array + { + return $this->get('nested_query_plugins'); + } + + /** + * @param string $operation + * @return array + * @throws SchemaBuilderException + */ + public function getOperationConfig(string $operation): array + { + return $this->get(['operations', $operation], []); + } + + /** + * @param string $class + * @return string + * @throws SchemaBuilderException + */ + public function getTypeName(string $class): string + { + $mapping = $this->get('type_mapping', []); + $custom = $mapping[$class] ?? null; + if ($custom) { + return $custom; + } + + $typeName = $this->formatClass($class); + $prefix = $this->getPrefix($class); + + return $prefix . $typeName; + } + + + + /** + * Return a setting by dot.separated.syntax + * @param string|array $path + * @param mixed $default + * @return array|string|bool|null + * @throws SchemaBuilderException + */ + public function get($path, $default = null) + { + Schema::invariant( + is_array($path) || is_string($path), + 'get() must be passed an array or string' + ); + $parts = is_string($path) ? explode('.', $path) : $path; + $scope = $this->settings; + foreach ($parts as $part) { + $scope = $scope[$part] ?? $default; + if (!is_array($scope)) { + break; + } + } + + return $scope; + } + + + /** + * @param string $class + * @return string + * @throws SchemaBuilderException + */ + private function formatClass(string $class): string + { + $formatter = $this->get('type_formatter'); + Schema::invariant( + is_callable($formatter, false), + 'type_formatter property for %s is not callable', + __CLASS__ + ); + + return call_user_func_array($formatter, [$class]); + } + + /** + * @param string $class + * @return string + * @throws SchemaBuilderException + */ + private function getPrefix(string $class): string + { + $prefix = $this->get('type_prefix'); + if (is_callable($prefix, false)) { + return call_user_func_array($prefix, [$class]); + } + + Schema::invariant( + is_string($prefix), + 'type_prefix on %s must be a string', + __CLASS__ + ); + + return $prefix; + } + +} diff --git a/src/Controller.php b/src/Controller.php index b368ef63f..dee398497 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -14,16 +14,22 @@ use SilverStripe\Core\Flushable; use SilverStripe\Core\Injector\Injector; use SilverStripe\GraphQL\Auth\Handler; +use SilverStripe\GraphQL\Dev\Benchmark; use SilverStripe\GraphQL\Dev\State\DisableTypeCacheState; +use SilverStripe\GraphQL\Permission\MemberContextProvider; +use SilverStripe\GraphQL\QueryHandler\QueryHandlerInterface; use SilverStripe\GraphQL\Scaffolding\StaticSchema; +use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; +use SilverStripe\GraphQL\Schema\Interfaces\ContextProvider; +use SilverStripe\GraphQL\Schema\Schema; use SilverStripe\ORM\Connect\DatabaseException; use SilverStripe\Security\Member; use SilverStripe\Security\Permission; use SilverStripe\Versioned\Versioned; +use InvalidArgumentException; /** * Top level controller for handling graphql requests. - * @todo CSRF protection (or token-based auth) * @skipUpgrade */ class Controller extends BaseController implements Flushable @@ -62,9 +68,14 @@ class Controller extends BaseController implements Flushable private static $cache_on_flush = true; /** - * @var Manager + * @var Schema */ - protected $manager; + private $schema; + + /** + * @var QueryHandlerInterface + */ + private $queryHandler; /** * @var GeneratedAssetHandler @@ -78,20 +89,16 @@ class Controller extends BaseController implements Flushable protected $corsConfig = []; /** - * @param Manager $manager + * @param string $schemaKey + * @param QueryHandlerInterface|null $queryHandler */ - public function __construct(Manager $manager = null) + public function __construct(string $schemaKey, ?QueryHandlerInterface $queryHandler = null) { parent::__construct(); - $this->manager = $manager; - - if ($this->manager && $this->manager->getSchemaKey()) { - // Side effect. This isn't ideal, but having multiple instances of StaticSchema - // is a massive architectural change. - StaticSchema::reset(); - - $this->manager->configure(); - } + $schema = Schema::create($schemaKey); + $this->setSchema($schema); + $handler = $queryHandler ?: Injector::inst()->get(QueryHandlerInterface::class); + $this->setQueryHandler($handler); } /** @@ -99,27 +106,42 @@ public function __construct(Manager $manager = null) * * @param HTTPRequest $request * @return HTTPResponse + * @throws InvalidArgumentException */ public function index(HTTPRequest $request) { $stage = $request->param('Stage'); - if ($stage && in_array($stage, [Versioned::DRAFT, Versioned::LIVE])) { + if ($stage) { Versioned::set_stage($stage); } // Check for a possible CORS preflight request and handle if necessary - // Refer issue 66: https://github.com/silverstripe/silverstripe-graphql/issues/66 if ($request->httpMethod() === 'OPTIONS') { return $this->handleOptions($request); } - + $queryPerf = ''; + $schemaPerf = ''; // Main query handling try { - $manager = $this->getManager($request); - // Parse input list($query, $variables) = $this->getRequestQueryVariables($request); + if (!$query) { + $this->httpError(400, 'This endpoint requires a "query" parameter'); + } + + // Temporary, maybe useful by feature flag later.. + Benchmark::start('schema-perf'); + $schema = $this->getSchema()->build(); + $schemaPerf = Benchmark::end('schema-perf', '%sms', true); + $handler = $this->getQueryHandler(); + if ($handler instanceof ContextProvider) { + $this->applyContext($handler, $request); + } + $ctx = $handler->getContext(); + $this->extend('onBeforeHandleQuery', $schema, $query, $ctx, $variables); + Benchmark::start('query-perf'); + $result = $handler->query($schema, $query, $variables); + $queryPerf = Benchmark::end('query-perf', '%sms', true); + $this->extend('onAfterHandleQuery', $schema, $query, $ctx, $variables, $result); - // Run query - $result = $manager->query($query, $variables); } catch (Exception $exception) { $error = ['message' => $exception->getMessage()]; @@ -136,41 +158,9 @@ public function index(HTTPRequest $request) } $response = $this->addCorsHeaders($request, new HTTPResponse(json_encode($result))); - return $response->addHeader('Content-Type', 'application/json'); - } - - /** - * @param HTTPRequest $request - * @return Manager - */ - public function getManager($request = null) - { - $manager = null; - if (!$request) { - $request = $this->getRequest(); - } - if ($this->manager) { - $manager = $this->manager; - } else { - // Get a service rather than an instance (to allow procedural configuration) - $config = Config::inst()->get(static::class, 'schema'); - $manager = Manager::createFromConfig($config); - } - $this->applyManagerContext($manager, $request); - $this->setManager($manager); - - return $manager; - } - - /** - * @param Manager $manager - * @return $this - */ - public function setManager($manager) - { - $this->manager = $manager; - - return $this; + return $response->addHeader('Content-Type', 'application/json') + ->addHeader('X-QueryPerf', $queryPerf) + ->addHeader('X-SchemaPerf', $schemaPerf); } /** @@ -302,26 +292,28 @@ protected function validateOrigin($origin, $allowedOrigins) } /** - * @param Manager $manager + * @param ContextProvider $provider * @param HTTPRequest $request * @throws Exception */ - protected function applyManagerContext(Manager $manager, HTTPRequest $request) + protected function applyContext(ContextProvider $provider, HTTPRequest $request) { - // Add request context to Manager - $manager->addContext('token', $this->getToken()); + $provider->addContext('token', $this->getToken()); $method = null; if ($request->isGET()) { $method = 'GET'; } elseif ($request->isPOST()) { $method = 'POST'; } - $manager->addContext('httpMethod', $method); - - // Check and validate user for this request - $member = $this->getRequestUser($request); - if ($member) { - $manager->setMember($member); + $provider->addContext('httpMethod', $method); + + if ($provider instanceof MemberContextProvider) { + // Check and validate user for this request + /* @var MemberContextProvider $provider */ + $member = $this->getRequestUser($request); + if ($member) { + $provider->setMemberContext($member); + } } } @@ -450,14 +442,8 @@ protected function getRequestUser(HTTPRequest $request) */ public function writeSchemaToFilesystem() { - if (Injector::inst()->has(HTTPRequest::class)) { - $request = Injector::inst()->get(HTTPRequest::class); - } else { - $request = new NullHTTPRequest(); - } - $manager = $this->getManager($request); try { - $types = StaticSchema::inst()->introspectTypes($manager); + $types = $this->introspectTypes(); } catch (Exception $e) { throw new Exception(sprintf( 'There was an error caching the GraphQL types: %s', @@ -468,6 +454,48 @@ public function writeSchemaToFilesystem() $this->writeTypes(json_encode($types)); } + /** + * @return array + * @throws Exception + */ + public function introspectTypes(): array + { + $handler = $this->getQueryHandler(); + if ($handler instanceof ContextProvider) { + $this->applyContext($handler, $this->getRequest()); + } + $fragments = $this->getQueryHandler()->query( + $this->getSchema()->build(), + <<getAssetHandler()) { @@ -500,6 +528,44 @@ public function processTypeCaching() } } + /** + * @return Schema + */ + public function getSchema(): Schema + { + return $this->schema; + } + + /** + * @param Schema $schema + * @return Controller + */ + public function setSchema(Schema $schema): self + { + $this->schema = $schema; + return $this; + } + + /** + * @return QueryHandlerInterface + */ + public function getQueryHandler(): QueryHandlerInterface + { + return $this->queryHandler; + } + + /** + * @param QueryHandlerInterface $queryHandler + * @return Controller + */ + public function setQueryHandler(QueryHandlerInterface $queryHandler): self + { + $this->queryHandler = $queryHandler; + return $this; + } + + + public static function flush() { if (!self::config()->get('cache_on_flush')) { @@ -540,6 +606,6 @@ public static function flush() */ protected function generateCacheFilename() { - return $this->getManager()->getSchemaKey() . '.' . self::CACHE_FILENAME; + return $this->getBuilder()->getSchemaKey() . '.' . self::CACHE_FILENAME; } } diff --git a/src/DataObjectInterfaceTypeCreator.php b/src/DataObjectInterfaceTypeCreator.php deleted file mode 100644 index 6020a2987..000000000 --- a/src/DataObjectInterfaceTypeCreator.php +++ /dev/null @@ -1,51 +0,0 @@ - 'DataObject', - 'description' => 'Base Interface', - ]; - } - - public function fields() - { - return [ - 'id' => [ - 'type' => Type::nonNull(Type::int()), - ], - 'created' => [ - 'type' => Type::string(), - ], - 'lastEdited' => [ - 'type' => Type::string(), - ], - ]; - } - - public function resolveType($object) - { - $type = null; - - if ($fqnType = $this->manager->getType(get_class($object))) { - $type = $fqnType; - } - - if ($baseType = $this->manager->getType(get_class($object))) { - $type = $baseType; - } - - return $type; - } -} diff --git a/src/Dev/Benchmark.php b/src/Dev/Benchmark.php new file mode 100644 index 000000000..c8648bab4 --- /dev/null +++ b/src/Dev/Benchmark.php @@ -0,0 +1,55 @@ +"; + + + return null; + } +} diff --git a/src/Dev/Build.php b/src/Dev/Build.php new file mode 100644 index 000000000..65f24ea83 --- /dev/null +++ b/src/Dev/Build.php @@ -0,0 +1,89 @@ + 'build' + ]; + + private static $allowed_actions = [ + 'build' + ]; + + /** + * @var Schema|null + */ + private static $activeBuild; + + /** + * @param HTTPRequest $request + * @throws SchemaBuilderException + */ + public function build(HTTPRequest $request) + { + $isBrowser = !Director::is_cli(); + if ($isBrowser) { + $renderer = DebugView::create(); + echo $renderer->renderHeader(); + echo $renderer->renderInfo("GraphQL Schema Builder", Director::absoluteBaseURL()); + echo "
"; + } + + $this->buildSchema($request->getVar('schema')); + + if ($isBrowser) { + echo "
"; + echo $renderer->renderFooter(); + } + } + + /** + * @param null $key + * @throws SchemaBuilderException + */ + public function buildSchema($key = null): void + { + $keys = $key ? [$key] : array_keys(Schema::config()->get('schemas')); + $keys = array_filter($keys, function ($key) { + return $key !== Schema::ALL; + }); + foreach ($keys as $key) { + Benchmark::start('build-schema-' . $key); + Schema::message(sprintf('--- Building schema "%s" ---', $key)); + $schema = Schema::create($key); + self::$activeBuild = $schema; + $schema->loadFromConfig(); + + //if ($clear) { todo: caching isn't great + $schema->getStore()->clear(); + //} + + $schema->save(); + Schema::message( + Benchmark::end('build-schema-' . $key, 'Built schema in %sms.') + ); + } + + self::$activeBuild = null; + } + + /** + * @return Schema|null + */ + public static function getActiveBuild(): ?Schema + { + return self::$activeBuild; + } +} diff --git a/src/Dev/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php new file mode 100644 index 000000000..8e92cd248 --- /dev/null +++ b/src/Dev/DevelopmentAdmin.php @@ -0,0 +1,42 @@ +param('Action'); + $reg = Config::inst()->get(static::class, 'registered_controllers'); + if (isset($reg[$baseUrlPart])) { + $controllerClass = $reg[$baseUrlPart]['controller']; + } + + if ($controllerClass && class_exists($controllerClass)) { + return $controllerClass::create(); + } + + $msg = 'Error: no controller registered in ' . __CLASS__ . ' for: ' . $request->param('Action'); + if (Director::is_cli()) { + // in CLI we cant use httpError because of a bug with stuff being in the output already, see DevAdminControllerTest + throw new Exception($msg); + } else { + $this->httpError(404, $msg); + } + } + +} diff --git a/src/Extensions/DevBuildExtension.php b/src/Extensions/DevBuildExtension.php new file mode 100644 index 000000000..ccf5ac31f --- /dev/null +++ b/src/Extensions/DevBuildExtension.php @@ -0,0 +1,35 @@ +get('enabled')) { + Build::singleton()->buildSchema(); + self::$done = true; + } + } +} diff --git a/src/Extensions/IntrospectionProvider.php b/src/Extensions/IntrospectionProvider.php index def18bfa8..181221f7e 100644 --- a/src/Extensions/IntrospectionProvider.php +++ b/src/Extensions/IntrospectionProvider.php @@ -22,8 +22,7 @@ class IntrospectionProvider extends Extension */ public function types(HTTPRequest $request) { - $manager = $this->owner->getManager(); - $fragments = StaticSchema::inst()->introspectTypes($manager); + $fragments = $this->owner->introspectTypes(); return (new HTTPResponse(json_encode($fragments), 200)) ->addHeader('Content-Type', 'application/json'); diff --git a/src/Extensions/QueryRecorderExtension.php b/src/Extensions/QueryRecorderExtension.php new file mode 100644 index 000000000..9936e0ccb --- /dev/null +++ b/src/Extensions/QueryRecorderExtension.php @@ -0,0 +1,66 @@ +levels)) { + return; + } + + // Add class to all nested levels + $class = $query->dataClass(); + for( $i = 0; $i < count($this->levels); $i++) { + $this->levels[$i][strtolower($class)] = $class; + } + } + + /** + * Create a new nesting level, record all classes queried during the callback, and unnest. + * Returns an array containing [ $listOfClasses, $resultOfCallback ] + * + * @param callable $callback + * @return array Two-length array with list of classes and result of callback + */ + public function recordClasses(callable $callback) + { + // Create nesting level + $this->levels[] = []; + try { + $result = $callback(); + $classes = end($this->levels); + return [$classes, $result]; + } finally { + // Reset scope after callback completes + array_pop($this->levels); + } + } +} diff --git a/src/FieldCreator.php b/src/FieldCreator.php deleted file mode 100644 index 86406e49f..000000000 --- a/src/FieldCreator.php +++ /dev/null @@ -1,155 +0,0 @@ -manager = $manager; - } - - /** - * Returns any fixed attributes for this type. E.g. 'name' or 'description' - * - * @link https://github.com/webonyx/graphql-php#schema - * @return array - */ - public function attributes() - { - return []; - } - - /** - * Gets the type for elements within this query, or callback to lazy-load this type - * - * @link https://github.com/webonyx/graphql-php#type-system - * @return Type|callable - */ - public function type() - { - return null; - } - - /** - * List of arguments this query accepts. - * - * @link https://github.com/webonyx/graphql-php#schema - * @return array - */ - public function args() - { - return []; - } - - /** - * Merge all attributes for this query (type, attributes, resolvers, etc). - * - * @return array - */ - public function getAttributes() - { - $args = $this->args(); - - $attributes = array_merge([ - 'args' => $args, - ], $this->attributes()); - - $type = $this->type(); - if (isset($type)) { - $attributes['type'] = $type; - } - - $resolver = $this->getResolver(); - if (isset($resolver)) { - $attributes['resolve'] = $resolver; - } - - return $attributes; - } - /** - * Convert the Fluent instance to an array. - * - * @return array - */ - public function toArray() - { - return $this->getAttributes(); - } - - /** - * Dynamically retrieve the value of an attribute. - * - * @param string $key - * - * @return mixed - */ - public function __get($key) - { - $attributes = $this->getAttributes(); - - return isset($attributes[$key]) ? $attributes[$key] : null; - } - - /** - * Dynamically check if an attribute is set. - * - * @param string $key - * @return bool - */ - public function __isset($key) - { - $attributes = $this->getAttributes(); - - return isset($attributes[$key]); - } - - /** - * Returns a closure callback to the resolve method. This method - * will convert an invocation of this operation into a result or set of results. - * - * Either implement {@see OperationResolver}, or add a callback resolver within - * getAttributes() with the 'resolve' key. - * - * @link https://github.com/webonyx/graphql-php#query-resolution - * @see OperationResolver::resolve() for method signature. - * @return \Closure|null - */ - protected function getResolver() - { - if (! method_exists($this, 'resolve')) { - return null; - } - - $resolver = array($this, 'resolve'); - - return function () use ($resolver) { - $args = func_get_args(); - $result = call_user_func_array($resolver, $args); - - return $result; - }; - } -} diff --git a/src/InterfaceTypeCreator.php b/src/InterfaceTypeCreator.php deleted file mode 100644 index e6bf750df..000000000 --- a/src/InterfaceTypeCreator.php +++ /dev/null @@ -1,59 +0,0 @@ -getTypeResolver(); - if (isset($resolver)) { - $attributes['resolveType'] = $resolver; - } - - return $attributes; - } - - /** - * Generates the interface type from its configuration - * - * @return InterfaceType - */ - public function toType() - { - return new InterfaceType($this->toArray()); - } -} diff --git a/src/Manager.php b/src/Manager.php deleted file mode 100644 index bd9355a17..000000000 --- a/src/Manager.php +++ /dev/null @@ -1,621 +0,0 @@ -middlewares; - } - - /** - * @param QueryMiddleware[] $middlewares - * @return $this - */ - public function setMiddlewares($middlewares) - { - $this->middlewares = $middlewares; - return $this; - } - - /** - * @param QueryMiddleware $middleware - * @return $this - */ - public function addMiddleware($middleware) - { - $this->middlewares[] = $middleware; - return $this; - } - - /** - * Call middleware to evaluate a graphql query - * - * @param Schema $schema - * @param string $query Query to invoke - * @param array $context - * @param array $params Variables passed to this query - * @param callable $last The callback to call after all middlewares - * @return ExecutionResult|array - */ - protected function callMiddleware(Schema $schema, $query, $context, $params, callable $last) - { - $this->extend('onBeforeCallMiddleware', $schema, $query, $context, $params); - - // Reverse middlewares - $next = $last; - // Filter out any middlewares that are set to `false`, e.g. via config - $middlewares = array_reverse(array_filter($this->getMiddlewares())); - /** @var QueryMiddleware $middleware */ - foreach ($middlewares as $middleware) { - $next = function ($schema, $query, $context, $params) use ($middleware, $next) { - return $middleware->process($schema, $query, $context, $params, $next); - }; - } - - $result = $next($schema, $query, $context, $params); - - $this->extend('onAfterCallMiddleware', $schema, $query, $context, $params, $result); - - return $result; - } - - /** - * @param string $schemaKey - */ - public function __construct($schemaKey = null) - { - if ($schemaKey) { - $this->setSchemaKey($schemaKey); - } - } - - /** - * @param $config - * @param string $schemaKey - * @return Manager - * @deprecated 4.0 - */ - public static function createFromConfig($config, $schemaKey = null) - { - Deprecation::notice('4.0', 'Use applyConfig() on a new instance instead'); - - $manager = new static($schemaKey); - - return $manager->applyConfig($config); - } - - /** - * Applies a configuration based on the schemaKey property - * - * @return Manager - * @throws Exception - */ - public function configure() - { - if (!$this->getSchemaKey()) { - throw new BadMethodCallException(sprintf( - 'Attempted to run configure() on a %s instance without a schema key set. See setSchemaKey(), - or specify one in the constructor.', - __CLASS__ - )); - } - - $schemas = $this->config()->get('schemas'); - $config = isset($schemas[$this->getSchemaKey()]) ? $schemas[$this->getSchemaKey()] : []; - - return $this->applyConfig($config); - } - - /** - * @param array $config An array with optional 'types' and 'queries' keys - * @return Manager - */ - public function applyConfig(array $config) - { - $this->extend('updateConfig', $config); - - // Bootstrap schema class mapping from config - if ($config && array_key_exists('typeNames', $config)) { - StaticSchema::inst()->setTypeNames($config['typeNames']); - } - - // Types (incl. Interfaces and InputTypes) - if ($config && array_key_exists('types', $config)) { - foreach ($config['types'] as $name => $typeCreatorClass) { - $typeCreator = Injector::inst()->create($typeCreatorClass, $this); - if (!($typeCreator instanceof TypeCreator)) { - throw new InvalidArgumentException(sprintf( - 'The type named "%s" needs to be a class extending ' . TypeCreator::class, - $name - )); - } - - $type = $typeCreator->toType(); - $this->addType($type, $name); - } - } - - // Queries - if ($config && array_key_exists('queries', $config)) { - foreach ($config['queries'] as $name => $queryCreatorClass) { - $queryCreator = Injector::inst()->create($queryCreatorClass, $this); - if (!($queryCreator instanceof QueryCreator)) { - throw new InvalidArgumentException(sprintf( - 'The type named "%s" needs to be a class extending ' . QueryCreator::class, - $name - )); - } - - $this->addQuery(function () use ($queryCreator) { - return $queryCreator->toArray(); - }, $name); - } - } - - // Mutations - if ($config && array_key_exists('mutations', $config)) { - foreach ($config['mutations'] as $name => $mutationCreatorClass) { - $mutationCreator = Injector::inst()->create($mutationCreatorClass, $this); - if (!($mutationCreator instanceof MutationCreator)) { - throw new InvalidArgumentException(sprintf( - 'The mutation named "%s" needs to be a class extending ' . MutationCreator::class, - $name - )); - } - - $this->addMutation(function () use ($mutationCreator) { - return $mutationCreator->toArray(); - }, $name); - } - } - - if (isset($config['scaffolding'])) { - $scaffolder = SchemaScaffolder::createFromConfig($config['scaffolding']); - } else { - $scaffolder = new SchemaScaffolder(); - } - if (isset($config['scaffolding_providers'])) { - foreach ($config['scaffolding_providers'] as $provider) { - if (!class_exists($provider)) { - throw new InvalidArgumentException(sprintf( - 'Scaffolding provider %s does not exist.', - $provider - )); - } - - $provider = Injector::inst()->create($provider); - if (!$provider instanceof ScaffoldingProvider) { - throw new InvalidArgumentException(sprintf( - 'All scaffolding providers must implement the %s interface', - ScaffoldingProvider::class - )); - } - $provider->provideGraphQLScaffolding($scaffolder); - } - } - - $scaffolder->addToManager($this); - - return $this; - } - - /** - * Build the main Schema instance that represents the final schema for this endpoint - * - * @return Schema - */ - public function schema() - { - $schema = [ - // usually inferred from 'query', but required for polymorphism on InterfaceType-based query results - self::TYPES_ROOT => $this->types, - ]; - - if (!empty($this->queries)) { - $schema[self::QUERY_ROOT] = new ObjectType([ - 'name' => 'Query', - 'fields' => function () { - return array_map(function ($query) { - return is_callable($query) ? $query() : $query; - }, $this->queries); - }, - ]); - } else { - $schema[self::QUERY_ROOT] = new ObjectType([ - 'name' => 'Query', - ]); - } - - if (!empty($this->mutations)) { - $schema[self::MUTATION_ROOT] = new ObjectType([ - 'name' => 'Mutation', - 'fields' => function () { - return array_map(function ($mutation) { - return is_callable($mutation) ? $mutation() : $mutation; - }, $this->mutations); - }, - ]); - } - - return new Schema($schema); - } - - /** - * Execute an arbitrary operation (mutation / query) on this schema. - * - * Note because middleware may produce serialised responses we need to conditionally - * normalise to serialised array on output from object -> array. - * - * @param string $query Raw query - * @param array $params List of arguments given for this operation - * @return array - */ - public function query($query, $params = []) - { - $executionResult = $this->queryAndReturnResult($query, $params); - - // Already in array form - if (is_array($executionResult)) { - return $executionResult; - } - return $this->serialiseResult($executionResult); - } - - /** - * Evaluate query via middleware - * - * @param string $query - * @param array $params - * @return ExecutionResult|array Result as either source object result, or serialised as array. - */ - public function queryAndReturnResult($query, $params = []) - { - $schema = $this->schema(); - $context = $this->getContext(); - - $last = function ($schema, $query, $context, $params) { - return GraphQL::executeQuery($schema, $query, null, $context, $params); - }; - - return $this->callMiddleware($schema, $query, $context, $params, $last); - } - - /** - * Register a new type - * - * @param Type $type - * @param string $name An optional identifier for this type (defaults to 'name' - * attribute in type definition). Needs to be unique in schema. - */ - public function addType(Type $type, $name = '') - { - if (!$name) { - $name = (string)$type; - } - - $this->types[$name] = $type; - } - - /** - * Return a type definition by name - * - * @param string $name - * @return Type - */ - public function getType($name) - { - if (isset($this->types[$name])) { - return $this->types[$name]; - } else { - throw new InvalidArgumentException("Type '$name' is not a registered GraphQL type"); - } - } - - /** - * @param string $name - * - * @return boolean - */ - public function hasType($name) - { - return isset($this->types[$name]); - } - - /** - * Register a new Query. Query can be defined as a closure to ensure - * dependent types are lazy loaded. - * - * @param array|Closure $query - * @param string $name Identifier for this query (unique in schema) - */ - public function addQuery($query, $name) - { - $this->queries[$name] = $query; - } - - /** - * Get a query by name - * - * @param string $name - * @return array - */ - public function getQuery($name) - { - return $this->queries[$name]; - } - - /** - * Register a new mutation. Mutations can be callbacks to ensure - * dependent types are lazy-loaded. - * - * @param array|Closure $mutation - * @param string $name Identifier for this mutation (unique in schema) - */ - public function addMutation($mutation, $name) - { - $this->mutations[$name] = $mutation; - } - - /** - * Get a mutation by name - * - * @param string $name - * @return array - */ - public function getMutation($name) - { - return $this->mutations[$name]; - } - - /** - * @return string - */ - public function getSchemaKey() - { - return $this->schemaKey; - } - - /** - * @param string $schemaKey - * @return $this - */ - public function setSchemaKey($schemaKey) - { - if (!is_string($schemaKey)) { - throw new InvalidArgumentException(sprintf( - '%s schemaKey must be a string', - __CLASS__ - )); - } - if (empty($schemaKey)) { - throw new InvalidArgumentException(sprintf( - '%s schemaKey must cannot be empty', - __CLASS__ - )); - } - if (preg_match('/[^A-Za-z0-9_-]/', $schemaKey)) { - throw new InvalidArgumentException(sprintf( - '%s schemaKey may only contain alphanumeric characters, dashes, and underscores', - __CLASS__ - )); - } - - $this->schemaKey = $schemaKey; - - return $this; - } - - /** - * More verbose error display defaults. - * - * @param Error $exception - * @return array - */ - public static function formatError(Error $exception) - { - $error = [ - 'message' => $exception->getMessage(), - ]; - - $locations = $exception->getLocations(); - if (!empty($locations)) { - $error['locations'] = array_map(function (SourceLocation $loc) { - return $loc->toArray(); - }, $locations); - } - - $previous = $exception->getPrevious(); - if ($previous && $previous instanceof ValidationException) { - $error['validation'] = $previous->getResult()->getMessages(); - } - - return $error; - } - - /** - * Set the Member for the current context - * - * @param Member $member - * @return $this - */ - public function setMember(Member $member) - { - $this->member = $member; - return $this; - } - - /** - * Get the Member for the current context either from a previously set value or the current user - * - * @return Member - */ - public function getMember() - { - return $this->member ?: Security::getCurrentUser(); - } - - /** - * get query from persisted id, return null if not found - * - * @param $id - * @return string | null - */ - public function getQueryFromPersistedID($id) - { - /** @var PersistedQueryMappingProvider $provider */ - $provider = Injector::inst()->get(PersistedQueryMappingProvider::class); - - return $provider->getByID($id); - } - - /** - * Get global context to pass to $context for all queries - * - * @return array - */ - protected function getContext() - { - return array_merge( - $this->getContextDefaults(), - $this->extraContext - ); - } - - /** - * @return array - */ - protected function getContextDefaults() - { - return [ - 'currentUser' => $this->getMember(), - ]; - } - - /** - * @param string $key - * @param any $value - * @return $this - */ - public function addContext($key, $value) - { - if (!is_string($key)) { - throw new InvalidArgumentException(sprintf( - 'Context key must be a string. Got %s', - gettype($key) - )); - } - $this->extraContext[$key] = $value; - - return $this; - } - - /** - * Serialise a Graphql result object for output - * - * @param ExecutionResult $executionResult - * @return array - */ - public function serialiseResult($executionResult) - { - // Format object - if (!empty($executionResult->errors)) { - return [ - 'data' => $executionResult->data, - 'errors' => array_map($this->errorFormatter, $executionResult->errors), - ]; - } else { - return [ - 'data' => $executionResult->data, - ]; - } - } -} diff --git a/src/Middleware/CSRFMiddleware.php b/src/Middleware/CSRFMiddleware.php index fb4af9e01..d4e10f3ff 100644 --- a/src/Middleware/CSRFMiddleware.php +++ b/src/Middleware/CSRFMiddleware.php @@ -3,18 +3,31 @@ namespace SilverStripe\GraphQL\Middleware; use Exception; +use GraphQL\Error\SyntaxError; use GraphQL\Language\AST\NodeKind; use GraphQL\Language\Parser; use GraphQL\Language\Source; -use GraphQL\Type\Schema; -use SilverStripe\GraphQL\Manager; +use SilverStripe\GraphQL\QueryHandler\QueryHandler; use SilverStripe\Security\SecurityToken; -class CSRFMiddleware implements QueryMiddleware +/** + * Adds functionality that checks a request for a token before allowing a mutation + * to happen. Protects against CSRF attacks + * + */ +class CSRFMiddleware implements Middleware { - public function process(Schema $schema, $query, $context, $params, callable $next) + /** + * @param array $params + * @param callable $next + * @return mixed + * @throws SyntaxError + */ + public function process(array $params, callable $next) { - if ($this->isMutation($query)) { + $query = $params['query'] ?? null; + $context = $params['context'] ?? []; + if ($query && QueryHandler::isMutation($query)) { if (empty($context['token'])) { throw new Exception('Mutations must provide a CSRF token in the X-CSRF-TOKEN header'); } @@ -25,41 +38,8 @@ public function process(Schema $schema, $query, $context, $params, callable $nex } } - return $next($schema, $query, $context, $params); + return $next($params); } - /** - * @param string $query - * @return bool - */ - protected function isMutation($query) - { - // Simple string matching as a first check to prevent unnecessary static analysis - if (stristr($query, Manager::MUTATION_ROOT) === false) { - return false; - } - // If "mutation" is the first expression in the query, then it's a mutation. - if (preg_match('/^\s*'.preg_quote(Manager::MUTATION_ROOT, '/').'/', $query)) { - return true; - } - - // Otherwise, bring in the big guns. - $document = Parser::parse(new Source($query ?: 'GraphQL')); - $defs = $document->definitions; - foreach ($defs as $statement) { - $options = [ - NodeKind::OPERATION_DEFINITION, - NodeKind::OPERATION_TYPE_DEFINITION - ]; - if (!in_array($statement->kind, $options, true)) { - continue; - } - if ($statement->operation === Manager::MUTATION_ROOT) { - return true; - } - } - - return false; - } } diff --git a/src/Middleware/HTTPMethodMiddleware.php b/src/Middleware/HTTPMethodMiddleware.php index 2ea5d96b0..e74ff4186 100644 --- a/src/Middleware/HTTPMethodMiddleware.php +++ b/src/Middleware/HTTPMethodMiddleware.php @@ -3,14 +3,26 @@ namespace SilverStripe\GraphQL\Middleware; use Exception; -use GraphQL\Type\Schema; -class HTTPMethodMiddleware implements QueryMiddleware +/** + * Ensures mutations use POST requests + */ +class HTTPMethodMiddleware implements Middleware { - public function process(Schema $schema, $query, $context, $params, callable $next) + /** + * @param array $params + * @param callable $next + * @return mixed + * @throws Exception + */ + public function process(array $params, callable $next) { + $context = $params['context'] ?? []; + $query = $params['query'] ?? null; + $isGET = false; $isPOST = false; + if (isset($context['httpMethod'])) { $isGET = $context['httpMethod'] === 'GET'; $isPOST = $context['httpMethod'] === 'POST'; @@ -26,6 +38,6 @@ public function process(Schema $schema, $query, $context, $params, callable $nex } } - return $next($schema, $query, $context, $params); + return $next($params); } } diff --git a/src/Middleware/Middleware.php b/src/Middleware/Middleware.php new file mode 100644 index 000000000..7fa2fa4c8 --- /dev/null +++ b/src/Middleware/Middleware.php @@ -0,0 +1,18 @@ +middlewares; + } + + /** + * @param Middleware[] $middlewares + * @return $this + */ + public function setMiddlewares(array $middlewares) + { + foreach ($middlewares as $middleware) { + if ($middleware instanceof Middleware) { + $this->addMiddleware($middleware); + } + } + return $this; + } + + /** + * @param Middleware $middleware + * @return $this + */ + public function addMiddleware($middleware) + { + $this->middlewares[] = $middleware; + return $this; + } + + /** + * @param array $params + * @param callable $last + * @return mixed + */ + protected function executeMiddleware(array $params, callable $last) + { + // Reverse middlewares + $next = $last; + // Filter out any middlewares that are set to `false`, e.g. via config + $middlewares = array_reverse(array_filter($this->getMiddlewares())); + /** @var Middleware $middleware */ + foreach ($middlewares as $middleware) { + $next = function ($params) use ($middleware, $next) { + return $middleware->process($params, $next); + }; + } + + $result = $next($params); + + return $result; + } + +} diff --git a/src/Middleware/QueryCachingMiddleware.php b/src/Middleware/QueryCachingMiddleware.php new file mode 100644 index 000000000..90dc01777 --- /dev/null +++ b/src/Middleware/QueryCachingMiddleware.php @@ -0,0 +1,176 @@ +hasExtension(QueryRecorderExtension::class)) { + throw new Exception(sprintf( + 'You must apply the %s extension to the %s in order to use the %s middleware', + QueryRecorderExtension::class, + DataObject::class, + __CLASS__ + )); + } + $query = $params['query']; + $vars = $params['vars']; + $key = $this->generateCacheKey($query, $vars); + + // Get successful cache response + $response = $this->getCachedResponse($key); + if ($response) { + return $response; + } + + // Closure begins / ends recording of classes queried by DataQuery. + // ClassSpyExtension is added to DataQuery via yml + $spy = QueryRecorderExtension::singleton(); + list ($classesUsed, $response) = $spy->recordClasses(function () use ($params, $next) { + return $next($params); + }); + + // Save freshly generated response + $this->storeCache($key, $response, $classesUsed); + return $response; + } + + /** + * @return CacheInterface + */ + public function getCache(): CacheInterface + { + return $this->cache; + } + + /** + * @param CacheInterface $cache + * @return $this + */ + public function setCache($cache): self + { + $this->cache = $cache; + return $this; + } + + /** + * Generate cache key + * + * @param string $query + * @param array $params + * @return string + */ + protected function generateCacheKey($query, $params): string + { + return md5(var_export( + [ + 'query' => $query, + 'params' => $params + ], + true + )); + } + + /** + * Get and validate cached response. + * + * Note: Cached responses can only be returned in array format, not object format. + * + * @param string $key + * @return array|null + * @throws InvalidArgumentException + */ + protected function getCachedResponse($key): ?array + { + // Initially check if the cached value exists at all + $cache = $this->getCache(); + $cached = $cache->get($key); + if (!isset($cached)) { + return null; + } + + // On cache success validate against cached classes + foreach ($cached['classes'] as $class) { + // Note: Could combine these clases into a UNION to cut down on extravagant queries + // Todo: We can get last-deleted/modified as well for versioned records + $lastEditedDate = DataObject::get($class)->max('LastEdited'); + if (strtotime($lastEditedDate) > strtotime($cached['date'])) { + // class modified, fail validation of cache + return null; + } + } + + // On cache success + validation + return $cached['response']; + } + + /** + * Send a successful response to the cache + * + * @param string $key + * @param ExecutionResult|array $response + * @param array $classesUsed + * @throws InvalidArgumentException + */ + protected function storeCache($key, $response, $classesUsed): void + { + // Ensure we store serialisable version of result + if ($response instanceof ExecutionResult) { + $handler = Injector::inst()->get(QueryHandlerInterface::class); + $response = $handler->serialiseResult($response); + } + + // Don't store an error response + $errors = $response['errors'] ?? []; + if (!empty($errors)) { + return; + } + + $this->getCache()->set($key, [ + 'classes' => $classesUsed, + 'response' => $response, + 'date' => DBDatetime::now()->getValue() + ]); + } + + public static function flush() + { + static::singleton()->getCache()->clear(); + } +} diff --git a/src/Middleware/QueryMiddleware.php b/src/Middleware/QueryMiddleware.php deleted file mode 100644 index f43b65fae..000000000 --- a/src/Middleware/QueryMiddleware.php +++ /dev/null @@ -1,22 +0,0 @@ - - * friends(limit:2,offset:2,sortBy:[{field:Name,direction:ASC}]) { - * edges { - * node { - * name - * } - * } - * pageInfo { - * totalCount - * hasPreviousPage - * hasNextPage - * } - * } - * - */ -class Connection implements OperationResolver -{ - use Injectable; - use PermissionCheckerAware; - - /** - * @var string - */ - protected $connectionName; - - /** - * Return a thunk function, which in turn returns the lazy-evaluated - * {@link ObjectType}. - * - * @var ObjectType|Callable - */ - protected $connectedType; - - /** - * @var string - */ - protected $description; - - /** - * @var Callable - */ - protected $connectionResolver; - - /** - * @var array - */ - protected $args = []; - - /** - * @var array Keyed by field argument name, values as DataObject column names. - * Does not support in-memory sorting for composite values (getters). - */ - protected $sortableFields = []; - - /** - * @var int - */ - protected $defaultLimit = 100; - - /** - * The maximum limit supported for the connection. Used to prevent excessive - * load on the server. To override the default limit, use {@link setLimits} - * - * @var int - */ - protected $maximumLimit = 100; - - /** - * @param string $connectionName - */ - public function __construct($connectionName) - { - $this->connectionName = $connectionName; - } - - /** - * @param Callable - * - * @return $this - */ - public function setConnectionResolver($func) - { - $this->connectionResolver = $func; - - return $this; - } - - /** - * Pass in the {@link ObjectType}. - * - * @param ObjectType|Callable $type Type, or callable to evaluate type - * @return $this - */ - public function setConnectionType($type) - { - $this->connectedType = $type; - - return $this; - } - - /** - * Evaluate Connection type - * - * @param bool $evaluate - * @return ObjectType|Callable - */ - public function getConnectionType($evaluate = true) - { - return ($evaluate && is_callable($this->connectedType)) - ? call_user_func($this->connectedType) - : $this->connectedType; - } - - /** - * @return Callable - */ - public function getConnectionResolver() - { - return $this->connectionResolver; - } - - /** - * @param array|Callable - * - * @return $this - */ - public function setArgs($args) - { - $this->args = $args; - - return $this; - } - - /** - * @param string - * - * @return $this - */ - public function setDescription($string) - { - $this->description = $string; - - return $this; - } - - /** - * @return string - */ - public function getDescription() - { - return $this->description; - } - - /** - * @param array $fields See {@link $sortableFields} - * @return $this - */ - public function setSortableFields($fields) - { - foreach ($fields as $field => $lookup) { - $this->sortableFields[is_numeric($field) ? $lookup : $field] = $lookup; - } - - return $this; - } - - /** - * @return array - */ - public function getSortableFields() - { - return $this->sortableFields; - } - - /** - * @param int - * - * @return $this - */ - public function setDefaultLimit($limit) - { - $this->defaultLimit = $limit; - - return $this; - } - - /** - * @return int - */ - public function getDefaultLimit() - { - return $this->defaultLimit; - } - - /** - * @param int - * - * @return $this - */ - public function setMaximumLimit($limit) - { - $this->maximumLimit = $limit; - - return $this; - } - - /** - * @return string - */ - public function getConnectionTypeName() - { - return $this->connectionName . 'Connection'; - } - - /** - * @return string - */ - public function getEdgeTypeName() - { - return $this->connectionName . 'Edge'; - } - - /** - * Pagination support for the connection type. Currently doesn't support - * cursors, just basic offset pagination. - * - * @return array - */ - public function args() - { - $existing = is_callable($this->args) ? call_user_func($this->args) : $this->args; - - if (!is_array($existing)) { - $existing = []; - } - - $args = array_merge($existing, [ - 'limit' => [ - 'type' => Type::int(), - ], - 'offset' => [ - 'type' => Type::int() - ] - ]); - - if ($fields = $this->getSortableFields()) { - $args['sortBy'] = [ - 'type' => Type::listOf( - Injector::inst()->create(SortInputTypeCreator::class, $this->connectionName) - ->setSortableFields($fields) - ->toType() - ) - ]; - } - - return $args; - } - - /** - * @return array - */ - public function fields() - { - return [ - 'pageInfo' => [ - 'type' => Type::nonNull( - Injector::inst()->get(PageInfoTypeCreator::class)->toType() - ), - 'description' => 'Pagination information' - ], - 'edges' => [ - 'type' => Type::listOf($this->getEdgeType()), - 'description' => 'Collection of records' - ] - ]; - } - - /** - * @return ObjectType - */ - public function getEdgeType() - { - if (!$this->connectedType) { - throw new InvalidArgumentException('Missing connectedType callable'); - } - - return new ObjectType([ - 'name' => $this->getEdgeTypeName(), - 'description' => 'The collections edge', - 'fields' => function () { - return [ - 'node' => [ - 'type' => $this->getConnectionType(), - 'description' => 'The node at the end of the collections edge', - 'resolve' => function ($obj) { - return $obj; - } - ] - ]; - } - ]); - } - - /** - * @return ObjectType - */ - public function toType() - { - return new ObjectType([ - 'name' => $this->getConnectionTypeName(), - 'description' => $this->description, - 'fields' => function () { - return $this->fields(); - }, - ]); - } - - /** - * Returns the collection resolved with the pageInfo provided. - * - * @param mixed $value - * @param array $args - * @param array $context - * @param ResolveInfo $info - * @return array - * @throws \Exception - */ - public function resolve($value, array $args, $context, ResolveInfo $info) - { - $result = call_user_func_array( - $this->connectionResolver, - func_get_args() - ); - - if (!$result instanceof SS_List) { - throw new \Exception('Connection::resolve() must resolve to a SS_List instance.'); - } - - return $this->resolveList($result, $args, $context, $info); - } - - /** - * Wraps an {@link SS_List} with the required data in order to return it as - * a response. If you wish to resolve a standard array as a list use - * {@link ArrayList}. - * - * @param SS_List $list - * @param array $args - * @param null $context - * @param ResolveInfo $info - * @return array - */ - public function resolveList($list, array $args, $context = null, ResolveInfo $info = null) - { - // Apply sort - if (!empty($args['sortBy'])) { - $list = $this->applySort($list, $args['sortBy']); - } - - // Default values - $count = $list->count(); - $nextPage = false; - $previousPage = false; - - // If list is limitable, apply pagination - if ($list instanceof Limitable) { - $offset = empty($args['offset']) ? 0 : $args['offset']; - $limit = empty($args['limit']) - ? $this->defaultLimit - : $args['limit']; - if ($limit > $this->maximumLimit) { - $limit = $this->maximumLimit; - } - - // Apply limit - $list = $list->limit($limit, $offset); - - // Flag prev-next page - if ($limit && (($limit + $offset) < $count)) { - $nextPage = true; - } - if ($offset > 0) { - $previousPage = true; - } - } - if ($checker = $this->getPermissionChecker()) { - $currentUser = isset($context['currentUser']) ? $context['currentUser'] : null; - $list = $checker->applyToList($list, $currentUser); - } - - return [ - 'edges' => $list, - 'pageInfo' => [ - 'totalCount' => $count, - 'hasNextPage' => $nextPage, - 'hasPreviousPage' => $previousPage - ] - ]; - } - - /** - * @param SS_List $list - * @param array $sortBy - * @return SS_List Sorted list, if sortable - * @throws InvalidArgumentException If an invalid sort column is specified - */ - protected function applySort($list, $sortBy) - { - // Ensure list is sortable - if (!$list instanceof Sortable) { - return $list; - } - - $sortableFields = $this->getSortableFields(); - - // convert the input from the input format of field, direction - // to an accepted SS_List sort format. - // https://github.com/graphql/graphql-relay-js/issues/20#issuecomment-220494222 - $sort = []; - foreach ($sortBy as $sortInput) { - if (isset($sortInput['field'])) { - $direction = isset($sortInput['direction']) ? $sortInput['direction'] : 'ASC'; - if (!array_key_exists($sortInput['field'], $sortableFields)) { - throw new InvalidArgumentException(sprintf( - '"%s" is not a valid sort column', - $sortInput['field'] - )); - } - - $column = $sortableFields[$sortInput['field']]; - $sort[$column] = $direction; - } - } - - if ($sort) { - $list = $list->sort($sort); - } - return $list; - } -} diff --git a/src/Pagination/PageInfoTypeCreator.php b/src/Pagination/PageInfoTypeCreator.php deleted file mode 100644 index cd478c0bc..000000000 --- a/src/Pagination/PageInfoTypeCreator.php +++ /dev/null @@ -1,64 +0,0 @@ -type) { - $this->type = parent::toType(); - } - return $this->type; - } - - public function getAttributes() - { - // Don't wrap static fields in callback - return array_merge( - $this->attributes(), - [ - 'fields' => function () { - return $this->fields(); - } - ] - ); - } - - public function attributes() - { - return [ - 'name' => 'PageInfo', - 'description' => 'Information about pagination in a connection.', - ]; - } - - public function fields() - { - return [ - 'totalCount' => [ - 'type' => Type::nonNull(Type::int()) - ], - 'hasNextPage' => [ - 'type' => Type::nonNull(Type::boolean()) - ], - 'hasPreviousPage' => [ - 'type' => Type::nonNull(Type::boolean()) - ], - ]; - } -} diff --git a/src/Pagination/PaginatedQueryCreator.php b/src/Pagination/PaginatedQueryCreator.php deleted file mode 100644 index 5d149b1a0..000000000 --- a/src/Pagination/PaginatedQueryCreator.php +++ /dev/null @@ -1,60 +0,0 @@ -connection) { - $this->connection = $this->createConnection(); - } - - return $this->connection; - } - - /** - * @return array - */ - public function args() - { - return $this->getConnection()->args(); - } - - public function type() - { - return $this->getConnection()->toType(); - } - - public function resolve($value, array $args, $context, ResolveInfo $info) - { - return $this->getConnection()->resolve( - $value, - $args, - $context, - $info - ); - } -} diff --git a/src/Pagination/SortDirectionTypeCreator.php b/src/Pagination/SortDirectionTypeCreator.php deleted file mode 100644 index b388dc2c8..000000000 --- a/src/Pagination/SortDirectionTypeCreator.php +++ /dev/null @@ -1,52 +0,0 @@ -type) { - $this->type = new EnumType($this->toArray()); - } - return $this->type; - } - - public function getAttributes() - { - return $this->attributes(); - } - - public function attributes() - { - return [ - 'name' => 'SortDirection', - 'description' => 'Set order order to either ASC or DESC', - 'values' => [ - 'ASC' => [ - 'value' => 'ASC', - 'description' => 'Lowest value to highest.', - ], - 'DESC' => [ - 'value' => 'DESC', - 'description' => 'Highest value to lowest.', - ], - ], - ]; - } -} diff --git a/src/Pagination/SortInputTypeCreator.php b/src/Pagination/SortInputTypeCreator.php deleted file mode 100644 index 8876a621e..000000000 --- a/src/Pagination/SortInputTypeCreator.php +++ /dev/null @@ -1,114 +0,0 @@ -inputName = $name; - } - - /** - * Specify the list of sortable fields - * - * @param array $sortableFields - * @return $this - */ - public function setSortableFields($sortableFields) - { - $this->sortableFields = $sortableFields; - return $this; - } - - public function toType() - { - if (!$this->type) { - $this->type = parent::toType(); - } - return $this->type; - } - - public function getAttributes() - { - // Don't wrap static fields in callback - return array_merge( - $this->attributes(), - [ - 'fields' => function () { - return $this->fields(); - } - ] - ); - } - - public function attributes() - { - return [ - 'name' => ucfirst($this->inputName) .'SortInputType', - 'description' => 'Define the sorting', - ]; - } - - public function fields() - { - $values = []; - foreach ($this->sortableFields as $fieldAlias => $fieldName) { - $values[$fieldAlias] = [ - 'value' => $fieldAlias - ]; - } - - $sortableField = new EnumType([ - 'name' => ucfirst($this->inputName) . 'SortFieldType', - 'description' => 'Field name to sort by.', - 'values' => $values, - ]); - - return [ - 'field' => [ - 'type' => Type::nonNull($sortableField), - 'description' => 'Sort field name.', - ], - 'direction' => [ - 'type' => Injector::inst()->get(SortDirectionTypeCreator::class)->toType(), - 'description' => 'Sort direction (ASC / DESC)', - ] - ]; - } -} diff --git a/src/Permission/CanViewPermissionChecker.php b/src/Permission/CanViewPermissionChecker.php deleted file mode 100644 index d68876615..000000000 --- a/src/Permission/CanViewPermissionChecker.php +++ /dev/null @@ -1,46 +0,0 @@ -canView($member)) { - $excludes[] = $record->ID; - } - } - - if (!empty($excludes)) { - return $list->exclude(['ID' => $excludes]); - } - - return $list; - } - - /** - * @param object $item - * @param Member|null $member - * @return bool - */ - public function checkItem($item, Member $member = null) - { - if (is_object($item) && method_exists($item, 'canView')) { - return $item->canView($member); - } - - return true; - } -} diff --git a/src/Permission/MemberAware.php b/src/Permission/MemberAware.php new file mode 100644 index 000000000..0eaf30904 --- /dev/null +++ b/src/Permission/MemberAware.php @@ -0,0 +1,38 @@ +member = $member; + } + + /** + * Get the Member for the current context either from a previously set value or the current user + * + * @return Member + */ + public function getMemberContext(): ?Member + { + return $this->member ?: Security::getCurrentUser(); + } + +} diff --git a/src/Permission/MemberContextProvider.php b/src/Permission/MemberContextProvider.php new file mode 100644 index 000000000..2e45c90d5 --- /dev/null +++ b/src/Permission/MemberContextProvider.php @@ -0,0 +1,24 @@ +permissionChecker = $checker; - - return $this; - } - - /** - * @return QueryPermissionChecker - */ - public function getPermissionChecker() - { - return $this->permissionChecker; - } -} diff --git a/src/Permission/QueryPermissionChecker.php b/src/Permission/QueryPermissionChecker.php deleted file mode 100644 index 5d91796e7..000000000 --- a/src/Permission/QueryPermissionChecker.php +++ /dev/null @@ -1,26 +0,0 @@ -setDataObjectClass($dataObjectClass); - } - - /** - * @param FilterRegistryInterface $registry - * @return $this - */ - public function setFilterRegistry(FilterRegistryInterface $registry) - { - $this->filterRegistry = $registry; - - return $this; - } - - /** - * @return FilterRegistryInterface - */ - public function getFilterRegistry() - { - return $this->filterRegistry; - } - - /** - * @return string - */ - public function getFilterKey() - { - return $this->filterKey; - } - - /** - * @param string $filterKey - * @return DataObjectQueryFilter - */ - public function setFilterKey($filterKey) - { - $this->filterKey = $filterKey; - return $this; - } - - /** - * @return string - */ - public function getExcludeKey() - { - return $this->excludeKey; - } - - /** - * @param string $excludeKey - * @return DataObjectQueryFilter - */ - public function setExcludeKey($excludeKey) - { - $this->excludeKey = $excludeKey; - return $this; - } - - /** - * @return bool - */ - public function exists() - { - return !empty($this->filteredFields); - } - - /** - * @param $fieldName - * @param $filterIdentifier - * @return $this - */ - public function addFieldFilterByIdentifier($fieldName, $filterIdentifier) - { - if (!isset($this->filteredFields[$fieldName])) { - $this->filteredFields[$fieldName] = []; - } - - $this->filteredFields[$fieldName][$filterIdentifier] = $filterIdentifier; - - return $this; - } - - /** - * @param $fieldName - * @param FieldFilterInterface $filter - */ - public function addFieldFilter($fieldName, FieldFilterInterface $filter) - { - if (!isset($this->filteredFields[$fieldName])) { - $this->filteredFields[$fieldName] = []; - } - - $this->filteredFields[$fieldName][$filter->getIdentifier()] = $filter; - - return $this; - } - - /** - * @param string $field - * @return $this - */ - public function addDefaultFilters($field) - { - $dbField = $this->getDBField($field); - if (!$dbField) { - throw new InvalidArgumentException(sprintf( - 'Could not resolve field %s on %s', - $field, - $this->getDataObjectClass() - )); - } - foreach ($dbField->config()->graphql_default_filters as $filterID) { - $this->addFieldFilterByIdentifier($field, $filterID); - } - - return $this; - } - - /** - * Adds all the default filters for every field on the dataobject - * @return $this - */ - public function addAllFilters() - { - $fields = array_keys($this->getDataObjectInstance()->searchableFields()); - foreach ($fields as $fieldName) { - $this->addDefaultFilters($fieldName); - } - - return $this; - } - - /** - * @param string $name - * @param bool $cached - * @return InputObjectType - */ - public function getInputType($name, $cached = true) - { - if ($cached && isset($this->inputTypeCache[$name])) { - return $this->inputTypeCache[$name]; - } - - $filteredFields = $this->filteredFields; - $input = new InputObjectType([ - 'name' => $name, - 'fields' => function () use ($filteredFields) { - $fields = []; - foreach ($filteredFields as $fieldName => $filterIDs) { - /* @var DBField|TypeCreatorExtension $db */ - $db = $this->getDBField($fieldName); - foreach ($filterIDs as $filterIdOrInstance) { - $filter = $filterIdOrInstance instanceof FieldFilterInterface - ? $filterIdOrInstance - : $this->getFilterRegistry()->getFilterByIdentifier($filterIdOrInstance); - - if (!$filter) { - throw new Exception(sprintf( - 'Filter %s not found', - $filterIdOrInstance - )); - } - $filterType = $db->getGraphQLType(); - if ($filter instanceof ListFieldFilterInterface) { - $filterType = Type::listOf($filterType); - } - $id = $filterIdOrInstance instanceof FieldFilterInterface - ? $filterIdOrInstance->getIdentifier() - : $filterIdOrInstance; - - $fields[$fieldName . self::SEPARATOR . $id] = [ - 'type' => $filterType, - ]; - } - } - return $fields; - } - ]); - - $this->inputTypeCache[$name] = $input; - - return $this->inputTypeCache[$name]; - } - - /** - * @param DataList $list - * @param array $args - * @return DataList - */ - public function applyArgsToList(DataList $list, $args = []) - { - if (isset($args[$this->getFilterKey()]) && !empty($args[$this->getFilterKey()])) { - foreach ($this->getFieldFilters($args[$this->getFilterKey()]) as $tuple) { - /* @var FieldFilterInterface $filter */ - list ($filter, $field, $value) = $tuple; - $list = $filter->applyInclusion($list, $field, $value); - } - } - if (isset($args[$this->getExcludeKey()]) && !empty($args[$this->getExcludeKey()])) { - foreach ($this->getFieldFilters($args[$this->getExcludeKey()]) as $tuple) { - /* @var FieldFilterInterface $filter */ - list ($filter, $field, $value) = $tuple; - $list = $filter->applyExclusion($list, $field, $value); - } - } - - return $list; - } - - /** - * @param string $fieldName - * @return array - * @throws InvalidArgumentException - */ - public function getFiltersForField($fieldName) - { - if (isset($this->filteredFields[$fieldName])) { - return $this->filteredFields[$fieldName]; - } - - throw new InvalidArgumentException(sprintf( - 'Field %s not found', - $fieldName - )); - } - - /** - * @param string $fieldName - * @return array - * @throws InvalidArgumentException - */ - public function getFilterIdentifiersForField($fieldName) - { - return array_keys($this->getFiltersForField($fieldName)); - } - - - /** - * @param string $fieldName - * @return bool - */ - public function isFieldFiltered($fieldName) - { - try { - $filters = $this->getFiltersForField($fieldName); - - return !empty($filters); - } catch (InvalidArgumentException $e) { - return false; - } - } - - /** - * @param string $fieldName - * @param string $id - * @return bool - */ - public function fieldHasFilter($fieldName, $id) - { - if ($this->isFieldFiltered($fieldName)) { - return in_array($id, $this->getFilterIdentifiersForField($fieldName)); - } - - return false; - } - - /** - * @param string $fieldName - * @param string $id - * @return $this - */ - public function removeFieldFilterByIdentifier($fieldName, $id) - { - if ($this->isFieldFiltered($fieldName)) { - unset($this->filteredFields[$fieldName][$id]); - } - - return $this; - } - - /** - * @param $fieldName - * @param $id - * @return FieldFilterInterface|null - */ - public function getFieldFilterByIdentifier($fieldName, $id) - { - $filters = $this->getFiltersForField($fieldName); - - return isset($filters[$id]) ? $filters[$id] : null; - } - - /** - * @param array $config - */ - public function applyConfig(array $config) - { - foreach ($config as $fieldName => $filterConfig) { - if ($filterConfig === true) { - $this->addDefaultFilters($fieldName); - } elseif (ArrayLib::is_associative($filterConfig)) { - foreach ($filterConfig as $filterID => $include) { - if (!$include) { - continue; - } - $this->addFieldFilterByIdentifier($fieldName, $filterID); - } - } else { - throw new InvalidArgumentException(sprintf( - 'Filters on field "%s" must be a map of filter ID to a boolean value', - $fieldName - )); - } - } - } - - /** - * @param array $filters An array of Field__Filter => Value - * @return \Generator - */ - protected function getFieldFilters(array $filters) - { - foreach ($filters as $key => $val) { - $pos = strrpos($key, self::SEPARATOR); - // falsy is okay here because a leading __ is invalid. - if (!$pos) { - throw new InvalidArgumentException(sprintf( - 'Invalid filter %s. Must be a composite string of field name, filter identifier, separated by %s', - $key, - self::SEPARATOR - )); - } - $parts = explode(self::SEPARATOR, $key); - $filterIdentifier = array_pop($parts); - // If the field segment contained __, that implies relationship (dot notation) - $field = implode('.', $parts); - if (!isset($result[$field])) { - $result[$field] = []; - } - $filter = $this->getFieldFilterByIdentifier($field, $filterIdentifier); - if (!$filter instanceof FieldFilterInterface) { - $filter = $this->getFilterRegistry()->getFilterByIdentifier($filterIdentifier); - } - if (!$filter) { - throw new InvalidArgumentException(sprintf( - 'Invalid filter "%s".', - $filterIdentifier - )); - } - - yield [$filter, $field, $val]; - } - } - - /** - * Get a DBField, __ notation allowed. - * @param string $field - * @return DBField - */ - protected function getDBField($field) - { - $dbField = null; - if (stristr($field, self::SEPARATOR) !== false) { - list ($relationName, $relationField) = explode(self::SEPARATOR, $field); - $class = $this->getDataObjectInstance()->getRelationClass($relationName); - if (!$class) { - throw new InvalidArgumentException(sprintf( - 'Could not find relation %s on %s', - $relationName, - $this->getDataObjectClass() - )); - } - return Injector::inst()->get($class)->dbObject($relationField); - } - - return $this->getDataObjectInstance()->dbObject($field); - } -} diff --git a/src/QueryFilter/FieldFilterInterface.php b/src/QueryFilter/FieldFilterInterface.php deleted file mode 100644 index e5351a434..000000000 --- a/src/QueryFilter/FieldFilterInterface.php +++ /dev/null @@ -1,33 +0,0 @@ -filter($fieldName . ':PartialMatch', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':PartialMatch', $value); - } - - public function getIdentifier() - { - return 'contains'; - } -} diff --git a/src/QueryFilter/Filters/EndsWithFilter.php b/src/QueryFilter/Filters/EndsWithFilter.php deleted file mode 100644 index 9073e2170..000000000 --- a/src/QueryFilter/Filters/EndsWithFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':EndsWith', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':EndsWith', $value); - } - - public function getIdentifier() - { - return 'endswith'; - } -} diff --git a/src/QueryFilter/Filters/EqualToFilter.php b/src/QueryFilter/Filters/EqualToFilter.php deleted file mode 100644 index bd88ae74b..000000000 --- a/src/QueryFilter/Filters/EqualToFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':ExactMatch', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':ExactMatch', $value); - } - - public function getIdentifier() - { - return 'eq'; - } -} diff --git a/src/QueryFilter/Filters/GreaterThanFilter.php b/src/QueryFilter/Filters/GreaterThanFilter.php deleted file mode 100644 index d1e1103a5..000000000 --- a/src/QueryFilter/Filters/GreaterThanFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':GreaterThan', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':GreaterThan', $value); - } - - public function getIdentifier() - { - return 'gt'; - } -} diff --git a/src/QueryFilter/Filters/GreaterThanOrEqualFilter.php b/src/QueryFilter/Filters/GreaterThanOrEqualFilter.php deleted file mode 100644 index 135b241ac..000000000 --- a/src/QueryFilter/Filters/GreaterThanOrEqualFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':GreaterThanOrEqual', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':GreaterThanOrEqual', $value); - } - - public function getIdentifier() - { - return 'gte'; - } -} diff --git a/src/QueryFilter/Filters/InFilter.php b/src/QueryFilter/Filters/InFilter.php deleted file mode 100644 index 3097b41f4..000000000 --- a/src/QueryFilter/Filters/InFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':ExactMatch', (array) $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':ExactMatch', (array) $value); - } - - public function getIdentifier() - { - return 'in'; - } -} diff --git a/src/QueryFilter/Filters/LessThanFilter.php b/src/QueryFilter/Filters/LessThanFilter.php deleted file mode 100644 index 5b5dce5a1..000000000 --- a/src/QueryFilter/Filters/LessThanFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':LessThan', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':LessThan', $value); - } - - public function getIdentifier() - { - return 'lt'; - } -} diff --git a/src/QueryFilter/Filters/LessThanOrEqualFilter.php b/src/QueryFilter/Filters/LessThanOrEqualFilter.php deleted file mode 100644 index cee1991ac..000000000 --- a/src/QueryFilter/Filters/LessThanOrEqualFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':LessThanOrEqual', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':LessThanOrEqual', $value); - } - - public function getIdentifier() - { - return 'lte'; - } -} diff --git a/src/QueryFilter/Filters/StartsWithFilter.php b/src/QueryFilter/Filters/StartsWithFilter.php deleted file mode 100644 index ea1ae2491..000000000 --- a/src/QueryFilter/Filters/StartsWithFilter.php +++ /dev/null @@ -1,25 +0,0 @@ -filter($fieldName . ':StartsWith', $value); - } - - public function applyExclusion(DataList $list, $fieldName, $value) - { - return $list->exclude($fieldName . ':StartsWith', $value); - } - - public function getIdentifier() - { - return 'startswith'; - } -} diff --git a/src/QueryFilter/QueryFilterAware.php b/src/QueryFilter/QueryFilterAware.php deleted file mode 100644 index 8dda8eca5..000000000 --- a/src/QueryFilter/QueryFilterAware.php +++ /dev/null @@ -1,56 +0,0 @@ -queryFilter = $filter; - - return $this; - } - - /** - * @return DataObjectQueryFilter - */ - public function queryFilter() - { - return $this->queryFilter; - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - if ($this->queryFilter()->exists()) { - $manager->addType( - $this->queryFilter->getInputType( - $this->inputTypeName(Read::FILTER) - ) - ); - $manager->addType( - $this->queryFilter->getInputType( - $this->inputTypeName(Read::EXCLUDE) - ) - ); - } - - parent::addToManager($manager); - } -} diff --git a/src/QueryHandler/QueryHandler.php b/src/QueryHandler/QueryHandler.php new file mode 100644 index 000000000..3b7e112e9 --- /dev/null +++ b/src/QueryHandler/QueryHandler.php @@ -0,0 +1,282 @@ +queryAndReturnResult($schema, $query, $params); + + // Already in array form + if (is_array($executionResult)) { + return $executionResult; + } + return $this->serialiseResult($executionResult); + } + + /** + * @param GraphQLSchema $schema + * @param string $query + * @param array|null $params + * @return array|ExecutionResult + */ + public function queryAndReturnResult(GraphQLSchema $schema, string $query, ?array $params = []) + { + $context = $this->getContext(); + $last = function ($params) { + $schema = $params['schema']; + $query = $params['query']; + $context = $params['context']; + $params = $params['vars']; + return GraphQL::executeQuery($schema, $query, null, $context, $params); + }; + + return $this->callMiddleware($schema, $query, $context, $params, $last); + + } + + + /** + * get query from persisted id, return null if not found + * + * @param string $id + * @return string|null + */ + public function getQueryFromPersistedID(string $id): ?string + { + /** @var PersistedQueryMappingProvider $provider */ + $provider = Injector::inst()->get(PersistedQueryMappingProvider::class); + + return $provider->getByID($id); + } + + /** + * Get global context to pass to $context for all queries + * + * @return array + */ + public function getContext(): array + { + return array_merge( + $this->getContextDefaults(), + $this->extraContext + ); + } + + /** + * @param string $key + * @param mixed $value + * @return ContextProvider + */ + public function addContext(string $key, $value): ContextProvider + { + if (empty($key)) { + throw new InvalidArgumentException('Context key cannot be empty'); + } + $this->extraContext[$key] = $value; + + return $this; + } + + /** + * Serialise a Graphql result object for output + * + * @param ExecutionResult $executionResult + * @return array + */ + public function serialiseResult(ExecutionResult $executionResult): array + { + // Format object + if (!empty($executionResult->errors)) { + return [ + 'data' => $executionResult->data, + 'errors' => array_map($this->errorFormatter, $executionResult->errors), + ]; + } else { + return [ + 'data' => $executionResult->data, + ]; + } + } + + /** + * @param callable $errorFormatter + * @return QueryHandler + */ + public function setErrorFormatter(callable $errorFormatter): self + { + $this->errorFormatter = $errorFormatter; + return $this; + } + + /** + * @return array + */ + protected function getContextDefaults(): array + { + return [ + self::CURRENT_USER => $this->getMemberContext(), + ]; + } + + + /** + * Call middleware to evaluate a graphql query + * + * @param GraphQLSchema $schema + * @param string $query Query to invoke + * @param array $context + * @param array $variables Variables passed to this query + * @param callable $last The callback to call after all middlewares + * @return ExecutionResult|array + */ + protected function callMiddleware(GraphQLSchema $schema, $query, $context, $variables, callable $last) + { + + $params = [ + 'schema' => $schema, + 'query' => $query, + 'context' => $context, + 'vars' => $variables, + ]; + $result = $this->executeMiddleware($params, $last); + + return $result; + } + + /** + * More verbose error display defaults. + * + * @param Error $exception + * @return array + */ + public static function formatError(Error $exception): array + { + $error = [ + 'message' => $exception->getMessage(), + ]; + + if (Director::isDev()) { + $error['code'] = $exception->getCode(); + $error['file'] = $exception->getFile(); + $error['line'] = $exception->getLine(); + $error['trace'] = $exception->getTraceAsString(); + } + + + $locations = $exception->getLocations(); + if (!empty($locations)) { + $error['locations'] = array_map(function (SourceLocation $loc) { + return $loc->toArray(); + }, $locations); + } + + $previous = $exception->getPrevious(); + if ($previous && $previous instanceof ValidationException) { + $errorx['validation'] = $previous->getResult()->getMessages(); + } + + return $error; + } + + /** + * @param string $query + * @return bool + * @throws SyntaxError + */ + public static function isMutation(string $query): bool + { + // Simple string matching as a first check to prevent unnecessary static analysis + if (stristr($query, 'mutation') === false) { + return false; + } + + // If "mutation" is the first expression in the query, then it's a mutation. + if (preg_match('/^\s*'.preg_quote('mutation', '/').'/', $query)) { + return true; + } + + // Otherwise, bring in the big guns. + $document = Parser::parse(new Source($query ?: 'GraphQL')); + $defs = $document->definitions; + foreach ($defs as $statement) { + $options = [ + NodeKind::OPERATION_DEFINITION, + NodeKind::OPERATION_TYPE_DEFINITION + ]; + if (!in_array($statement->kind, $options, true)) { + continue; + } + if ($statement->operation === 'mutation') { + return true; + } + } + + return false; + } +} diff --git a/src/QueryHandler/QueryHandlerInterface.php b/src/QueryHandler/QueryHandlerInterface.php new file mode 100644 index 000000000..7628e02d9 --- /dev/null +++ b/src/QueryHandler/QueryHandlerInterface.php @@ -0,0 +1,26 @@ +configType(); - if (is_array($type)) { - return Injector::inst()->createWithArgs( - TypeParserInterface::class . '.array', - [ - StaticSchema::inst()->typeName(get_class($this->owner)), - $type - ] - ); - } - - return Injector::inst()->createWithArgs( - TypeParserInterface::class . '.string', - [(string) $type] - ); - } - - /** - * Creates the type using appropriate parser - * - * @param Manager|null $manager - * @return \GraphQL\Type\Definition\Type - */ - public function getGraphQLType(Manager $manager = null) - { - $type = $this->createTypeParser()->getType(); - $name = $type->name; - if ($manager && !$this->isInternal($name)) { - return $manager->getType($name); - } - - return $type; - } - - /** - * Returns true if the type parser creates an internal type e.g. String - * - * @return bool - */ - public function isInternalGraphQLType() - { - $type = $this->createTypeParser()->getType(); - - return $this->isInternal($type->name); - } - - /** - * Adds this object's GraphQL type to the Manager - * - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $parser = $this->createTypeParser(); - $type = $parser->getType(); - if ($this->isInternal($type->name)) { - return; - } - $manager->addType($type, $parser->getName()); - } - - /** - * Gets the graphql type from config - * - * @return string - */ - protected function configType() - { - return Config::inst()->get(get_class($this->owner), 'graphql_type'); - } - - /** - * Returns true if the named of the type is an internal one, e.g. "String" - * - * @param string $typeName - * @return bool - */ - protected function isInternal($typeName) - { - return is_scalar($typeName) && StringTypeParser::isInternalType($typeName); - } -} diff --git a/src/Scaffolding/Interfaces/CRUDInterface.php b/src/Scaffolding/Interfaces/CRUDInterface.php deleted file mode 100644 index e7c6570c0..000000000 --- a/src/Scaffolding/Interfaces/CRUDInterface.php +++ /dev/null @@ -1,11 +0,0 @@ -argName = $argName; - $parser = Injector::inst()->createWithArgs( - TypeParserInterface::class . '.string', - [$typeStr] - ); - $this->defaultValue = $parser->getDefaultValue(); - $this->type = $parser->getType(false); - $this->required = $parser->isRequired(); - } - - /** - * Sets the argument as required - * - * @param boolean $bool - * @return $this - */ - public function setRequired($bool) - { - $this->required = (boolean) $bool; - - return $this; - } - - /** - * Sets the argument description - * - * @param string $description - * @return $this - */ - public function setDescription($description) - { - $this->description = $description; - - return $this; - } - - /** - * Sets the default value of the argument - * - * @param mixed $value - * @return $this - */ - public function setDefaultValue($value) - { - $this->defaultValue = $value; - - return $this; - } - - /** - * @return string - */ - public function getDescription() - { - return $this->description; - } - - /** - * @return mixed - */ - public function getDefaultValue() - { - return $this->defaultValue; - } - - /** - * @return boolean - */ - public function isRequired() - { - return $this->required; - } - - /** - * Applies an array of configuration to the scaffolder - * @param array $config - * @return void - */ - public function applyConfig(array $config) - { - if (isset($config['description'])) { - $this->description = $config['description']; - } - - if (isset($config['default'])) { - $this->defaultValue = $config['default']; - } - - if (isset($config['required'])) { - $this->required = (boolean) $config['required']; - } - } - - /** - * Creates an array suitable for a map of args in a field - * @param Manager $manager - * @return array - */ - public function toArray(Manager $manager = null) - { - $typeValue = null; - $type = $this->type; - if (!$type instanceof Type) { - if (!$manager) { - throw new InvalidArgumentException(sprintf( - 'Custom type %s provided, but no %s instance was given to %s', - $type, - Manager::class, - __CLASS__ - )); - } - $type = $manager->getType($type); - } - - $args = [ - 'description' => $this->description, - 'type' => $this->required ? Type::nonNull($type) : $type, - ]; - - if ($this->defaultValue !== null) { - $args['defaultValue'] = $this->defaultValue; - } - - return $args; - } -} diff --git a/src/Scaffolding/Scaffolders/CRUD/Create.php b/src/Scaffolding/Scaffolders/CRUD/Create.php deleted file mode 100644 index 1b1566300..000000000 --- a/src/Scaffolding/Scaffolders/CRUD/Create.php +++ /dev/null @@ -1,139 +0,0 @@ -getTypeName()); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $manager->addType($this->generateInputType($manager)); - parent::addToManager($manager); - } - - /** - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - return [ - 'Input' => [ - 'type' => Type::nonNull($manager->getType($this->inputTypeName())), - ] - ]; - } - - /** - * @param Manager $manager - * @return InputObjectType - */ - protected function generateInputType(Manager $manager) - { - return new InputObjectType([ - 'name' => $this->inputTypeName(), - 'fields' => function () use ($manager) { - $fields = []; - $instance = $this->getDataObjectInstance(); - - // Setup default input args.. Placeholder! - $schema = Injector::inst()->get(DataObjectSchema::class); - $db = $schema->fieldSpecs($this->getDataObjectClass()); - - unset($db['ID']); - - foreach ($db as $dbFieldName => $dbFieldType) { - /** @var DBField|TypeCreatorExtension $result */ - $result = $instance->obj($dbFieldName); - // Skip complex fields, e.g. composite, as that would require scaffolding a new input type. - if (!$result->isInternalGraphQLType()) { - continue; - } - $arr = [ - 'type' => $result->getGraphQLType($manager), - ]; - $fields[$dbFieldName] = $arr; - } - - return $fields; - }, - ]); - } - - /** - * @return string - */ - protected function inputTypeName() - { - return $this->getTypeName() . 'CreateInputType'; - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - // Todo: this is totally half baked - $singleton = $this->getDataObjectInstance(); - if (!$singleton->canCreate($context['currentUser'], $context)) { - throw new Exception("Cannot create {$this->getDataObjectClass()}"); - } - - /** @var DataObject $newObject */ - $newObject = Injector::inst()->create($this->getDataObjectClass()); - $newObject->update($args['Input']); - - // Extension points that return false should kill the create - $results = $this->extend('augmentMutation', $newObject, $args, $context, $info); - if (in_array(false, $results, true)) { - return null; - } - - // Save and return - $newObject->write(); - $newObject = DataObject::get_by_id($this->getDataObjectClass(), $newObject->ID); - - $this->extend('afterMutation', $newObject, $args, $context, $info); - - return $newObject; - } -} diff --git a/src/Scaffolding/Scaffolders/CRUD/Delete.php b/src/Scaffolding/Scaffolders/CRUD/Delete.php deleted file mode 100644 index 207e31eab..000000000 --- a/src/Scaffolding/Scaffolders/CRUD/Delete.php +++ /dev/null @@ -1,100 +0,0 @@ -getTypeName()); - } - - /** - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - return [ - 'IDs' => [ - 'type' => Type::nonNull($this->generateInputType()), - ], - ]; - } - - /** - * @return ListOfType - */ - protected function generateInputType() - { - return Type::listOf(Type::id()); - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - DB::get_conn()->withTransaction(function () use ($args, $context, $info) { - // Build list to filter - $results = DataList::create($this->getDataObjectClass()) - ->byIDs($args['IDs']); - $extensionResults = $this->extend('augmentMutation', $results, $args, $context, $info); - - // Extension points that return false should kill the deletion - if (in_array(false, $extensionResults, true)) { - return; - } - - // Before deleting, check if any items fail canDelete() - /** @var DataObject[] $resultsList */ - $resultsList = $results->toArray(); - foreach ($resultsList as $obj) { - if (!$obj->canDelete($context['currentUser'])) { - throw new Exception(sprintf( - 'Cannot delete %s with ID %s', - $this->getDataObjectClass(), - $obj->ID - )); - } - } - - // Delete - foreach ($resultsList as $obj) { - $obj->delete(); - } - - $this->extend('afterMutation', $resultsList, $args, $context, $info); - }); - } -} diff --git a/src/Scaffolding/Scaffolders/CRUD/Read.php b/src/Scaffolding/Scaffolders/CRUD/Read.php deleted file mode 100644 index dec46158d..000000000 --- a/src/Scaffolding/Scaffolders/CRUD/Read.php +++ /dev/null @@ -1,172 +0,0 @@ -create(DataObjectQueryFilter::class, $dataObjectClass) - ->setFilterKey(self::FILTER) - ->setExcludeKey(self::EXCLUDE); - $this->setQueryFilter($filter); - } - - /** - * @param array $args - * @return DataList - */ - protected function getResults($args) - { - $list = DataList::create($this->getDataObjectClass()); - if (!$this->queryFilter->exists()) { - return $list; - } - return $this->queryFilter->applyArgsToList($list, $args); - } - - /** - * @param DataObjectQueryFilter $filter - * @return $this - */ - public function setQueryFilter(DataObjectQueryFilter $filter) - { - $this->queryFilter = $filter; - - return $this; - } - - /** - * A "find or make" API useful for the fluent declarations in scaffolding code. - * @return DataObjectQueryFilter - */ - public function queryFilter() - { - return $this->queryFilter; - } - - /** - * @return string - */ - public function getName() - { - $name = parent::getName(); - if ($name) { - return $name; - } - - $typePlural = $this->pluralise($this->getTypeName()); - return 'read' . ucfirst($typePlural); - } - - /** - * @param DataObjectInterface $object - * @param array $args - * @param array $context - * @param ResolveInfo $info - * @return mixed - * @throws Exception - */ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $list = $this->getResults($args); - $this->extend('updateList', $list, $args, $context, $info); - return $list; - } - - /** - * Pluralise a name - * - * @param string $typeName - * @return string - */ - protected function pluralise($typeName) - { - // Ported from DataObject::plural_name() - if (preg_match('/[^aeiou]y$/i', $typeName)) { - $typeName = substr($typeName, 0, -1) . 'ie'; - } - $typeName .= 's'; - return $typeName; - } - - /** - * Use a generated Input type, and require an ID. - * - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - if (!$this->queryFilter->exists()) { - return []; - } - return [ - self::FILTER => [ - 'type' => $manager->getType($this->inputTypeName(self::FILTER)), - ], - self::EXCLUDE => [ - 'type' => $manager->getType($this->inputTypeName(self::EXCLUDE)), - ], - ]; - } - - /** - * @param string $key - * @return string - */ - protected function inputTypeName($key = '') - { - return $this->getTypeName() . $key . 'ReadInputType'; - } - - - public function applyConfig(array $config) - { - parent::applyConfig($config); - - if (isset($config['filters'])) { - if ($config['filters'] === SchemaScaffolder::ALL) { - $this->queryFilter->addAllFilters(); - } else { - if (is_array($config['filters'])) { - $this->queryFilter->applyConfig($config['filters']); - } else { - throw new InvalidArgumentException(sprintf( - 'Config setting "filters" must be an array mapping field names to a list of filter identifiers, or %s for all', - SchemaScaffolder::ALL - )); - } - } - } - } -} diff --git a/src/Scaffolding/Scaffolders/CRUD/ReadOne.php b/src/Scaffolding/Scaffolders/CRUD/ReadOne.php deleted file mode 100644 index 2bd7a863d..000000000 --- a/src/Scaffolding/Scaffolders/CRUD/ReadOne.php +++ /dev/null @@ -1,138 +0,0 @@ -create(DataObjectQueryFilter::class, $dataObjectClass) - ->setFilterKey(Read::FILTER) - ->setExcludeKey(Read::EXCLUDE); - $this->setQueryFilter($filter); - } - - public function getName() - { - $name = parent::getName(); - if ($name) { - return $name; - } - - return 'readOne' . ucfirst($this->getTypeName()); - } - - /** - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - $args = [ - 'ID' => [ - 'type' => Type::id() - ], - ]; - - if ($this->queryFilter->exists()) { - $args[Read::FILTER] = [ - 'type' => $this->queryFilter->getInputType($this->inputTypeName(Read::FILTER)), - ]; - $args[Read::EXCLUDE] = [ - 'type' => $this->queryFilter->getInputType($this->inputTypeName(Read::EXCLUDE)), - ]; - } - - return $args; - } - - /** - * @param string $key - * @return string - */ - protected function inputTypeName($key = '') - { - return $this->getTypeName() . $key . 'ReadOneInputType'; - } - - /** - * @param DataObjectInterface $object - * @param array $args - * @param array $context - * @param ResolveInfo $info - * @return mixed - * @throws Exception - */ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - // get as a list so extensions can influence it pre-query - $list = DataList::create($this->getDataObjectClass()); - if (isset($args['ID'])) { - $list = $list->filter('ID', $args['ID']); - } - if ($this->queryFilter->exists()) { - $list = $this->queryFilter->applyArgsToList($list, $args); - } - $this->extend('updateList', $list, $args, $context, $info); - - // Fall back to getting an empty singleton to use for permission checking - $item = $list->first() ?: $this->getDataObjectInstance(); - - // Check permissions on the individual item as some permission checks may investigate saved state - $checker = $this->getPermissionChecker(); - if ($checker && !$checker->checkItem($item, $context['currentUser'])) { - throw new Exception(sprintf( - 'Cannot view %s', - $this->getDataObjectClass() - )); - } - - return $list->first(); - } - - public function applyConfig(array $config) - { - parent::applyConfig($config); - - if (isset($config['filters'])) { - if ($config['filters'] === SchemaScaffolder::ALL) { - $this->queryFilter->addAllFilters(); - } else { - if (is_array($config['filters'])) { - $this->queryFilter->applyConfig($config['filters']); - } else { - throw new InvalidArgumentException(sprintf( - 'Config setting "filters" must be an array mapping field names to a list of filter identifiers, or %s for all', - SchemaScaffolder::ALL - )); - } - } - } - } -} diff --git a/src/Scaffolding/Scaffolders/CRUD/Update.php b/src/Scaffolding/Scaffolders/CRUD/Update.php deleted file mode 100644 index 2044cdf61..000000000 --- a/src/Scaffolding/Scaffolders/CRUD/Update.php +++ /dev/null @@ -1,161 +0,0 @@ -getTypeName()); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $manager->addType($this->generateInputType($manager)); - parent::addToManager($manager); - } - - /** - * Use a generated Input type, and require an ID. - * - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - return [ - 'Input' => [ - 'type' => Type::nonNull($manager->getType($this->inputTypeName())), - ], - ]; - } - - /** - * Based on the args provided, create an Input type to add to the Manager. - * @param Manager $manager - * @return InputObjectType - */ - protected function generateInputType(Manager $manager) - { - return new InputObjectType([ - 'name' => $this->inputTypeName(), - 'fields' => function () use ($manager) { - $fields = [ - 'ID' => [ - 'type' => Type::nonNull(Type::id()), - ], - ]; - $instance = $this->getDataObjectInstance(); - - // Setup default input args.. Placeholder! - $schema = Injector::inst()->get(DataObjectSchema::class); - $db = $schema->fieldSpecs($this->getDataObjectClass()); - - unset($db['ID']); - - foreach ($db as $dbFieldName => $dbFieldType) { - /** @var DBField|TypeCreatorExtension $result */ - $result = $instance->obj($dbFieldName); - // Skip complex fields, e.g. composite, as that would require scaffolding a new input type. - if (!$result->isInternalGraphQLType()) { - continue; - } - $arr = [ - 'type' => $result->getGraphQLType($manager), - ]; - $fields[$dbFieldName] = $arr; - } - return $fields; - } - ]); - } - - /** - * @return string - */ - protected function inputTypeName() - { - return $this->getTypeName() . 'UpdateInputType'; - } - - /** - * @param DataObjectInterface $object - * @param array $args - * @param array $context - * @param ResolveInfo $info - * @return mixed - * @throws Exception - */ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $input = $args['Input']; - $obj = DataList::create($this->getDataObjectClass()) - ->byID($input['ID']); - if (!$obj) { - throw new Exception(sprintf( - '%s with ID %s not found', - $this->getDataObjectClass(), - $input['ID'] - )); - } - unset($input['ID']); - if (!$obj->canEdit($context['currentUser'])) { - throw new Exception(sprintf( - 'Cannot edit this %s', - $this->getDataObjectClass() - )); - } - - // Extension points that return false should kill the write operation - $results = $this->extend('augmentMutation', $obj, $args, $context, $info); - if (in_array(false, $results, true)) { - return $obj; - } - - $obj->update($input); - $obj->write(); - - $this->extend('afterMutation', $obj, $args, $context, $info); - - return $obj; - } -} diff --git a/src/Scaffolding/Scaffolders/DataObjectScaffolder.php b/src/Scaffolding/Scaffolders/DataObjectScaffolder.php deleted file mode 100644 index 5cbe943f3..000000000 --- a/src/Scaffolding/Scaffolders/DataObjectScaffolder.php +++ /dev/null @@ -1,735 +0,0 @@ - 'ID', - ]; - - /** - * @var ArrayList - */ - protected $fields; - - /** - * @var OperationList - */ - protected $operations; - - /** - * @var OperationList - */ - protected $nestedQueries = []; - - /** - * DataObjectScaffold constructor. - * - * @param string $dataObjectClass - */ - public function __construct($dataObjectClass) - { - $this->fields = ArrayList::create([]); - $this->operations = OperationList::create([]); - $this->setDataObjectClass($dataObjectClass); - } - - /** - * Name of graphql type - * - * @return string - */ - public function getTypeName() - { - return $this->typeName(); - } - - /** - * Adds visible fields, and optional descriptions. - * - * Ex: - * [ - * 'MyField' => 'Some description', - * 'MyOtherField' // No description - * ] - * - * @param array $fieldData - * @return $this - */ - public function addFields(array $fieldData) - { - foreach ($fieldData as $k => $data) { - $assoc = !is_numeric($k); - $name = $assoc ? $k : $data; - $field = ArrayData::create( - [ - 'Name' => $name, - 'Description' => $assoc ? $data : null, - ] - ); - $this->removeField($name); - $this->fields->add($field); - } - - return $this; - } - - /** - * @param string $field - * @param string $description - * @return $this - */ - public function addField($field, $description = null) - { - return $this->addFields([$field => $description]); - } - - /** - * Adds all db fields, and optionally has_one. - * - * @param bool $includeHasOne - * - * @return $this - */ - public function addAllFields($includeHasOne = false) - { - $fields = $this->allFieldsFromDataObject($includeHasOne); - - return $this->addFields($fields); - } - - /** - * Adds fields against a blacklist. - * - * @param array|string $exclusions - * @param bool $includeHasOne - * - * @return $this - */ - public function addAllFieldsExcept($exclusions, $includeHasOne = false) - { - if (!is_array($exclusions)) { - $exclusions = [$exclusions]; - } - $fields = $this->allFieldsFromDataObject($includeHasOne); - $filteredFields = array_diff($fields, $exclusions); - - return $this->addFields($filteredFields); - } - - /** - * @param string $field - * @return $this - */ - public function removeField($field) - { - return $this->removeFields([$field]); - } - - /** - * @param array $fields - * @return $this - */ - public function removeFields(array $fields) - { - $this->fields = $this->fields->exclude('Name', $fields); - - return $this; - } - - /** - * @return ArrayList - */ - public function getFields() - { - return $this->fields; - } - - /** - * @return OperationList - */ - public function getOperations() - { - return $this->operations; - } - - /** - * @return OperationList - */ - public function getNestedQueries() - { - return $this->nestedQueries; - } - - /** - * Sets the description to an existing field. - * - * @param string $field - * @param string $description - * @return $this - */ - public function setFieldDescription($field, $description) - { - $existing = $this->fields->find('Name', $field); - if (!$existing) { - throw new InvalidArgumentException( - sprintf( - 'Cannot set description of %s. It has not been added to %s.', - $field, - $this->getDataObjectClass() - ) - ); - } - - $this->fields->replace( - $existing, - ArrayData::create( - [ - 'Name' => $field, - 'Description' => $description - ] - ) - ); - - return $this; - } - - /** - * Gets the Description property from a field, given a name - * - * @param string $field - * @return string - * @throws Exception - */ - public function getFieldDescription($field) - { - $item = $this->fields->find('Name', $field); - - if (!$item) { - throw new Exception( - sprintf( - 'Tried to get field description for %s, but it has not been added to %s', - $field, - $this->getDataObjectClass() - ) - ); - } - - return $item->Description; - } - - /** - * Removes an operation. - * - * @param string $identifier - * @return $this - */ - public function removeOperation($identifier) - { - $this->operations->removeByIdentifier($identifier); - - return $this; - } - - /** - * Adds all operations that are registered - * - * @return $this - */ - public function addAllOperations() - { - foreach (OperationScaffolder::getOperations() as $id => $operation) { - $this->operation($id); - } - return $this; - } - - /** - * Find or make an operation. - * - * @param string $operation - * - * @return OperationScaffolder - */ - public function operation($operation) - { - $existing = $this->operations->findByIdentifier($operation); - - if ($existing) { - return $existing; - } - - $scaffoldClass = OperationScaffolder::getClassFromIdentifier($operation); - if (!$scaffoldClass) { - throw new InvalidArgumentException( - sprintf( - 'Invalid operation: %s added to %s', - $operation, - $this->getDataObjectClass() - ) - ); - } - /** - * @var OperationScaffolder $scaffolder - */ - $scaffolder = Injector::inst()->createWithArgs($scaffoldClass, [$this->getDataObjectClass()]); - - $this->operations->push( - $scaffolder->setChainableParent($this) - ); - - return $scaffolder; - } - - - /** - * Finds or adds a nested query, e.g. has_many/many_many relation, or a query created - * with a custom scaffolder - * - * @param string $fieldName - * @param QueryScaffolder $queryScaffolder - * @return OperationScaffolder|ListQueryScaffolder - */ - public function nestedQuery($fieldName, QueryScaffolder $queryScaffolder = null) - { - $query = isset($this->nestedQueries[$fieldName]) ? $this->nestedQueries[$fieldName] : null; - - if ($query) { - return $query; - } - - if (!$queryScaffolder) { - // If no scaffolder if provided, try to infer the type by resolving the field - $result = $this->getDataObjectInstance()->obj($fieldName); - - if (!$result instanceof DataList && !$result instanceof ArrayList) { - throw new InvalidArgumentException( - sprintf( - '%s::nestedQueries tried to add %s, but must be passed a method name or relation that returns a DataList or ArrayList', - __CLASS__, - $fieldName - ) - ); - } - - $queryScaffolder = Injector::inst()->create( - ListQueryScaffolder::class, - $fieldName, - null, - function ($obj) use ($fieldName) { - /* @var DataObject $obj */ - return $obj->obj($fieldName); - }, - $result->dataClass() - ); - } - - $queryScaffolder->setChainableParent($this); - $queryScaffolder->setNested(true); - $this->nestedQueries[$fieldName] = $queryScaffolder; - - return $queryScaffolder; - } - - /** - * Gets types for all ancestors of this class that will need to be added. - * - * @return array - */ - public function getDependentClasses() - { - return array_merge( - array_values($this->nestedDataObjectClasses()), - array_values($this->nestedConnections()) - ); - } - - /** - * Gets the class ancestry back to DataObject. - * - * @return array - * @deprecated 2.0.0..3.0.0 Use StaticSchema::getAncestry($class) instead - */ - public function getAncestralClasses() - { - Deprecation::notice('3.0', 'Use StaticSchema::getAncestry($class) instead'); - - return StaticSchema::inst()->getAncestry($this->getDataObjectClass()); - } - - /** - * Clones this scaffolder to another class, copying over only valid fields and operations - * @param DataObjectScaffolder $target - * @return DataObjectScaffolder - */ - public function cloneTo(DataObjectScaffolder $target) - { - $inst = $target->getDataObjectInstance(); - - foreach ($this->getFields() as $field) { - if (StaticSchema::inst()->isValidFieldName($inst, $field->Name)) { - $target->addField($field->Name, $field->Description); - } - } - foreach ($this->getOperations() as $op) { - if (!$op->getCloneable()) { - continue; - } - - $identifier = OperationScaffolder::getIdentifier($op); - $target->operation($identifier); - } - - return $target; - } - - /** - * Applies settings from an array, i.e. YAML - * - * @param array $config - * @return $this - * @throws Exception - */ - public function applyConfig(array $config) - { - $dataObjectClass = $this->getDataObjectClass(); - if (empty($config['fields']) && empty($config['nestedQueries'])) { - throw new Exception( - "No fields or nestedQueries defined for $dataObjectClass" - ); - } - if (isset($config['fields'])) { - if ($config['fields'] === SchemaScaffolder::ALL) { - $this->addAllFields(true); - } elseif (is_array($config['fields'])) { - $this->addFields($config['fields']); - } else { - throw new Exception( - sprintf( - "Fields must be an array, or '%s' for all fields in $dataObjectClass", - SchemaScaffolder::ALL - ) - ); - } - } - - if (isset($config['excludeFields'])) { - if (!is_array($config['excludeFields']) || ArrayLib::is_associative($config['excludeFields'])) { - throw new InvalidArgumentException( - sprintf( - '"excludeFields" must be an enumerated list of fields. See %s', - $this->getDataObjectClass() - ) - ); - } - - $this->removeFields($config['excludeFields']); - } - - if (isset($config['fieldDescriptions'])) { - if (!ArrayLib::is_associative($config['fieldDescriptions'])) { - throw new InvalidArgumentException( - sprintf( - '"fieldDescripions" must be a map of field name to description. See %s', - $this->getDataObjectClass() - ) - ); - } - - foreach ($config['fieldDescriptions'] as $fieldName => $description) { - $this->setFieldDescription($fieldName, $description); - } - } - - if (isset($config['operations'])) { - if ($config['operations'] === SchemaScaffolder::ALL) { - $config['operations'] = []; - foreach (OperationScaffolder::getOperations() as $id => $operation) { - $config['operations'][$id] = true; - } - } - - if (!ArrayLib::is_associative($config['operations'])) { - throw new Exception( - 'Operations field must be a map of operation names to a map of settings, or true/false' - ); - } - - foreach ($config['operations'] as $opID => $opSettings) { - if ($opSettings === false) { - continue; - } - $this->operation($opID) - ->applyConfig((array)$opSettings); - } - } - - if (isset($config['nestedQueries'])) { - if (!ArrayLib::is_associative($config['nestedQueries'])) { - throw new InvalidArgumentException( - sprintf( - '"nestedQueries" must be a map of relation name to a map of settings, or true/false. See %s', - $this->getDataObjectClass() - ) - ); - } - - foreach ($config['nestedQueries'] as $relationName => $settings) { - if ($settings === false) { - continue; - } elseif (is_string($settings)) { - if (is_subclass_of($settings, QueryScaffolder::class)) { - $queryScaffolder = new $settings($relationName); - $this->nestedQuery($relationName, $queryScaffolder); - } else { - throw new InvalidArgumentException(sprintf( - 'Tried to specify %s as a custom query scaffolder for %s on %s, but it is not a subclass of %s.', - $settings, - $relationName, - $this->getDataObjectClass(), - QueryScaffolder::class - )); - } - } else { - $this->nestedQuery($relationName) - ->applyConfig((array)$settings); - } - } - } - - return $this; - } - - /** - * @param Manager $manager - * - * @return ObjectType - */ - public function scaffold(Manager $manager) - { - return new ObjectType( - [ - 'name' => $this->getTypeName(), - 'fields' => function () use ($manager) { - return $this->createFields($manager); - }, - ] - ); - } - - /** - * Adds the type to the Manager. - * - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $this->extend('onBeforeAddToManager', $manager); - $scaffold = $this->scaffold($manager); - if (!$manager->hasType($this->getTypeName())) { - $manager->addType($scaffold, $this->getTypeName()); - } - - foreach ($this->operations as $op) { - $op->addToManager($manager); - } - - foreach ($this->nestedQueries as $scaffold) { - $scaffold->addToManager($manager); - } - - $this->extend('onAfterAddToManager', $manager); - } - - /** - * @param bool $includeHasOne - * - * @return array - */ - protected function allFieldsFromDataObject($includeHasOne = false) - { - $fields = []; - $db = DataObject::config()->get('fixed_fields'); - $extra = Config::inst()->get($this->getDataObjectClass(), 'db'); - if ($extra) { - $db = array_merge($db, $extra); - } - - foreach ($db as $fieldName => $type) { - $fields[] = $fieldName; - } - - if ($includeHasOne) { - $hasOne = $this->getDataObjectInstance()->hasOne(); - foreach ($hasOne as $fieldName => $class) { - $fields[] = $fieldName; - } - } - - return $fields; - } - - /** - * Gets any DataObjects that are implicitly required by this type definition, e.g. has_one, has_many. - * - * @return array - */ - protected function nestedDataObjectClasses() - { - $types = []; - $instance = $this->getDataObjectInstance(); - $fields = $this->fields->column('Name'); - - foreach ($fields as $fieldName) { - $result = $instance->obj($fieldName); - if ($result instanceof DataObjectInterface) { - $types[$fieldName] = get_class($result); - } - } - - return $types; - } - - /** - * Gets the list of class names that are in nested queries - * - * @return array - */ - protected function nestedConnections() - { - $queries = []; - $inst = $this->getDataObjectInstance(); - foreach ($this->nestedQueries as $name => $q) { - $result = $inst->obj($name); - if ($result instanceof DataList || $result instanceof UnsavedRelationList) { - $queries[$name] = $result->dataClass(); - } - } - - return $queries; - } - - /** - * Validates the raw field map and creates a map suitable for ObjectType - * - * @param Manager $manager - * @return array - */ - protected function createFields(Manager $manager) - { - $fieldMap = []; - $instance = $this->getDataObjectInstance(); - $extraDataObjects = $this->nestedDataObjectClasses(); - $this->fields->removeDuplicates('Name'); - - if (!$this->fields->exists()) { - $this->addFields( - Config::inst()->get(self::class, 'default_fields') - ); - } - - $resolver = function ($obj, $args, $context, $info) { - /** - * @var DataObject $obj - */ - $field = $obj->obj($info->fieldName); - // return the raw field value, or checks like `is_numeric()` fail - if ($field instanceof DBField && $field->isInternalGraphQLType()) { - return $field->getValue(); - } - return $field; - }; - - foreach ($this->fields as $fieldData) { - $fieldName = $fieldData->Name; - if (!StaticSchema::inst()->isValidFieldName($instance, $fieldName)) { - throw new InvalidArgumentException( - sprintf( - 'Invalid field "%s" on %s', - $fieldName, - $this->getDataObjectClass() - ) - ); - } - - $result = $instance->obj($fieldName); - - if ($result instanceof SS_List) { - throw new InvalidArgumentException( - sprintf( - 'Fieldname %s added to %s returns a list. This should be defined as a nested query using nestedQueries', - $fieldName, - $this->getDataObjectClass(), - $fieldName - ) - ); - } - - if ($result instanceof DBField) { - /** @var DBField|TypeCreatorExtension $result */ - $fieldMap[$fieldName] = []; - $fieldMap[$fieldName]['type'] = $result->getGraphQLType($manager); - $fieldMap[$fieldName]['resolve'] = $resolver; - $fieldMap[$fieldName]['description'] = $fieldData->Description; - } - } - - foreach ($extraDataObjects as $fieldName => $className) { - $description = $this->getFieldDescription($fieldName); - $fieldMap[$fieldName] = [ - 'type' => StaticSchema::inst()->fetchFromManager($className, $manager), - 'description' => $description, - 'resolve' => $resolver, - ]; - } - - foreach ($this->nestedQueries as $name => $scaffolder) { - $scaffold = $scaffolder->scaffold($manager); - $scaffold['name'] = $name; - $fieldMap[$name] = $scaffold; - } - - return $fieldMap; - } -} diff --git a/src/Scaffolding/Scaffolders/InheritanceScaffolder.php b/src/Scaffolding/Scaffolders/InheritanceScaffolder.php deleted file mode 100644 index 6b86ea4e7..000000000 --- a/src/Scaffolding/Scaffolders/InheritanceScaffolder.php +++ /dev/null @@ -1,146 +0,0 @@ -setRootClass($rootDataObjectClass); - $this->setSuffix($suffix); - - parent::__construct( - $this->generateTypeName(), - $this->getTypes() - ); - } - - /** - * @return string - */ - public function getRootClass() - { - - return $this->rootClass; - } - - /** - * @param string $rootClass - * @return InheritanceScaffolder - */ - public function setRootClass($rootClass) - { - if (!class_exists($rootClass)) { - throw new InvalidArgumentException(sprintf( - 'Class %s does not exist.', - $rootClass - )); - } - - if (!is_subclass_of($rootClass, DataObject::class)) { - throw new InvalidArgumentException(sprintf( - 'Class %s is not a subclass of %s.', - $rootClass, - DataObject::class - )); - } - - $this->rootClass = $rootClass; - - return $this; - } - - /** - * @return string - */ - public function getSuffix() - { - return $this->suffix; - } - - /** - * @param string $suffix - * @return $this - */ - public function setSuffix($suffix) - { - $this->suffix = $suffix; - - return $this; - } - - /** - * Get all the GraphQL types in the ancestry - * @return array - */ - public function getTypes() - { - $schema = StaticSchema::inst(); - $tree = array_merge( - [$this->rootClass], - $schema->getDescendants($this->rootClass) - ); - - return array_map(function ($class) use ($tree, $schema) { - return $schema->typeNameForDataObject($class); - }, $tree); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $types = $this->getTypes(); - if (sizeof($types) === 1) { - return; - } - - $manager->addType( - $this->scaffold($manager), - $this->getName() - ); - } - - /** - * @return string - */ - protected function generateTypeName() - { - $prefix = StaticSchema::inst()->typeNameForDataObject($this->rootClass); - - return $prefix . $this->suffix; - } -} diff --git a/src/Scaffolding/Scaffolders/ItemQueryScaffolder.php b/src/Scaffolding/Scaffolders/ItemQueryScaffolder.php deleted file mode 100644 index eff65ccbe..000000000 --- a/src/Scaffolding/Scaffolders/ItemQueryScaffolder.php +++ /dev/null @@ -1,59 +0,0 @@ -getPermissionChecker(); - if (!$checker) { - return $resolverFn; - } - - return function ($obj, array $args, $context, ResolveInfo $info) use ($resolverFn, $checker) { - $item = call_user_func_array($resolverFn, func_get_args()); - $currentUser = $context['currentUser']; - if (!$checker->checkItem($item, $currentUser)) { - throw new Exception(sprintf( - 'Cannot view %s', - $this->getDataObjectClass() - )); - } - - return $item; - }; - } - - /** - * @param Manager $manager - * - * @return array - */ - public function scaffold(Manager $manager) - { - return [ - 'name' => $this->getName(), - 'description' => $this->getDescription(), - 'args' => $this->createArgs($manager), - 'type' => $this->getType($manager), - 'resolve' => $this->createResolverFunction(), - ]; - } -} diff --git a/src/Scaffolding/Scaffolders/ListQueryScaffolder.php b/src/Scaffolding/Scaffolders/ListQueryScaffolder.php deleted file mode 100644 index d52c33111..000000000 --- a/src/Scaffolding/Scaffolders/ListQueryScaffolder.php +++ /dev/null @@ -1,260 +0,0 @@ -usePagination = (bool) $bool; - return $this; - } - - /** - * @return int - */ - public function getPaginationLimit() - { - return $this->defaultLimit; - } - - /** - * @param int $int - * @return $this - */ - public function setPaginationLimit($int) - { - if ((int) $int > $this->maximumLimit) { - $int = $this->maximumLimit; - } - $this->defaultLimit = (int) $int; - - return $this; - } - - /** - * @return int - */ - public function getMaximumPaginationLimit() - { - return $this->maximumLimit; - } - - /** - * @param int $int - * @return $this - */ - public function setMaximumPaginationLimit($int) - { - $this->maximumLimit = (int) $int; - if ($this->getPaginationLimit() > (int) $int) { - $this->setPaginationLimit($int); - } - - return $this; - } - - /** - * @param array $fields - * @return $this - */ - public function addSortableFields($fields) - { - $this->sortableFields = array_unique( - array_merge( - $this->sortableFields, - (array)$fields - ) - ); - - return $this; - } - - /** - * @return array - */ - public function getSortableFields() - { - return $this->sortableFields; - } - - /** - * @param array $config - * @return $this - */ - public function applyConfig(array $config) - { - parent::applyConfig($config); - if (isset($config['sortableFields'])) { - $fields = $config['sortableFields']; - if (is_array($fields)) { - $this->addSortableFields($fields); - } else { - throw new InvalidArgumentException(sprintf( - 'sortableFields must be an array (see %s)', - $this->getTypeName() - )); - } - } - if (isset($config['paginate'])) { - $paginate = $config['paginate']; - $this->setUsePagination($paginate); - - if (isset($paginate['maximumLimit'])) { - $this->setMaximumPaginationLimit($paginate['maximumLimit']); - } - - if (isset($paginate['limit'])) { - $this->setPaginationLimit($paginate['limit']); - } elseif (isset($paginate['defaultLimit'])) { - $this->setPaginationLimit($paginate['defaultLimit']); - } - } - - return $this; - } - - /** - * @param Manager $manager - * @throws Exception - */ - public function addToManager(Manager $manager) - { - if ($this->usePagination) { - $paginationScaffolder = $this->getPaginationScaffolder($manager); - $paginationScaffolder->addToManager($manager); - } - - parent::addToManager($manager); - } - - /** - * @param Manager $manager - * - * @return array - */ - public function scaffold(Manager $manager) - { - if ($this->usePagination) { - $paginationScaffolder = $this->getPaginationScaffolder($manager); - - return $paginationScaffolder->scaffold($manager); - } - - return [ - 'name' => $this->getName(), - 'description' => $this->getDescription(), - 'args' => $this->createArgs($manager), - 'type' => Type::listOf($this->getType($manager)), - 'resolve' => $this->createResolverFunction(), - ]; - } - - /** - * Creates a Connection for pagination. - * - * @param Manager $manager - * @return Connection - */ - protected function createConnection(Manager $manager) - { - return Connection::create($this->getName()) - ->setConnectionType(function () use ($manager) { - return $this->getType($manager); - }) - ->setConnectionResolver($this->createResolverFunction()) - ->setArgs($this->createArgs($manager)) - ->setSortableFields($this->getSortableFields()) - ->setDefaultLimit($this->getPaginationLimit()) - ->setMaximumLimit($this->getMaximumPaginationLimit()); - } - - /** - * @return callable|\Closure - */ - protected function createResolverFunction() - { - $resolverFn = parent::createResolverFunction(); - - // Wrap resolver in permission checks unless we're paginating. - // In this case, the connection is in charge of these checks - // in order to avoid looping through unfiltered lists - $checker = $this->getPermissionChecker(); - if ($checker && !$this->usePagination) { - return function ($obj, array $args, $context, ResolveInfo $info) use ($resolverFn, $checker) { - $list = call_user_func_array($resolverFn, func_get_args()); - $currentUser = $context['currentUser']; - - // Perform permission check if result is a filterable list. - if ($list instanceof SS_List) { - $list = $checker->applyToList($list, $currentUser); - } - - return $list; - }; - } - - return $resolverFn; - } - - - /** - * @param Manager $manager - * @return PaginationScaffolder - */ - protected function getPaginationScaffolder(Manager $manager) - { - if (!$this->paginationScaffolder) { - $this->paginationScaffolder = new PaginationScaffolder( - $this->getName(), - $manager, - $this->createConnection($manager) - ); - } - - return $this->paginationScaffolder; - } -} diff --git a/src/Scaffolding/Scaffolders/MutationScaffolder.php b/src/Scaffolding/Scaffolders/MutationScaffolder.php deleted file mode 100644 index 496b35697..000000000 --- a/src/Scaffolding/Scaffolders/MutationScaffolder.php +++ /dev/null @@ -1,99 +0,0 @@ -setDataObjectClass($class); - } - parent::__construct($operationName, $typeName, $resolver); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $this->extend('onBeforeAddToManager', $this, $manager); - $manager->addMutation(function () use ($manager) { - return $this->scaffold($manager); - }, $this->getName()); - } - - - /** - * @param Manager $manager - * - * @return array - */ - public function scaffold(Manager $manager) - { - return [ - 'name' => $this->getName(), - 'description' => $this->getDescription(), - 'args' => $this->createArgs($manager), - 'type' => $this->getType($manager), - 'resolve' => $this->createResolverFunction(), - ]; - } - - public function getTypeName() - { - return parent::getTypeName() ?: $this->typeName(); - } - - /** - * Get the type from Manager - * - * @param Manager $manager - * @return Type - */ - protected function getType(Manager $manager) - { - // If an explicit type name has been provided, use it. - $typeName = $this->getTypeName(); - if ($typeName && $manager->hasType($typeName)) { - return $manager->getType($typeName); - } - - // Fall back on a computed type name - $dataObjectClass = $this->getDataObjectClass(); - if ($dataObjectClass) { - return StaticSchema::inst()->fetchFromManager( - $this->getDataObjectClass(), - $manager, - StaticSchema::PREFER_SINGLE - ); - } - - throw new InvalidArgumentException(sprintf( - '%s must have either a typeName or dataObjectClass member defined.', - __CLASS__ - )); - } -} diff --git a/src/Scaffolding/Scaffolders/OperationScaffolder.php b/src/Scaffolding/Scaffolders/OperationScaffolder.php deleted file mode 100644 index fdb88c49d..000000000 --- a/src/Scaffolding/Scaffolders/OperationScaffolder.php +++ /dev/null @@ -1,523 +0,0 @@ -cloneable; - } - - /** - * @param bool $cloneable - * @return $this - */ - public function setCloneable($cloneable) - { - $this->cloneable = $cloneable; - - return $this; - } - - /** - * @return string - */ - public function getDescription() - { - return $this->description; - } - - /** - * @param string $description - * @return OperationScaffolder - */ - public function setDescription($description) - { - $this->description = $description; - return $this; - } - - /** - * @param string $name - * @return string|null - */ - public static function getClassFromIdentifier($name) - { - $operations = static::getOperations(); - - return isset($operations[$name]) ? $operations[$name] : null; - } - - /** - * @param string|OperationScaffolder $instOrClass - * @return string|null - */ - public static function getIdentifier($instOrClass) - { - $class = ($instOrClass instanceof OperationScaffolder) ? get_class($instOrClass) : $instOrClass; - $operations = static::getOperations(); - $operations = array_flip($operations); - - return isset($operations[$class]) ? $operations[$class] : null; - } - - /** - * Gets a map of operation identifiers to their classes - * @return array - */ - public static function getOperations() - { - $operations = Config::inst()->get(__CLASS__, 'operations', Config::UNINHERITED); - $validOperations = []; - foreach ($operations as $identifier => $class) { - if (!$class) { - continue; - } - $validOperations[$identifier] = $class; - } - - return $validOperations; - } - - /** - * OperationScaffolder constructor. - * - * @param string $operationName - * @param string $typeName - * @param OperationResolver|callable|null $resolver - */ - public function __construct($operationName = null, $typeName = null, $resolver = null) - { - $this->setName($operationName); - $this->setTypeName($typeName); - $this->args = ArrayList::create([]); - - if ($resolver) { - $this->setResolver($resolver); - } - } - - /** - * Adds args to the operation - * - * Ex: - * [ - * 'MyArg' => 'String!', - * 'MyOtherArg' => 'Int', - * 'MyCustomArg' => new InputObjectType([ - * ] - * - * @param array $argData - * @return $this - */ - public function addArgs(array $argData) - { - foreach ($argData as $argName => $type) { - $this->removeArg($argName); - $this->args->add(new ArgumentScaffolder($argName, $type)); - } - - return $this; - } - - /** - * @param string $argName - * @param string $typeStr - * @param string $description - * @param mixed $defaultValue - * @return $this - */ - public function addArg($argName, $typeStr, $description = null, $defaultValue = null) - { - $this->addArgs([$argName => $typeStr]); - $this->setArgDescription($argName, $description); - $this->setArgDefault($argName, $defaultValue); - - return $this; - } - - /** - * Sets descriptions of arguments - * [ - * 'Email' => 'The email of the user' - * ] - * @param array $argData - * @return $this - */ - public function setArgDescriptions(array $argData) - { - foreach ($argData as $argName => $description) { - /* @var ArgumentScaffolder $arg */ - $arg = $this->args->find('argName', $argName); - if (!$arg) { - throw new InvalidArgumentException(sprintf( - 'Tried to set description for %s, but it was not added to %s', - $argName, - $this->operationName ?: '(unnamed operation)' - )); - } - - $arg->setDescription($description); - } - - return $this; - } - - /** - * Sets a single arg description - * - * @param string $argName - * @param string $description - * @return $this - */ - public function setArgDescription($argName, $description) - { - return $this->setArgDescriptions([$argName => $description]); - } - - /** - * Sets argument defaults - * [ - * 'Featured' => true - * ] - * @param array $argData - * @return $this - */ - public function setArgDefaults(array $argData) - { - foreach ($argData as $argName => $default) { - /* @var ArgumentScaffolder $arg */ - $arg = $this->args->find('argName', $argName); - if (!$arg) { - throw new InvalidArgumentException(sprintf( - 'Tried to set default for %s, but it was not added to %s', - $argName, - $this->operationName ?: '(unnamed operation)' - )); - } - - $arg->setDefaultValue($default); - } - - return $this; - } - - /** - * Sets a default for a single arg - * - * @param string $argName - * @param mixed $default - * @return $this - */ - public function setArgDefault($argName, $default) - { - return $this->setArgDefaults([$argName => $default]); - } - - /** - * Sets operation arguments as required or not - * [ - * 'ID' => true - * ] - * @param array $argData - * @return $this - */ - public function setArgsRequired($argData) - { - foreach ($argData as $argName => $required) { - /* @var ArgumentScaffolder $arg */ - $arg = $this->args->find('argName', $argName); - if (!$arg) { - throw new InvalidArgumentException(sprintf( - 'Tried to make arg %s required, but it was not added to %s', - $argName, - $this->operationName ?: '(unnamed operation)' - )); - } - - $arg->setRequired($required); - } - - return $this; - } - - /** - * Sets an operation argument as required or not - * - * @param string $argName - * @param boolean $required - * @return OperationScaffolder - */ - public function setArgRequired($argName, $required) - { - return $this->setArgsRequired([$argName => $required]); - } - - /** - * @return string - */ - public function getName() - { - return $this->operationName; - } - - /** - * @param string $name - * @return $this - */ - public function setName($name) - { - $this->operationName = $name; - - return $this; - } - - /** - * @return ArrayList - */ - public function getArgs() - { - return $this->args; - } - - /** - * Type name - * - * @param string $typeName - * @return $this - */ - public function setTypeName($typeName) - { - $this->typeName = $typeName; - return $this; - } - - /** - * @return string - */ - public function getTypeName() - { - return $this->typeName; - } - - /** - * @param string $arg - * @return $this - */ - public function removeArg($arg) - { - return $this->removeArgs([$arg]); - } - - /** - * @param array $args - * @return $this - */ - public function removeArgs(array $args) - { - $this->args = $this->args->exclude('argName', $args); - - return $this; - } - - /** - * @return callable|OperationResolver - */ - public function getResolver() - { - return $this->resolver; - } - - /** - * @param callable|OperationResolver|string $resolver Callable, instance of (or classname of) a OperationResolver - * @return $this - * @throws InvalidArgumentException - */ - public function setResolver($resolver) - { - if (is_callable($resolver) || $resolver instanceof OperationResolver) { - $this->resolver = $resolver; - return $this; - } - if (is_subclass_of($resolver, OperationResolver::class)) { - $this->resolver = Injector::inst()->create($resolver); - return $this; - } - - throw new InvalidArgumentException(sprintf( - '%s::setResolver() accepts closures, instances of %s or names of resolver subclasses.', - __CLASS__, - OperationResolver::class - )); - } - - /** - * @param array $config - * @return $this - * @throws Exception - */ - public function applyConfig(array $config) - { - if (isset($config['args'])) { - if (!is_array($config['args'])) { - throw new Exception(sprintf( - 'args must be an array on %s', - $this->operationName ?: '(unnamed operation)' - )); - } - foreach ($config['args'] as $argName => $argData) { - if (is_array($argData)) { - if (!isset($argData['type'])) { - throw new Exception(sprintf( - 'Argument %s must have a type', - $argName - )); - } - - $scaffolder = new ArgumentScaffolder($argName, $argData['type']); - $scaffolder->applyConfig($argData); - $this->removeArg($argName); - $this->args->add($scaffolder); - } elseif (is_string($argData)) { - $this->addArg($argName, $argData); - } else { - throw new Exception(sprintf( - 'Arg %s should be mapped to a string or an array', - $argName - )); - } - } - } - - if (isset($config['resolver'])) { - $this->setResolver($config['resolver']); - } - - if (isset($config['name'])) { - $this->setName($config['name']); - } - - if (isset($config['cloneable'])) { - $this->setCloneable((bool)$config['cloneable']); - } - - if (isset($config['description'])) { - $this->setDescription($config['description']); - } - - return $this; - } - - /** - * Based on the type of resolver, create a function that invokes it. - * - * @return callable - */ - protected function createResolverFunction() - { - $resolver = $this->resolver; - - return function () use ($resolver) { - $args = func_get_args(); - if (is_callable($resolver)) { - return call_user_func_array($resolver, $args); - } else { - if ($resolver instanceof OperationResolver) { - return call_user_func_array([$resolver, 'resolve'], $args); - } else { - throw new \Exception(sprintf( - '%s resolver must be a closure or implement %s', - __CLASS__, - OperationResolver::class - )); - } - } - }; - } - - /** - * Helper for scaffolding args that require more work than ArgumentScaffolder::toArray() - * - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - return []; - } - - /** - * Parses the args to proper graphql-php spec. - * - * @param Manager $manager - * @return array - */ - protected function createArgs(Manager $manager) - { - $args = $this->createDefaultArgs($manager); - foreach ($this->args as $scaffolder) { - $args[$scaffolder->argName] = $scaffolder->toArray($manager); - } - $this->extend('updateArgs', $args, $manager); - return $args; - } -} diff --git a/src/Scaffolding/Scaffolders/PaginationScaffolder.php b/src/Scaffolding/Scaffolders/PaginationScaffolder.php deleted file mode 100644 index 4109dcee2..000000000 --- a/src/Scaffolding/Scaffolders/PaginationScaffolder.php +++ /dev/null @@ -1,85 +0,0 @@ -connection = $connection; - $this->operationName = $operationName; - } - - /** - * Connection is passed in through the constructor argument, - * to allow the instance to be created by the external scaffolding logic. - * - * @return Connection - */ - public function createConnection() - { - return $this->connection; - } - - /** - * @return string - */ - public function getOperationName() - { - return $this->operationName; - } - - /** - * @param string $name - * @return $this - */ - public function setOperationName($name) - { - $this->operationName = $name; - - return $this; - } - - /** - * @param Manager $manager - * @return array - */ - public function scaffold(Manager $manager) - { - $connectionName = $this->connection->getConnectionTypeName(); - return [ - 'name' => $this->operationName, - 'args' => $this->connection->args(), - 'type' => $manager->getType($connectionName), - 'resolve' => function ($obj, array $args, $context, ResolveInfo $info) { - return $this->connection->resolve($obj, $args, $context, $info); - } - ]; - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $manager->addType($this->connection->toType()); - } -} diff --git a/src/Scaffolding/Scaffolders/QueryScaffolder.php b/src/Scaffolding/Scaffolders/QueryScaffolder.php deleted file mode 100644 index fc0af47c4..000000000 --- a/src/Scaffolding/Scaffolders/QueryScaffolder.php +++ /dev/null @@ -1,100 +0,0 @@ -setDataObjectClass($class); - } - parent::__construct($operationName, $typeName, $resolver); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - if (!$this->getName() && !$this->dataObjectClass) { - throw new InvalidArgumentException(sprintf( - '%s must have either a typeName or dataObjectClass member defined.', - __CLASS__ - )); - } - - $this->extend('onBeforeAddToManager', $manager); - if (!$this->isNested) { - $manager->addQuery(function () use ($manager) { - return $this->scaffold($manager); - }, $this->getName()); - } - } - - /** - * Set to true if this query is a nested field and should not appear in the root query field - * @param bool $bool - * @return $this - */ - public function setNested($bool) - { - $this->isNested = (boolean)$bool; - - return $this; - } - - public function getTypeName() - { - return parent::getTypeName() ?: $this->typeName(); - } - - /** - * Get the type from Manager - * - * @param Manager $manager - * @return Type - */ - protected function getType(Manager $manager) - { - // If an explicit type name has been provided, use it. - $typeName = $this->getTypeName(); - if ($typeName && $manager->hasType($typeName)) { - return $manager->getType($typeName); - } - - // Fall back on a computed type name - return StaticSchema::inst()->fetchFromManager( - $this->dataObjectClass, - $manager, - StaticSchema::PREFER_UNION - ); - } -} diff --git a/src/Scaffolding/Scaffolders/SchemaScaffolder.php b/src/Scaffolding/Scaffolders/SchemaScaffolder.php deleted file mode 100644 index ef2ea79d6..000000000 --- a/src/Scaffolding/Scaffolders/SchemaScaffolder.php +++ /dev/null @@ -1,389 +0,0 @@ -get(self::class); - if (isset($config['types'])) { - if (!ArrayLib::is_associative($config['types'])) { - throw new InvalidArgumentException( - '"types" must be a map of class name to settings.' - ); - } - - foreach ($config['types'] as $dataObjectClass => $settings) { - $scaffolder->type($dataObjectClass) - ->applyConfig($settings); - } - } - - $queryMap = [ - 'queries' => 'query', - 'mutations' => 'mutation', - ]; - - foreach ($queryMap as $group => $method) { - if (isset($config[$group])) { - if (!ArrayLib::is_associative($config[$group])) { - throw new InvalidArgumentException( - sprintf( - '"%s" must be a map of operation name to settings.', - $group - ) - ); - } - - foreach ($config[$group] as $fieldName => $fieldSettings) { - if (!isset($fieldSettings['type'])) { - throw new InvalidArgumentException( - sprintf( - '"%s" must have a "type" field. See %s', - $group, - $fieldName - ) - ); - } - - $scaffolder->$method($fieldName, $fieldSettings['type']) - ->applyConfig($fieldSettings); - } - } - } - - return $scaffolder; - } - - /** - * Constructor. - */ - public function __construct() - { - $this->queries = OperationList::create([]); - $this->mutations = OperationList::create([]); - } - - /** - * Finds or makes a DataObject definition. - * - * @param string $class - * @return DataObjectScaffolder - * @throws InvalidArgumentException - */ - public function type($class) - { - // Remove leading backslash. All namespaces are assumed absolute in YAML - $class = ltrim($class, '\\'); - - foreach ($this->types as $scaffold) { - if ($scaffold->getDataObjectClass() === $class) { - return $scaffold; - } - } - - $scaffold = Injector::inst()->create(DataObjectScaffolder::class, $class) - ->setChainableParent($this); - $this->types[] = $scaffold; - - return $scaffold; - } - - /** - * Find or make a query. - * - * @param string $name - * @param string $class - * @param callable|OperationResolver $resolver - * @return QueryScaffolder|ListQueryScaffolder - */ - public function query($name, $class, $resolver = null) - { - /** - * @var QueryScaffolder $query - */ - $query = $this->queries->findByName($name); - if ($query) { - return $query; - } - - $operationScaffold = (new ListQueryScaffolder($name, null, $resolver, $class)) - ->setChainableParent($this); - - $this->queries->push($operationScaffold); - - return $operationScaffold; - } - - /** - * Find or make a mutation. - * - * @param string $name - * @param string $class - * @param callable|OperationResolver $resolver - * @return bool|MutationScaffolder - */ - public function mutation($name, $class, $resolver = null) - { - $mutation = $this->mutations->findByName($name); - - if ($mutation) { - return $mutation; - } - - $operationScaffold = (new MutationScaffolder($name, null, $resolver, $class)) - ->setChainableParent($this); - - $this->mutations->push($operationScaffold); - - return $operationScaffold; - } - - /** - * Removes a mutation. - * - * @param string $name - * @return $this - */ - public function removeMutation($name) - { - $this->mutations->removeByName($name); - - return $this; - } - - /** - * Removes a query. - * - * @param string $name - * - * @return $this - */ - public function removeQuery($name) - { - $this->queries->removeByName($name); - - return $this; - } - - /** - * @return DataObjectScaffolder[] - */ - public function getTypes() - { - return $this->types; - } - - /** - * Returns true if the type has been added to the scaffolder - * - * @param string $dataObjectClass - * @return bool - */ - public function hasType($dataObjectClass) - { - foreach ($this->types as $scaffold) { - if ($scaffold->getDataObjectClass() == $dataObjectClass) { - return true; - } - } - - return false; - } - - /** - * @return OperationList - */ - public function getQueries() - { - return $this->queries; - } - - /** - * Gets all nested queries for all types - * @return array - */ - public function getNestedQueries() - { - $queries = []; - foreach ($this->types as $scaffold) { - $queries = array_merge($queries, $scaffold->getNestedQueries()); - } - - return $queries; - } - - /** - * @return OperationList - */ - public function getMutations() - { - return $this->mutations; - } - - /** - * Adds every DataObject and its dependencies to the Manager. - * - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $this->registerFixedTypes($manager); - - $this->registerPeripheralTypes($manager); - - $this->extend('onBeforeAddToManager', $manager); - - // Add all DataObjects to the manager - foreach ($this->types as $scaffold) { - $scaffold->addToManager($manager); - $inheritanceScaffolder = new InheritanceScaffolder( - $scaffold->getDataObjectClass(), - StaticSchema::config()->get('inheritanceTypeSuffix') - ); - // Due to shared ancestry, it's inevitable that the same union type will get added multiple times. - if (!$manager->hasType($inheritanceScaffolder->getName())) { - $inheritanceScaffolder->addToManager($manager); - } - } - - foreach ($this->queries as $scaffold) { - $scaffold->addToManager($manager); - } - - foreach ($this->mutations as $scaffold) { - $scaffold->addToManager($manager); - } - - $this->extend('onAfterAddToManager', $manager); - } - - /** - * Registers special SS types that are made available to all schemas, e.g. DBFile ObjectType - * - * @param Manager $manager - * @throws Exception - */ - protected function registerFixedTypes(Manager $manager) - { - $fixedTypes = Config::inst()->get(self::class, 'fixed_types'); - if ($fixedTypes) { - if (!is_array($fixedTypes)) { - throw new Exception( - sprintf( - '%s.fixed_types must be an array', - __CLASS__ - ) - ); - } - foreach ($fixedTypes as $className) { - $instance = Injector::inst()->get($className); - if (!$instance instanceof ViewableData) { - throw new Exception( - sprintf( - 'Cannot auto register class %s. It is not a subclass of %s', - $className, - ViewableData::class - ) - ); - } - if (!$instance->hasExtension(TypeCreatorExtension::class)) { - throw new Exception( - sprintf( - 'Cannot auto register class %s. Is does not have the extension %s.', - $className, - TypeCreatorExtension::class - ) - ); - } - $instance->addToManager($manager); - } - } - } - - /** - * Registers types and respective operations for all ancestors of exposed dataobjects - * @param Manager $manager - */ - protected function registerPeripheralTypes(Manager $manager) - { - $schema = StaticSchema::inst(); - foreach ($this->types as $scaffold) { - // Add dependent classes, e.g has_one, has_many nested queries - foreach ($scaffold->getDependentClasses() as $class) { - $this->type($class); - // Implicitly, all subclasses are added (albeit with no fields) - foreach ($schema->getDescendants($class) as $subclass) { - $this->type($subclass); - } - } - - $tree = array_merge( - $schema->getAncestry($scaffold->getDataObjectClass()), - $schema->getDescendants($scaffold->getDataObjectClass()) - ); - - // Expose all the classes along the inheritance chain - foreach ($tree as $class) { - $newType = $this->type($class); - $scaffold->cloneTo($newType); - } - } - } -} diff --git a/src/Scaffolding/Scaffolders/UnionScaffolder.php b/src/Scaffolding/Scaffolders/UnionScaffolder.php deleted file mode 100644 index 133ec459d..000000000 --- a/src/Scaffolding/Scaffolders/UnionScaffolder.php +++ /dev/null @@ -1,118 +0,0 @@ -name = $name; - $this->types = $types; - } - - /** - * @return string - */ - public function getName() - { - return $this->name; - } - - /** - * @param string $name - * @return UnionScaffolder - */ - public function setName($name) - { - $this->name = $name; - - return $this; - } - - /** - * @return array - */ - public function getTypes() - { - return $this->types; - } - - /** - * @param array $types - * @return $this - */ - public function setTypes($types) - { - $this->types = $types; - - return $this; - } - - /** - * @param Manager $manager - * @return UnionType - */ - public function scaffold(Manager $manager) - { - $types = $this->types; - return new UnionType([ - 'name' => $this->name, - 'types' => function () use ($manager, $types) { - return array_map(function ($item) use ($manager) { - return $manager->getType($item); - }, $types); - }, - 'resolveType' => function ($obj) use ($manager) { - if (!$obj instanceof DataObject) { - throw new Exception(sprintf( - 'Type with class %s is not a DataObject', - get_class($obj) - )); - } - $class = get_class($obj); - while ($class !== DataObject::class) { - $typeName = StaticSchema::inst()->typeNameForDataObject($class); - if ($manager->hasType($typeName)) { - return $manager->getType($typeName); - } - $class = get_parent_class($class); - } - throw new Exception(sprintf( - 'There is no type defined for %s, and none of its ancestors are defined.', - get_class($obj) - )); - } - ]); - } - - /** - * @param Manager $manager - */ - public function addToManager(Manager $manager) - { - $manager->addType($this->scaffold($manager)); - } -} diff --git a/src/Scaffolding/StaticSchema.php b/src/Scaffolding/StaticSchema.php deleted file mode 100644 index 02d5e3c55..000000000 --- a/src/Scaffolding/StaticSchema.php +++ /dev/null @@ -1,327 +0,0 @@ -get('schemas'); - $typeNames = $schemaConfig[$schemaName]['typeNames'] ?? []; - - return $this->setTypeNames($typeNames); - } - - /** - * Given a DataObject subclass name, transform it into a sanitised (and implicitly unique) type - * name suitable for the GraphQL schema - * - * @param string $class - * @return string - */ - public function typeNameForDataObject($class) - { - $customTypeName = $this->mappedTypeName($class); - if ($customTypeName) { - return $customTypeName; - } - - $parts = explode('\\', $class); - $typeName = sizeof($parts) > 1 ? $parts[0] . end($parts) : $parts[0]; - - return $this->typeName($typeName); - } - - /** - * Gets the type name for a union type of all ancestors of a class given the classname - * @param string $class - * @return string - */ - public function inheritanceTypeNameForDataObject($class) - { - $typeName = $this->typeNameForDataObject($class); - return $this->inheritanceTypeNameForType($typeName); - } - - /** - * Gets the type name for a union type of all ancestors of a class given the type name - * @param string $typeName - * @return string - */ - public function inheritanceTypeNameForType($typeName) - { - return $typeName . $this->config()->get('inheritanceTypeSuffix'); - } - - /** - * @param string $str - * @return mixed - */ - public function typeName($str) - { - return preg_replace('/[^A-Za-z0-9_]/', '_', str_replace(' ', '', $str)); - } - - /** - * Returns true if the field name can be accessed on the given object - * - * @param ViewableData $instance - * @param string $fieldName - * @return bool - */ - public function isValidFieldName(ViewableData $instance, $fieldName) - { - return ($instance->hasMethod($fieldName) || $instance->hasField($fieldName)); - } - - /** - * @param array $typesMap An associate array of classname => type name - * @return $this - */ - public function setTypeNames($typesMap) - { - if ($typesMap && !ArrayLib::is_associative($typesMap)) { - throw new InvalidArgumentException(sprintf( - '%s.typeNames must be a map of class names to type names', - static::class - )); - } - $allTypes = array_values($typesMap); - $diff = array_unique( - array_diff_assoc( - $allTypes, - array_unique($allTypes) - ) - ); - - if (!empty($diff)) { - throw new InvalidArgumentException(sprintf( - '%s.typeNames contains duplicate type names: %s', - static::class, - implode(', ', $diff) - )); - } - - foreach ($typesMap as $class => $type) { - $this->ensureDataObject($class); - } - - $this->typesMap = $typesMap; - - return $this; - } - - /** - * Gets all ancestors of a DataObject - * @param string $dataObjectClass - * @return array - */ - public function getAncestry($dataObjectClass) - { - $classes = []; - $ancestry = array_reverse(ClassInfo::ancestry($dataObjectClass)); - - foreach ($ancestry as $class) { - if ($class === $dataObjectClass) { - continue; - } - if ($class == DataObject::class) { - break; - } - $classes[] = $class; - } - - return $classes; - } - - /** - * @param string $dataObjectClass - * @return array - * @throws InvalidArgumentException - */ - public function getDescendants($dataObjectClass) - { - if (!is_subclass_of($dataObjectClass, DataObject::class)) { - throw new InvalidArgumentException(sprintf( - '%s::getDescendants takes only %s subclasses', - __CLASS__, - DataObject::class - )); - } - - $descendants = ClassInfo::subclassesFor($dataObjectClass); - array_shift($descendants); - - return array_values($descendants); - } - - /** - * Gets the type from the manager given a DataObject class. Will use an - * inheritance type if available. - * @param string $class - * @param Manager $manager - * @param int $mode - * @return Type - */ - public function fetchFromManager($class, Manager $manager, $mode = self::PREFER_UNION) - { - if (!in_array($mode, [self::PREFER_UNION, self::PREFER_SINGLE])) { - throw new InvalidArgumentException(sprintf( - '%s::%s illegal mode %s. Allowed modes are PREFER_UNION, PREFER_SINGLE', - __CLASS__, - __FUNCTION__, - $mode - )); - } - $typeName = $this->typeNameForDataObject($class); - $inheritanceTypeName = $this->inheritanceTypeNameForDataObject($class); - $names = $mode === self::PREFER_UNION - ? [$inheritanceTypeName, $typeName] - : [$typeName, $inheritanceTypeName]; - - foreach ($names as $type) { - if ($manager->hasType($type)) { - return $manager->getType($type); - } - } - - throw new InvalidArgumentException(sprintf( - 'The class %s could not be resolved to any type in the manager instance.', - $class - )); - } - - /** - * @param Manager $manager - * @return array - * @throws Exception - */ - public function introspectTypes(Manager $manager) - { - $fragments = $manager->query( - <<typesMap[$class]) ? $this->typesMap[$class] : null; - } - - /** - * @param string $class - * @throws InvalidArgumentException - */ - protected function ensureDataObject($class) - { - if (!is_subclass_of($class, DataObject::class)) { - throw new InvalidArgumentException(sprintf( - '%s is not a subclass of %s', - $class, - DataObject::class - )); - } - } -} diff --git a/src/Scaffolding/Traits/Chainable.php b/src/Scaffolding/Traits/Chainable.php deleted file mode 100644 index 636b287a5..000000000 --- a/src/Scaffolding/Traits/Chainable.php +++ /dev/null @@ -1,35 +0,0 @@ -chainableParent = $parent; - - return $this; - } - - /** - * @return DataObjectScaffolder|SchemaScaffolder - */ - public function end() - { - return $this->chainableParent; - } -} diff --git a/src/Scaffolding/Traits/DataObjectTypeTrait.php b/src/Scaffolding/Traits/DataObjectTypeTrait.php deleted file mode 100644 index a28d98c36..000000000 --- a/src/Scaffolding/Traits/DataObjectTypeTrait.php +++ /dev/null @@ -1,82 +0,0 @@ -dataObjectClass; - } - - /** - * Type name inferred from the dataobject. - * This should not be called directly, but only by getTypeName() - * - * @return string - */ - protected function typeName() - { - $dataObjectClass = $this->getDataObjectClass(); - if (!$dataObjectClass) { - throw new BadMethodCallException(__CLASS__ . " must have a dataobject class specified"); - } - return StaticSchema::inst()->typeNameForDataObject($dataObjectClass); - } - - /** - * @return DataObject - */ - public function getDataObjectInstance() - { - if (!$this->dataObjectInstance) { - $this->dataObjectInstance = Injector::inst()->get($this->dataObjectClass); - } - return $this->dataObjectInstance; - } - - /** - * Sets the DataObject name - * @param string $class - * @return $this - */ - public function setDataObjectClass($class) - { - if (!$class) { - throw new InvalidArgumentException("Missing class provided"); - } - - if (!class_exists($class)) { - throw new InvalidArgumentException("Non-existent classname \"{$class}\""); - } - - if (!is_subclass_of($class, DataObject::class)) { - throw new InvalidArgumentException("\"{$class}\" is not a DataObject subclass"); - } - - $this->dataObjectClass = $class; - return $this; - } -} diff --git a/src/Scaffolding/Util/ArrayTypeParser.php b/src/Scaffolding/Util/ArrayTypeParser.php deleted file mode 100644 index c8f4feb0d..000000000 --- a/src/Scaffolding/Util/ArrayTypeParser.php +++ /dev/null @@ -1,78 +0,0 @@ -name = $name; - $this->fields = $fields; - } - - /** - * @return mixed - */ - public function getName() - { - return $this->name; - } - - - /** - * @return Type - */ - public function getType() - { - $fields = []; - foreach ($this->fields as $field => $type) { - $fields[$field] = [ - 'type' => Injector::inst()->createWithArgs( - TypeParserInterface::class . '.string', - [$type] - )->getType(), - ]; - } - - return new ObjectType([ - 'name' => $this->name, - 'fields' => $fields, - ]); - } -} diff --git a/src/Scaffolding/Util/OperationList.php b/src/Scaffolding/Util/OperationList.php deleted file mode 100644 index d585837b7..000000000 --- a/src/Scaffolding/Util/OperationList.php +++ /dev/null @@ -1,128 +0,0 @@ -findItemByCallback(function (OperationScaffolder $item) use ($name) { - return $name === $item->getName(); - }); - } - - /** - * @param string $identifier - * @return bool|OperationScaffolder - */ - public function findByIdentifier($identifier) - { - $scaffoldClass = OperationScaffolder::getClassFromIdentifier($identifier); - if (!$scaffoldClass) { - return false; - } - return $this->findItemByCallback(function (OperationScaffolder $item) use ($scaffoldClass) { - return get_class($item) === $scaffoldClass; - }); - } - - /** - * @param string $name - */ - public function removeByName($name) - { - $this->removeItemByCallback(function (OperationScaffolder $operation) use ($name) { - return $operation->getName() === $name; - }); - } - - /** - * @param string $id - */ - public function removeByIdentifier($id) - { - $className = OperationScaffolder::getClassFromIdentifier($id); - $this->removeItemByCallback(function (OperationScaffolder $operation) use ($className) { - return $operation instanceof $className; - }); - } - - /** - * @param callable $callback - */ - public function removeItemByCallback($callback) - { - $renumberKeys = false; - foreach ($this->items as $key => $value) { - if ($callback($value)) { - $renumberKeys = true; - unset($this->items[$key]); - } - } - - if ($renumberKeys) { - $this->items = array_values($this->items); - } - } - - /** - * @param callable $callback - * @return OperationScaffolder|false - */ - public function findItemByCallback($callback) - { - foreach ($this->items as $key => $value) { - if ($callback($value)) { - return $value; - } - } - - return false; - } -} diff --git a/src/Scaffolding/Util/StringTypeParser.php b/src/Scaffolding/Util/StringTypeParser.php deleted file mode 100644 index 968aab7cb..000000000 --- a/src/Scaffolding/Util/StringTypeParser.php +++ /dev/null @@ -1,143 +0,0 @@ -rawArg = $rawArg; - $this->typeStr = $matches[1]; - $this->required = isset($matches[2]) && $matches[2] == '!'; - if (isset($matches[3])) { - $this->defaultValue = $matches[3]; - } - } - - /** - * @return bool - */ - public function isRequired() - { - return $this->required; - } - - /** - * @return mixed - */ - public function getName() - { - return $this->typeStr; - } - - /** - * @return null - */ - public function getDefaultValue() - { - if ($this->defaultValue === null) { - return null; - } - - switch ($this->typeStr) { - case Type::ID: - return (int)$this->defaultValue; - case Type::STRING: - return (string)$this->defaultValue; - case Type::BOOLEAN: - return (boolean)$this->defaultValue; - case Type::INT: - return (int)$this->defaultValue; - case Type::FLOAT: - return (float)$this->defaultValue; - default: - return $this->defaultValue; - } - } - - /** - * @param boolean $nullable If true, allow the type to be null. Otherwise, - * return the typename, which may be arbitrary. - * @return Type|string - */ - public function getType($nullable = true) - { - switch ($this->typeStr) { - case Type::ID: - return Type::id(); - case Type::STRING: - return Type::string(); - case Type::BOOLEAN: - return Type::boolean(); - case Type::INT: - return Type::int(); - case Type::FLOAT: - return Type::float(); - default: - return $nullable ? null : $this->typeStr; - } - } -} diff --git a/src/Schema/DataObject/CreateCreator.php b/src/Schema/DataObject/CreateCreator.php new file mode 100644 index 000000000..7ffcf3385 --- /dev/null +++ b/src/Schema/DataObject/CreateCreator.php @@ -0,0 +1,161 @@ + '%$' . FieldAccessor::class, + ]; + + /** + * @var FieldAccessor + */ + private $fieldAccessor; + + /** + * @param SchemaModelInterface $model + * @param string $typeName + * @param array $config + * @return ModelOperation|null + * @throws SchemaBuilderException + */ + public function createOperation( + SchemaModelInterface $model, + string $typeName, + array $config = [] + ): ?ModelOperation + { + $plugins = $config['plugins'] ?? []; + $mutationName = $config['name'] ?? null; + if (!$mutationName) { + $mutationName = 'create' . ucfirst($typeName); + } + $inputTypeName = self::inputTypeName($typeName); + + return ModelMutation::create($model, $mutationName) + ->setType($typeName) + ->setPlugins($plugins) + ->setDefaultResolver([static::class, 'resolve']) + ->setResolverContext([ + 'dataClass' => $model->getSourceClass(), + ]) + ->addArg('input', "{$inputTypeName}!"); + } + + /** + * @param array $resolverContext + * @return Closure + */ + public static function resolve(array $resolverContext = []): Closure + { + $dataClass = $resolverContext['dataClass'] ?? null; + return function ($obj, $args = [], $context = [], ResolveInfo $info) use ($dataClass) { + if (!$dataClass) { + return null; + } + $singleton = Injector::inst()->get($dataClass); + if (!$singleton->canCreate($context[QueryHandler::CURRENT_USER], $context)) { + throw new PermissionsException("Cannot create {$dataClass}"); + } + + $fieldAccessor = FieldAccessor::singleton(); + /** @var DataObject $newObject */ + $newObject = Injector::inst()->create($dataClass); + $update = []; + foreach ($args['input'] as $fieldName => $value) { + $update[$fieldAccessor->normaliseField($newObject, $fieldName)] = $value; + } + $newObject->update($update); + + // Save and return + $newObject->write(); + $newObject = DataObject::get_by_id($dataClass, $newObject->ID); + + return $newObject; + }; + } + + /** + * @param SchemaModelInterface $model + * @param string $typeName + * @param array $config + * @return array + * @throws SchemaBuilderException + */ + public function provideInputTypes(SchemaModelInterface $model, string $typeName, array $config = []): array + { + $dataObject = Injector::inst()->get($model->getSourceClass()); + $includedFields = $this->reconcileFields($config, $dataObject, $this->getFieldAccessor()); + $fieldMap = []; + foreach ($includedFields as $fieldName) { + $type = $model->getField($fieldName)->getType(); + if ($type) { + $fieldMap[$fieldName] = $type; + } + } + $inputType = InputType::create( + self::inputTypeName($typeName), + [ + 'fields' => $fieldMap + ] + ); + + return [$inputType]; + } + + /** + * @return FieldAccessor + */ + public function getFieldAccessor(): FieldAccessor + { + return $this->fieldAccessor; + } + + /** + * @param FieldAccessor $fieldAccessor + * @return CreateCreator + */ + public function setFieldAccessor(FieldAccessor $fieldAccessor): self + { + $this->fieldAccessor = $fieldAccessor; + return $this; + } + + + /** + * @param string $typeName + * @return string + */ + private static function inputTypeName(string $typeName): string + { + return 'Create' . ucfirst($typeName) . 'Input'; + } + +} diff --git a/src/Schema/DataObject/DataObjectModel.php b/src/Schema/DataObject/DataObjectModel.php new file mode 100644 index 000000000..ad5ca8f00 --- /dev/null +++ b/src/Schema/DataObject/DataObjectModel.php @@ -0,0 +1,361 @@ + '%$' . FieldAccessor::class, + ]; + + /** + * @var array + * @config + */ + private $operations = []; + + /** + * @var DataObject + */ + private $dataObject; + + /** + * @var FieldAccessor + */ + private $fieldAccessor; + + /** + * @return string + */ + public static function getIdentifier(): string + { + return 'DataObject'; + } + + /** + * DataObjectModel constructor. + * @param string $class + * @param ModelConfiguration|null $config + * @throws SchemaBuilderException + */ + public function __construct(string $class, ?ModelConfiguration $config = null) + { + Schema::invariant( + is_subclass_of($class, DataObject::class), + '%s only accepts %s subclasses', + static::class, + DataObject::class + ); + $this->dataObject = Injector::inst()->get($class); + $this->configuration = $config; + } + + /** + * @param string $fieldName + * @return bool + */ + public function hasField(string $fieldName): bool + { + return $this->getFieldAccessor()->hasField($this->dataObject, $fieldName); + } + + /** + * @param string $fieldName + * @param array $config + * @return ModelField|null + * @throws SchemaBuilderException + */ + public function getField(string $fieldName, array $config = []): ?ModelField + { + $result = $this->getFieldAccessor()->accessField($this->dataObject, $fieldName); + if (!$result) { + return null; + } + $fieldConfig = array_merge([ + 'type' => $result->config()->get('graphql_type'), + ], $config); + + if ($result instanceof DBField) { + return ModelField::create($fieldName, $fieldConfig, $this); + } + + $class = $this->getModelClass($result); + Schema::invariant( + $class, + 'Cannot determine data class for field %s on %s', + $fieldName, + get_class($this->dataObject) + ); + + $type = DataObjectModel::create($class, $this->configuration)->getTypeName(); + if ($this->isList($result)) { + $queryConfig = array_merge([ + 'type' => sprintf('[%s]', $type), + ], $config); + $query = ModelQuery::create($this, $fieldName, $queryConfig); + $query->setDefaultPlugins($this->getModelConfig()->getNestedQueryPlugins()); + return $query; + } + return ModelField::create($fieldName, $type, $this); + } + + /** + * @return array + */ + public function getDefaultFields(): array + { + $idField = $this->getFieldAccessor()->formatField('ID'); + return [ + $idField => 'ID', + ]; + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getBlacklistedFields(): array + { + $class = $this->getSourceClass(); + $config = DataObject::singleton($class)->config()->get('graphql_blacklisted_fields') ?? []; + $blackList = []; + Schema::assertValidConfig($config); + foreach ($config as $fieldName => $bool) { + if ($bool === true && is_string($fieldName)) { + $blackList[] = $fieldName; + } + } + return array_map(function (string $field) { + return $this->getFieldAccessor()->formatField($field); + }, $blackList); + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getAllFields(): array + { + $blackList = $this->getBlacklistedFields(); + $allFields = $this->getFieldAccessor()->getAllFields($this->dataObject); + + return array_diff($allFields, $blackList); + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getUninheritedFields(): array + { + $blackList = $this->getBlacklistedFields(); + $allFields = $this->getFieldAccessor()->getAllFields($this->dataObject, true, false); + + return array_diff($allFields, $blackList); + } + + /** + * @param array|null $context + * @return ResolverReference + */ + public function getDefaultResolver(?array $context = []): ResolverReference + { + $callable = empty($context) + ? [Resolver::class, 'resolve'] + : [Resolver::class, 'resolveContext']; + + return ResolverReference::create($callable); + } + + /** + * @return string + */ + public function getSourceClass(): string + { + return get_class($this->dataObject); + } + + /** + * @return FieldAccessor + */ + public function getFieldAccessor(): FieldAccessor + { + return $this->fieldAccessor; + } + + /** + * @param FieldAccessor $fieldAccessor + * @return DataObjectModel + */ + public function setFieldAccessor(FieldAccessor $fieldAccessor): self + { + $this->fieldAccessor = $fieldAccessor; + return $this; + } + + /** + * @param string $id + * @return OperationCreator|null + * @throws SchemaBuilderException + */ + public function getOperationCreatorByIdentifier(string $id): ?OperationCreator + { + $registeredOperations = $this->config()->get('operations') ?? []; + $creator = $registeredOperations[$id] ?? null; + if (!$creator) { + return null; + } + Schema::invariant( + class_exists($creator), + 'Operation creator %s does not exist', + $creator + ); + /* @var OperationCreator $obj */ + $obj = Injector::inst()->get($creator); + Schema::invariant( + $obj instanceof OperationCreator, + 'Operation %s is not an instance of %s', + $creator, + OperationCreator::class + ); + + return $obj; + } + + /** + * @return string[] + */ + public function getAllOperationIdentifiers(): array + { + $registeredOperations = $this->config()->get('operations') ?? []; + + return array_keys($registeredOperations); + } + + /** + * Gets a field that resolves to another model, (e.g. an ObjectType from a has_one). + * This method can be used to determine *if* a field is another model, and also to + * get that field. + * + * @param string $fieldName + * @return ModelType|null + */ + public function getModelTypeForField(string $fieldName): ?ModelType + { + $result = $this->getFieldAccessor()->accessField($this->dataObject, $fieldName); + $class = $this->getModelClass($result); + if (!$class) { + return null; + } + $model = SchemaModelCreatorRegistry::singleton()->getModel($class); + if (!$model) { + return null; + } + + return ModelType::create($class); + } + + /** + * @return DataObject + */ + public function getDataObject(): DataObject + { + return $this->dataObject; + } + + /** + * @return string + * @throws SchemaBuilderException + */ + public function getTypeName(): string + { + return $this->getModelConfig()->getTypeName(get_class($this->dataObject)); + } + + /** + * @return ModelConfiguration + */ + public function getModelConfig(): ModelConfiguration + { + return $this->configuration; + } + + /** + * @param ModelConfiguration $configuration + */ + public function applyModelConfig(ModelConfiguration $configuration): void + { + $this->configuration = $configuration; + } + + + /** + * @param $result + * @return string|null + */ + private function getModelClass($result): ?string + { + if ($result instanceof DataObject) { + return get_class($result); + } + if ($result instanceof SS_List && method_exists($result, 'dataClass')) { + return $result->dataClass(); + } + + return null; + } + + /** + * @param $result + * @return bool + */ + private function isList($result): bool + { + return $result instanceof SS_List || $result instanceof UnsavedRelationList; + } + + +} diff --git a/src/Schema/DataObject/DeleteCreator.php b/src/Schema/DataObject/DeleteCreator.php new file mode 100644 index 000000000..d8be93ef4 --- /dev/null +++ b/src/Schema/DataObject/DeleteCreator.php @@ -0,0 +1,100 @@ +setType('[ID]') + ->setPlugins($plugins) + ->setDefaultResolver([static::class, 'resolve']) + ->setResolverContext([ + 'dataClass' => $model->getSourceClass(), + ]) + ->addArg('ids', '[ID]!'); + } + + /** + * @param array $resolverContext + * @return Closure + */ + public static function resolve(array $resolverContext = []): Closure + { + $dataClass = $resolverContext['dataClass'] ?? null; + return function ($obj, array $args, array $context, ResolveInfo $info) use ($dataClass) { + if (!$dataClass) { + return null; + } + $ids = []; + DB::get_conn()->withTransaction(function () use ($args, $context, $info, $dataClass, $ids) { + // Build list to filter + $results = DataList::create($dataClass) + ->byIDs($args['ids']); + + // Before deleting, check if any items fail canDelete() + /** @var DataObject[] $resultsList */ + $resultsList = $results->toArray(); + foreach ($resultsList as $obj) { + if (!$obj->canDelete($context[QueryHandler::CURRENT_USER])) { + throw new PermissionsException(sprintf( + 'Cannot delete %s with ID %s', + $dataClass, + $obj->ID + )); + } + } + + // Delete + foreach ($resultsList as $obj) { + $obj->delete(); + $ids[] = $obj; + } + }); + + return $ids; + }; + } +} diff --git a/src/Schema/DataObject/FieldAccessor.php b/src/Schema/DataObject/FieldAccessor.php new file mode 100644 index 000000000..38027d7e0 --- /dev/null +++ b/src/Schema/DataObject/FieldAccessor.php @@ -0,0 +1,258 @@ +getSchema(); + $class = get_class($dataObject); + + if ($schema->fieldSpec($class, $field) || $schema->unaryComponent($class, $field)) { + return $field; + } + $lookup = $this->getCaseInsensitiveMapping($dataObject); + + $normalised = strtolower($field); + $property = $lookup[$normalised] ?? null; + if ($property) { + return $property; + } + + // Sometimes, getters and DB fields overlap, e.g. "getTitle", so this check comes last to ensure + // the native field gets priority. + if ($dataObject->hasMethod('get' . $field)) { + return $field; + } + + return null; + } + + /** + * @param DataObject $dataObject + * @param string $field + * @return bool + */ + public function hasField(DataObject $dataObject, string $field): bool + { + $path = explode('.', $field); + $fieldName = array_shift($path); + return $this->normaliseField($dataObject, $fieldName) !== null; + } + + /** + * @param DataObject $dataObject + * @param string $field + * @return DBField|SS_List|DataObject|null + */ + public function accessField(DataObject $dataObject, string $field) + { + if ($path = explode('.', $field)) { + if (count($path) === 1) { + $fieldName = $this->normaliseField($dataObject, $path[0]); + if (!$fieldName) { + return null; + } + + return $dataObject->obj($fieldName); + } + } + + return $this->parsePath($dataObject, $path); + } + + /** + * @param string $field + * @return string + */ + public static function formatField(string $field): string + { + return call_user_func_array(static::config()->get('field_formatter'), [$field]); + } + + /** + * @param DataObject $dataObject + * @param bool $includeRelations + * @param bool $includeInherited + * @return array + */ + public function getAllFields(DataObject $dataObject, $includeRelations = true, $includeInherited = true): array + { + return array_map( + $this->config()->get('field_formatter'), + array_values($this->getCaseInsensitiveMapping($dataObject, $includeRelations, $includeInherited)) + ); + } + + /** + * @param DataObject $dataObject + * @param bool $includeRelations + * @param bool $includeInherited + * @return array + */ + private function getAccessibleFields( + DataObject $dataObject, + $includeRelations = true, + $includeInherited = true + ): array { + $class = get_class($dataObject); + $schema = $dataObject->getSchema(); + $configFlag = $includeInherited ? 0 : Config::UNINHERITED; + $schemaFlag = $includeInherited ? 0 : DataObjectSchema::UNINHERITED; + $db = array_keys($schema->fieldSpecs(get_class($dataObject), $schemaFlag)); + if (!$includeRelations) { + return $db; + } + /* @var Config_ForClass $config */ + $config = $class::config(); + $hasOnes = array_keys((array) $config->get('has_one', $configFlag)); + $belongsTo = array_keys((array) $config->get('belongs_to', $configFlag)); + $hasMany = array_keys((array) $config->get('has_many', $configFlag)); + $manyMany = array_keys((array) $config->get('many_many', $configFlag)); + + return array_merge($db, $hasOnes, $belongsTo, $hasMany, $manyMany); + } + + /** + * @param DataObject $dataObject + * @param bool $includeRelations + * @param bool $includeInherirted + * @return array + */ + private function getCaseInsensitiveMapping( + DataObject $dataObject, + $includeRelations = true, + $includeInherirted = true + ): array { + $cacheKey = md5(json_encode([ + get_class($dataObject), + ($includeRelations ? '_relations' : ''), + ($includeInherirted ? '_inherited' : '') + ])); + $cached = self::$__mappingCache[$cacheKey] ?? null; + if (!$cached) { + $normalFields = $this->getAccessibleFields($dataObject, $includeRelations, $includeInherirted); + $lowercaseFields = array_map('strtolower', $normalFields); + $lookup = array_combine($lowercaseFields, $normalFields); + self::$__mappingCache[$cacheKey] = $lookup; + } + return self::$__mappingCache[$cacheKey]; + } + + /** + * Resolves complex dot syntax references. + * + * Image.URL (String) + * FeaturedProduct.Categories.Title ([String] ->column('Title')) + * FeaturedProduct.Categories.Count() (Int) + * FeaturedProduct.Categories.Products.Max(Price) + * Category.Products.Reviews ([Review]) + * + * @param DataObject|DataList|DBField $subject + * @param array $path + * @return string|int|bool|array|DataList + * @throws LogicException + */ + private function parsePath($subject, array $path) + { + $nextField = array_shift($path); + if ($subject instanceof DataObject) { + $result = $subject->obj($nextField); + if ($result instanceof DBField) { + return $result->getValue(); + } + return $this->parsePath($result, $path); + } + + if ($subject instanceof DataList || $subject instanceof UnsavedRelationList) { + if (!$nextField) { + return $subject; + } + + // Aggregate field, eg. Comments.Count(), Page.FeaturedProducts.Avg(Price) + if (preg_match('/([A-Za-z]+)\(\s*(?:([A-Za-z_*][A-Za-z0-9_]*))?\s*\)$/', $nextField, $matches)) { + $aggregateFunction = strtolower($matches[1]); + $aggregateColumn = $matches[2] ?? null; + if (!in_array($aggregateFunction, $this->config()->get('allowed_aggregates'))) { + throw new LogicException(sprintf( + 'Cannot call aggregate function %s', + $aggregateFunction + )); + } + return call_user_func_array([$subject, $aggregateFunction], [$aggregateColumn]); + } + + $singleton = DataObject::singleton($subject->dataClass()); + if ($singleton->hasField($nextField)) { + return $subject->column($nextField); + } + + $maybeList = $singleton->obj($nextField); + if ($maybeList instanceof RelationList || $maybeList instanceof UnsavedRelationList) { + return $this->parsePath($subject->relation($nextField), $path); + } + } + + throw new LogicException(sprintf( + 'Cannot resolve field %s on list of class %s', + $nextField, + $subject->dataClass() + )); + } +} diff --git a/src/Schema/DataObject/FieldReconciler.php b/src/Schema/DataObject/FieldReconciler.php new file mode 100644 index 000000000..90278b453 --- /dev/null +++ b/src/Schema/DataObject/FieldReconciler.php @@ -0,0 +1,53 @@ + $bool) { + if ($bool === false) { + continue; + } + $fields[] = $fieldName; + } + } else { + $fields = $fieldAccessor->getAllFields($dataObject, false); + } + $configExclude = $config['exclude'] ?? null; + $excluded = []; + if ($configExclude) { + Schema::assertValidConfig($configExclude); + foreach ($configExclude as $fieldName => $bool) { + if ($bool === false) { + continue; + } + $excluded[] = $fieldName; + } + $includedFields = array_diff($fields, $excluded); + } else { + $includedFields = $fields; + } + + return $includedFields; + } +} diff --git a/src/Schema/DataObject/InheritanceChain.php b/src/Schema/DataObject/InheritanceChain.php new file mode 100644 index 000000000..a43738983 --- /dev/null +++ b/src/Schema/DataObject/InheritanceChain.php @@ -0,0 +1,223 @@ +dataObjectClass = $dataObjectClass; + Schema::invariant( + is_subclass_of($this->dataObjectClass, DataObject::class), + '%s only accepts %s subclasses', + __CLASS__, + DataObject::class + ); + $this->inst = DataObject::singleton($this->dataObjectClass); + } + + /** + * @return string + */ + public static function getName(): string + { + return static::config()->get('field_name'); + } + + /** + * @return array + */ + public function getAncestralModels(): array + { + $classes = []; + $ancestry = array_reverse(ClassInfo::ancestry($this->dataObjectClass)); + + foreach ($ancestry as $class) { + if ($class === $this->dataObjectClass) { + continue; + } + if ($class == DataObject::class) { + break; + } + $classes[] = $class; + } + + return $classes; + } + + /** + * @return bool + */ + public function hasAncestors(): bool + { + return count($this->getAncestralModels()) > 0; + } + + /** + * @return array + * @throws ReflectionException + */ + public function getDescendantModels(): array + { + $descendants = ClassInfo::subclassesFor($this->dataObjectClass, false); + + return array_values($descendants); + } + + /** + * @return array + * @throws ReflectionException + */ + public function getDirectDescendants(): array + { + $parentClass = $this->dataObjectClass; + return array_filter($this->getDescendantModels(), function ($class) use ($parentClass) { + return get_parent_class($class) === $parentClass; + }); + } + + /** + * @return bool + * @throws ReflectionException + */ + public function hasDescendants(): bool + { + return count($this->getDescendantModels()) > 0; + } + + /** + * @return string + */ + public function getBaseClass(): string + { + return $this->inst->baseClass(); + } + + /** + * @return array|null + * @throws ReflectionException + * @throws SchemaBuilderException + */ + public function getExtensionType(): ?array + { + 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] + ); + + $nameCreator = $this->config()->get('subtype_name_creator'); + + $subtypes = []; + foreach ($this->getDescendantModels() as $className) { + $modelType = ModelType::create($className); + $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; + } + + /** + * @param DataObject $dataObject + * @return string + */ + public static function createDescendantTypename(DataObject $dataObject): string + { + $model = SchemaModelCreatorRegistry::singleton()->getModel($dataObject); + return $model->getTypeName() . 'Descendants'; + } + + /** + * @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 + { + return $obj; + } + +} diff --git a/src/Schema/DataObject/ModelCreator.php b/src/Schema/DataObject/ModelCreator.php new file mode 100644 index 000000000..c77c34909 --- /dev/null +++ b/src/Schema/DataObject/ModelCreator.php @@ -0,0 +1,36 @@ +getModel()->getSourceClass(), + DataObject::class + ), + 'The %s plugin can only be applied to queries that return dataobjects', + $this->getIdentifier() + ); + + $query->addResolverAfterware( + $this->getPermissionResolver() + ); + } + + abstract protected function getPermissionResolver(): array; +} diff --git a/src/Schema/DataObject/Plugin/CanViewPermission.php b/src/Schema/DataObject/Plugin/CanViewPermission.php new file mode 100644 index 000000000..67da28275 --- /dev/null +++ b/src/Schema/DataObject/Plugin/CanViewPermission.php @@ -0,0 +1,153 @@ +fieldName, + Filterable::class, + AbstractCanViewPermission::class, + SS_List::class, + Filterable::class + )); + + } + + /** + * @param array $obj + * @param array $args + * @param array $context + * @param ResolveInfo $info + * @return array + */ + 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(); + $filteredList = CanViewPermission::permissionCheck($list, $args, $context, $info); + $newCount = $filteredList->count(); + if ($originalCount === $newCount) { + return $obj; + } + $obj['nodes'] = $filteredList; + $edges = []; + foreach ($filteredList as $record) { + $edges[] = ['node' => $record]; + } + $obj['edges'] = $edges; + + return $obj; + + } + + /** + * @param $obj + * @param array $args + * @param array $context + * @param ResolveInfo $info + * @return object|null + */ + public static function itemPermissionCheck($obj, array $args, array $context, ResolveInfo $info) + { + $member = $context[QueryHandler::CURRENT_USER] ?? null; + if (is_object($obj) && method_exists($obj, 'canView')) { + if (!$obj->canView($member)) { + return null; + } + } + + return $obj; + } + + /** + * @param Filterable $obj + * @param array $args + * @param array $context + * @param ResolveInfo $info + * @return Filterable + */ + public static function listPermissionCheck(Filterable $obj, array $args, array $context, ResolveInfo $info): Filterable + { + $member = $context[QueryHandler::CURRENT_USER] ?? null; + $excludes = []; + + foreach ($obj as $record) { + if (ClassInfo::hasMethod($record, 'canView') && !$record->canView($member)) { + $excludes[] = $record->ID; + } + } + + if (!empty($excludes)) { + return $obj->exclude(['ID' => $excludes]); + } + + return $obj; + } +} diff --git a/src/Schema/DataObject/Plugin/FirstResult.php b/src/Schema/DataObject/Plugin/FirstResult.php new file mode 100644 index 000000000..50afba129 --- /dev/null +++ b/src/Schema/DataObject/Plugin/FirstResult.php @@ -0,0 +1,51 @@ +getModel()->getSourceClass(), + DataObject::class + ), + 'The %s plugin can only be applied to queries that return dataobjects', + $this->getIdentifier() + ); + + $query->addResolverAfterware([static::class, 'firstResult']); + } + + /** + * @param DataList $obj + * @return DataObject|null + */ + public static function firstResult(DataList $obj): ?DataObject + { + return $obj->first(); + } +} diff --git a/src/Schema/DataObject/Plugin/Inheritance.php b/src/Schema/DataObject/Plugin/Inheritance.php new file mode 100644 index 000000000..75cb749bb --- /dev/null +++ b/src/Schema/DataObject/Plugin/Inheritance.php @@ -0,0 +1,168 @@ +getModels() as $modelType) { + $class = $modelType->getModel()->getSourceClass(); + if (!is_subclass_of($class, DataObject::class)) { + continue; + } + $baseClass = InheritanceChain::create($class)->getBaseClass(); + if (self::isTouched($schema, $baseClass)) { + continue; + } + self::addInheritance($schema, $baseClass); + self::touchNode($schema, $baseClass); + } + } + + /** + * @param Schema $schema + * @param string $class + * @param ModelType|null $parentModel + * @throws ReflectionException + * @throws SchemaBuilderException + */ + private static function addInheritance(Schema $schema, string $class, ?ModelType $parentModel = null) + { + $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); + } + + if (!$inheritance->hasDescendants()) { + return; + } + + // Add the new __extends field to the base class only + if (!$parentModel) { + $result = $inheritance->getExtensionType(); + 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 = $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($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); + } + } + + /** + * @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 new file mode 100644 index 000000000..c7672b045 --- /dev/null +++ b/src/Schema/DataObject/Plugin/InheritedPlugins.php @@ -0,0 +1,87 @@ +getSourceClass(); + Schema::invariant( + is_subclass_of($sourceClass, DataObject::class), + '%s only applies to %s subclasses', + static::class, + DataObject::class + ); + + $chain = InheritanceChain::create($sourceClass); + if (!$chain->hasAncestors()) { + return; + } + $ancestors = array_reverse($chain->getAncestralModels()); + /* @var ModelType[] $ancestorModels */ + $ancestorModels = []; + foreach ($ancestors as $ancestor) { + $modelType = $schema->getModelByClassName($ancestor); + if (!$modelType) { + continue; + } + $ancestorModels[] = $modelType; + } + + $pluginArgs = []; + foreach ($ancestorModels as $model) { + $pluginArgs[] = $model->getPlugins(false); + } + $pluginArgs[] = $type->getPlugins(false); + + $plugins = array_replace_recursive(...$pluginArgs); + $type->setPlugins($plugins); + + $operations = $type->getOperations(); + + /* @var ModelField $operation */ + foreach ($operations as $name => $operation) { + $pluginArgs = []; + foreach ($ancestorModels as $model) { + $ancestorOperation = $model->getOperations()[$name] ?? null; + if ($ancestorOperation) { + $pluginArgs[] = $ancestorOperation->getPlugins(false); + } + } + $pluginArgs[] = $operation->getPlugins(false); + $plugins = array_replace_recursive(...$pluginArgs); + $operation->setPlugins($plugins); + } + } +} diff --git a/src/Schema/DataObject/Plugin/Paginator.php b/src/Schema/DataObject/Plugin/Paginator.php new file mode 100644 index 000000000..32ec2820a --- /dev/null +++ b/src/Schema/DataObject/Plugin/Paginator.php @@ -0,0 +1,53 @@ +count(); + + $limit = min($limit, $maxLimit); + + // Apply limit + /* @var Limitable $list */ + $limitedList = $list->limit($limit, $offset); + return static::createPaginationResult($total, $limitedList, $limit, $offset); + }; + } + +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/FieldFilterInterface.php b/src/Schema/DataObject/Plugin/QueryFilter/FieldFilterInterface.php new file mode 100644 index 000000000..b0b4eb133 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/FieldFilterInterface.php @@ -0,0 +1,26 @@ +getIdentifier(); if (!preg_match('/^[A-Za-z0-9_]+$/', $id)) { throw new InvalidArgumentException(sprintf( @@ -52,10 +44,10 @@ public function addFilter(FieldFilterInterface $filter, $identifier = null) } /** - * @param $identifier - * @return mixed + * @param string $identifier + * @return FieldFilterInterface|null */ - public function getFilterByIdentifier($identifier) + public function getFilterByIdentifier(string $identifier): ?FieldFilterInterface { if (isset($this->filters[$identifier])) { return $this->filters[$identifier]; @@ -65,7 +57,7 @@ public function getFilterByIdentifier($identifier) } /** - * @return FilterInterface[] + * @return FieldFilterInterface[] */ public function getAll() { diff --git a/src/Schema/DataObject/Plugin/QueryFilter/FilterRegistryInterface.php b/src/Schema/DataObject/Plugin/QueryFilter/FilterRegistryInterface.php new file mode 100644 index 000000000..885c50513 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/FilterRegistryInterface.php @@ -0,0 +1,29 @@ +filter($fieldName . ':PartialMatch', $value); + } + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'contains'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/EndsWithFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/EndsWithFilter.php new file mode 100644 index 000000000..6f49a094b --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/EndsWithFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':EndsWith', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'endswith'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/EqualToFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/EqualToFilter.php new file mode 100644 index 000000000..8d8a46137 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/EqualToFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':ExactMatch', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'eq'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanFilter.php new file mode 100644 index 000000000..cecf3d0ea --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':GreaterThan', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'gt'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanOrEqualFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanOrEqualFilter.php new file mode 100644 index 000000000..bdf3c3c1a --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/GreaterThanOrEqualFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':GreaterThanOrEqual', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'gte'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/InFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/InFilter.php new file mode 100644 index 000000000..e09d58b0d --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/InFilter.php @@ -0,0 +1,29 @@ +filter($fieldName, (array) $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'in'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanFilter.php new file mode 100644 index 000000000..61c3d26eb --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':LessThan', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'lt'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanOrEqualFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanOrEqualFilter.php new file mode 100644 index 000000000..107168a51 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/LessThanOrEqualFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':LessThanOrEqual', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'lte'; + } +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/NotEqualFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/NotEqualFilter.php new file mode 100644 index 000000000..efe28adba --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/NotEqualFilter.php @@ -0,0 +1,30 @@ +exclude($fieldName, $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'ne'; + } + +} diff --git a/src/Schema/DataObject/Plugin/QueryFilter/Filters/StartsWithFilter.php b/src/Schema/DataObject/Plugin/QueryFilter/Filters/StartsWithFilter.php new file mode 100644 index 000000000..9fbabd3c9 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QueryFilter/Filters/StartsWithFilter.php @@ -0,0 +1,29 @@ +filter($fieldName . ':StartsWith', $value); + } + + /** + * @inheritDoc + */ + public function getIdentifier(): string + { + return 'startswith'; + } +} diff --git a/src/QueryFilter/ListFieldFilterInterface.php b/src/Schema/DataObject/Plugin/QueryFilter/ListFieldFilterInterface.php similarity index 66% rename from src/QueryFilter/ListFieldFilterInterface.php rename to src/Schema/DataObject/Plugin/QueryFilter/ListFieldFilterInterface.php index 1f04b247c..db8354b55 100644 --- a/src/QueryFilter/ListFieldFilterInterface.php +++ b/src/Schema/DataObject/Plugin/QueryFilter/ListFieldFilterInterface.php @@ -1,7 +1,7 @@ getModel()->getSourceClass(), + DataObject::class + ), + 'Cannot apply plugin %s to a query that is not based on a DataObject' + ); + parent::apply($query, $schema, $config); + } + + /** + * @param string $class + * @param string $fieldName + * @param Schema $schema + * @return string|null + */ + public static function getObjectProperty(string $class, string $fieldName, Schema $schema): string + { + $modelType = $schema->getModelByClassName($class); + if ($modelType) { + /* @var ModelField $field */ + $field = $modelType->getFieldByName($fieldName); + if ($field) { + $prop = $field->getPropertyName(); + $sng = DataObject::singleton($class); + return FieldAccessor::singleton()->normaliseField($sng, $prop) ?: $fieldName; + } + } + + return $fieldName; + } + + /** + * @param array $context + * @return Closure + */ + public static function filter(array $context) + { + $mapping = $context['fieldMapping'] ?? []; + $fieldName = $context['fieldName']; + + return function (DataList $list, array $args) use ($mapping, $fieldName) { + $filterArgs = $args[$fieldName] ?? []; + /* @var FilterRegistryInterface $registry */ + $registry = Injector::inst()->get(FilterRegistryInterface::class); + $paths = static::buildPathsFromArgs($filterArgs); + foreach ($paths as $path => $value) { + $fieldParts = explode('.', $path); + $filterID = array_pop($fieldParts); + $fieldPath = implode('.', $fieldParts); + $normalised = $mapping[$fieldPath] ?? $fieldPath; + $filter = $registry->getFilterByIdentifier($filterID); + if ($filter) { + $list = $filter->apply($list, $normalised, $value); + } + } + + return $list; + }; + } + + /** + * @param ModelField $field + * @param ModelType $modelType + * @return bool + */ + protected function shouldAddField(ModelField $field, ModelType $modelType): bool + { + $fieldName = $field->getPropertyName(); + return stristr($fieldName, '.') === false; + } + + +} diff --git a/src/Schema/DataObject/Plugin/QuerySort.php b/src/Schema/DataObject/Plugin/QuerySort.php new file mode 100644 index 000000000..94e4a4549 --- /dev/null +++ b/src/Schema/DataObject/Plugin/QuerySort.php @@ -0,0 +1,146 @@ +getModel()->getSourceClass(), + DataObject::class + ), + 'Cannot apply plugin %s to a query that is not based on a DataObject', + $this->getIdentifier() + ); + parent::apply($query, $schema, $config); + } + + /** + * @param ModelType $modelType + * @param Schema $schema + * @param int $level + * @return array + * @throws SchemaBuilderException + */ + protected function buildAllFieldsConfig(ModelType $modelType, Schema $schema, int $level = 0): array + { + $filters = []; + /* @var ModelField $fieldObj */ + foreach ($modelType->getFields() as $fieldObj) { + $fieldName = $fieldObj->getPropertyName(); + if (!$modelType->getModel()->hasField($fieldName)) { + continue; + } + // Plural relationships are not sortable. No nested lists allowed. + if (!$fieldObj->isList() && $relatedModelType = $fieldObj->getModelType()) { + if ($level > $this->config()->get('max_nesting')) { + continue; + } + if ($relatedModel = $schema->getModel($relatedModelType->getName())) { + $filters[$fieldObj->getPropertyName()] = $this->buildAllFieldsConfig($relatedModel, $schema, $level + 1); + } + } else { + $filters[$fieldObj->getName()] = true; + } + } + + return $filters; + } + + + /** + * @param string $class + * @param string $fieldName + * @param Schema $schema + * @return string + */ + protected static function getObjectProperty(string $class, string $fieldName, Schema $schema): string + { + $modelType = $schema->getModelByClassName($class); + if ($modelType) { + /* @var ModelField $field */ + $field = $modelType->getFieldByName($fieldName); + if ($field) { + $prop = $field->getPropertyName(); + $sng = DataObject::singleton($class); + return FieldAccessor::singleton()->normaliseField($sng, $prop) ?: $fieldName; + } + } + + return $fieldName; + } + + /** + * @param array $context + * @return Closure + */ + public static function sort(array $context): closure + { + $mapping = $context['fieldMapping'] ?? []; + $fieldName = $context['fieldName']; + + return function (DataList $list, array $args) use ($mapping, $fieldName) { + $filterArgs = $args[$fieldName] ?? []; + $paths = static::buildPathsFromArgs($filterArgs); + foreach ($paths as $path => $value) { + $normalised = $mapping[$path] ?? $path; + $list = $list->sort($normalised, $value); + } + + return $list; + }; + } + + /** + * @param ModelField $field + * @param ModelType $modelType + * @return bool + */ + protected function shouldAddField(ModelField $field, ModelType $modelType): bool + { + return !$field->isList(); + } + +} diff --git a/src/Schema/DataObject/ReadCreator.php b/src/Schema/DataObject/ReadCreator.php new file mode 100644 index 000000000..14e140e34 --- /dev/null +++ b/src/Schema/DataObject/ReadCreator.php @@ -0,0 +1,68 @@ +setType("[$typeName]") + ->setPlugins($plugins) + ->setDefaultResolver([static::class, 'resolve']) + ->setResolverContext([ + 'dataClass' => $model->getSourceClass(), + ]); + } + + /** + * @param array $resolverContext + * @return Closure + */ + public static function resolve(array $resolverContext = []): Closure + { + $dataClass = $resolverContext['dataClass'] ?? null; + return function () use ($dataClass) { + if (!$dataClass) { + return null; + } + return DataList::create($dataClass); + }; + } + +} diff --git a/src/Schema/DataObject/ReadOneCreator.php b/src/Schema/DataObject/ReadOneCreator.php new file mode 100644 index 000000000..494567b82 --- /dev/null +++ b/src/Schema/DataObject/ReadOneCreator.php @@ -0,0 +1,52 @@ +setType($typeName) + ->setPlugins($plugins) + ->setDefaultResolver([ReadCreator::class, 'resolve']) + ->setResolverContext([ + 'dataClass' => $model->getSourceClass() + ]); + } + +} diff --git a/src/Schema/DataObject/Resolver.php b/src/Schema/DataObject/Resolver.php new file mode 100644 index 000000000..78a367537 --- /dev/null +++ b/src/Schema/DataObject/Resolver.php @@ -0,0 +1,69 @@ +fieldName; + return static::resolveField($obj, $fieldName); + } + + /** + * Property mapping allows custom property names for a DataObject field rather + * than relying on basic field formatting + * + * @param array $resolverContext + * @return Closure + */ + public static function resolveContext(array $resolverContext = []): Closure + { + $propertyMapping = $resolverContext['propertyMapping']; + return function( + DataObject $obj, + array $args, + array $context, + ResolveInfo $info + ) use ($propertyMapping) { + $fieldName = $info->fieldName; + $property = $propertyMapping[$fieldName] ?? null; + if (!$property) { + return null; + } + return static::resolveField($obj, $property); + }; + } + + /** + * @param DataObject $obj + * @param string $fieldName + * @return string|bool|int|float|null + */ + protected static function resolveField(DataObject $obj, string $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 new file mode 100644 index 000000000..893357a0f --- /dev/null +++ b/src/Schema/DataObject/UpdateCreator.php @@ -0,0 +1,166 @@ + '%$' . FieldAccessor::class, + ]; + + /** + * @var FieldAccessor + */ + private $fieldAccessor; + + /** + * @param SchemaModelInterface $model + * @param string $typeName + * @param array $config + * @return ModelOperation|null + * @throws SchemaBuilderException + */ + public function createOperation( + SchemaModelInterface $model, + string $typeName, + array $config = [] + ): ?ModelOperation + { + $plugins = $config['plugins'] ?? []; + $mutationName = $config['name'] ?? null; + if (!$mutationName) { + $mutationName = 'update' . ucfirst($typeName); + } + $inputTypeName = self::inputTypeName($typeName); + + return ModelMutation::create($model, $mutationName) + ->setType($typeName) + ->setPlugins($plugins) + ->setDefaultResolver([static::class, 'resolve']) + ->addResolverContext('dataClass', $model->getSourceClass()) + ->addArg('input', "{$inputTypeName}!"); + } + + /** + * @param array $resolverContext + * @return Closure + */ + public static function resolve(array $resolverContext = []): Closure + { + $dataClass = $resolverContext['dataClass'] ?? null; + return function ($obj, array $args, array $context, ResolveInfo $info) use ($dataClass) { + if (!$dataClass) { + return null; + } + $idField = FieldAccessor::singleton()->formatField('ID'); + $input = $args['input']; + $obj = DataList::create($dataClass) + ->byID($input[$idField]); + if (!$obj) { + throw new MutationException(sprintf( + '%s with ID %s not found', + $dataClass, + $input[$idField] + )); + } + unset($input[$idField]); + if (!$obj->canEdit($context[QueryHandler::CURRENT_USER])) { + throw new PermissionsException(sprintf( + 'Cannot edit this %s', + $dataClass + )); + } + $fieldAccessor = FieldAccessor::singleton(); + $update = []; + foreach ($input as $fieldName => $value) { + $update[$fieldAccessor->normaliseField($obj, $fieldName)] = $value; + } + + $obj->update($update); + $obj->write(); + + return $obj; + }; + } + + /** + * @param SchemaModelInterface $model + * @param string $typeName + * @param array $config + * @return array + * @throws SchemaBuilderException + */ + public function provideInputTypes(SchemaModelInterface $model, string $typeName, array $config = []): array + { + $dataObject = Injector::inst()->get($model->getSourceClass()); + $includedFields = $this->reconcileFields($config, $dataObject, $this->getFieldAccessor()); + + $fieldMap = []; + foreach ($includedFields as $fieldName) { + $fieldMap[$fieldName] = $model->getField($fieldName)->getType(); + } + $inputType = InputType::create( + self::inputTypeName($typeName), + [ + 'fields' => $fieldMap + ] + ); + + return [$inputType]; + } + + /** + * @return FieldAccessor + */ + public function getFieldAccessor(): FieldAccessor + { + return $this->fieldAccessor; + } + + /** + * @param FieldAccessor $fieldAccessor + * @return UpdateCreator + */ + public function setFieldAccessor(FieldAccessor $fieldAccessor): UpdateCreator + { + $this->fieldAccessor = $fieldAccessor; + return $this; + } + + + /** + * @param string $typeName + * @return string + */ + private static function inputTypeName(string $typeName): string + { + return 'Update' . ucfirst($typeName) . 'Input'; + } + +} diff --git a/src/Schema/Exception/MutationException.php b/src/Schema/Exception/MutationException.php new file mode 100644 index 000000000..e4b6b61ea --- /dev/null +++ b/src/Schema/Exception/MutationException.php @@ -0,0 +1,13 @@ +name = $name; + Schema::invariant( + is_string($config) || is_array($config), + '%::%s requires a string type name or an array as a second parameter', + __CLASS__, + __FUNCTION__ + ); + + $appliedConfig = is_string($config) ? ['type' => $config] : $config; + $this->applyConfig($appliedConfig); + } + + /** + * @param array $config + * @throws SchemaBuilderException + */ + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, ['description', 'defaultValue', 'type']); + $type = $config['type'] ?? null; + Schema::invariant( + $type, + 'No type provided for argument %s', + $this->getName() + ); + $this->setType($type); + + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + if (isset($config['defaultValue'])) { + $this->setDefaultValue($config['defaultValue']); + } + } + + /** + * @param string|EncodedType $type + * @return $this + * @throws SchemaBuilderException + */ + public function setType($type): self + { + Schema::invariant( + is_string($type) || $type instanceof EncodedType, + 'Type on arg %s must be a string or instance of %s', + $this->getName(), + EncodedType::class + ); + if (is_string($type)) { + $ref = TypeReference::create($type); + $this->setDefaultValue($ref->getDefaultValue()); + } + $this->type = $type; + return $this; + } + + /** + * @return string|null + */ + public function getType(): ?string + { + return $this->type; + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string $name + * @return Argument + */ + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + /** + * @return EncodedType + */ + public function getEncodedType(): EncodedType + { + if ($this->type instanceof EncodedType) { + return $this->type; + } + + $ref = TypeReference::create($this->type); + + return EncodedType::create($ref); + } + + /** + * @return bool|int|string|null + */ + public function getDefaultValue() + { + return $this->defaultValue; + } + + /** + * @param bool|int|string|null $defaultValue + * @return Argument + */ + public function setDefaultValue($defaultValue): self + { + $this->defaultValue = $defaultValue; + return $this; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * @return Argument + */ + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + /** + * @return string + * @throws SchemaBuilderException + */ + public function getSignature(): string + { + $components = [ + $this->getName(), + $this->getEncodedType()->encode(), + $this->getDescription(), + $this->getDefaultValue(), + ]; + + return md5(json_encode($components)); + } + +} diff --git a/src/Schema/Field/Field.php b/src/Schema/Field/Field.php new file mode 100644 index 000000000..889ae0bdc --- /dev/null +++ b/src/Schema/Field/Field.php @@ -0,0 +1,632 @@ +setResolverRegistry(Injector::inst()->get(ResolverRegistry::class)); + list ($name, $args) = static::parseName($name); + $this->setName($name); + + Schema::invariant( + is_string($config) || is_array($config), + 'Config for field %s must be a string or array. Got %s', + $name, + gettype($config) + ); + $appliedConfig = is_string($config) ? ['type' => $config] : $config; + if ($args) { + $configArgs = $config['args'] ?? []; + $appliedConfig['args'] = array_merge($configArgs, $args); + } + $this->applyConfig($appliedConfig); + } + + /** + * Negotiates a variety of syntax that can appear in a field name definition. + * + * fieldName + * fieldName(arg1: String!, arg2: Int) + * fieldName(arg1: String! = "foo") + * + * @param string $def + * @throws SchemaBuilderException + * @return array + */ + public static function parseName(string $def): array + { + $name = null; + $args = null; + if (stristr($def, Token::PAREN_L) !== false) { + list ($name, $args) = explode(Token::PAREN_L, $def); + } else { + $name = $def; + } + Schema::assertValidName($name); + + if (!$args) { + return [$name, []]; + } + + preg_match('/^(.*?)\)$/', $args, $matches); + + Schema::invariant( + $matches, + 'Could not parse args on "%s"', + $def + ); + $argList = []; + $argDefs = explode(',', $matches[1]); + foreach ($argDefs as $argDef) { + Schema::invariant( + stristr($argDef, Token::COLON) !== false, + 'Invalid arg: %s', + $argDef + ); + list ($argName, $argType) = explode(':', $argDef); + $argList[trim($argName)] = trim($argType); + } + return [$name, $argList]; + } + + /** + * @param array $config + * @throws SchemaBuilderException + */ + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, [ + 'type', + 'model', + 'args', + 'description', + 'resolver', + 'resolverContext', + 'defaultResolver', + 'plugins', + ]); + + $type = $config['type'] ?? null; + $modelTypeDef = $config['model'] ?? null; + if ($modelTypeDef) { + $this->setTypeByModel($modelTypeDef); + } + if ($type) { + $this->setType($type); + } + + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + if (isset($config['resolver'])) { + $this->setResolver($config['resolver']); + } + if (isset($config['defaultResolver'])) { + $this->setDefaultResolver($config['defaultResolver']); + } + if (isset($config['resolverContext'])) { + $this->setResolverContext($config['resolverContext']); + } + + $plugins = $config['plugins'] ?? []; + $this->setPlugins($plugins); + $args = $config['args'] ?? []; + $this->setArgs($args); + } + + /** + * @param string $argName + * @param null $config + * @param callable|null $callback + * @return Field + */ + public function addArg(string $argName, $config, ?callable $callback = null): self + { + $argObj = $config instanceof Argument ? $config : Argument::create($argName, $config); + $this->args[$argObj->getName()] = $argObj; + if ($callback) { + call_user_func_array($callback, [$argObj]); + } + return $this; + } + + /** + * @param array $args + * @return $this + * @throws SchemaBuilderException + */ + public function setArgs(array $args): self + { + Schema::assertValidConfig($args); + foreach ($args as $argName => $config) { + if ($config === false) { + continue; + } + $this->addArg($argName, $config); + } + + return $this; + } + + /** + * @param Field $field + * @return Field + */ + public function mergeWith(Field $field): self + { + foreach ($field->getArgs() as $arg) { + $this->args[$arg->getName()] = $arg; + } + $this->mergePlugins($field->getPlugins()); + + return $this; + } + + /** + * @return bool + */ + public function isList(): bool + { + return $this->getTypeRef()->isList(); + } + + /** + * @return bool + */ + public function isRequired(): bool + { + return $this->getTypeRef()->isRequired(); + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + Schema::invariant( + $this->type, + 'Field %s has no type defined', + $this->getName() + ); + } + + /** + * @param $type + * @return Field + * @throws SchemaBuilderException + */ + public function setType($type): self + { + Schema::invariant( + !is_array($type), + 'Type on %s is an array. Did you forget to quote the [TypeName] syntax in your YAML?', + $this->getName() + ); + Schema::invariant( + is_string($type) || $type instanceof EncodedType, + '%s::%s must be a string or an instance of %s', + __CLASS__, + __FUNCTION__, + EncodedType::class + ); + + $this->type = $type; + + return $this; + } + + /** + * @param string $modelTypeDef + * @return $this + * @throws SchemaBuilderException + */ + public function setTypeByModel(string $modelTypeDef): self + { + $safeModelTypeDef = str_replace('\\', '__', $modelTypeDef); + $safeNamedClass = TypeReference::create($safeModelTypeDef)->getNamedType(); + $namedClass = str_replace('__', '\\', $safeNamedClass); + /* @var SchemaModelCreatorRegistry $registry */ + $registry = SchemaModelCreatorRegistry::singleton(); + $model = $registry->getModel($namedClass); + Schema::invariant( + $model, + 'No model found for %s on %s', + $namedClass, + $this->getName() + ); + + $typeName = $model->getTypeName(); + $wrappedTypeName = str_replace($namedClass, $typeName, $modelTypeDef); + + return $this->setType($wrappedTypeName); + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string $name + * @return Field + */ + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return Argument[] + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * @return EncodedType + * @throws SchemaBuilderException + */ + public function getEncodedType(): EncodedType + { + Schema::invariant( + $this->type, + 'Field %s has no type defined.', + $this->getName() + ); + return $this->type instanceof EncodedType + ? $this->type + : EncodedType::create($this->getTypeRef()); + } + + /** + * Gets the name of the type, ignoring any nonNull/listOf wrappers + * + * @return string + */ + public function getNamedType(): string + { + return $this->getTypeRef()->getNamedType(); + } + + /** + * @param string|null $typeName + * @return EncodedResolver + */ + public function getEncodedResolver(?string $typeName = null): EncodedResolver + { + if ($this->getResolver()) { + $encodedResolver = EncodedResolver::create($this->getResolver(), $this->getResolverContext()); + } else { + $resolver = $this->getResolverRegistry()->findResolver($typeName, $this); + $encodedResolver = EncodedResolver::create($resolver, $this->getResolverContext()); + } + + foreach ($this->resolverMiddlewares as $middlewareRef) { + $encodedResolver->addMiddleware($middlewareRef); + } + foreach ($this->resolverAfterwares as $afterwareRef) { + $encodedResolver->addAfterware($afterwareRef); + } + return $encodedResolver; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * @return Field + */ + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + /** + * @return ResolverReference|null + */ + public function getResolver(): ?ResolverReference + { + return $this->resolver; + } + + /** + * @param array|string|ResolverReference|null $resolver + * @return Field + */ + public function setResolver($resolver): self + { + if ($resolver) { + $this->resolver = $resolver instanceof ResolverReference + ? $resolver + : ResolverReference::create($resolver); + } else { + $this->resolver = null; + } + + return $this; + } + + /** + * @return ResolverReference|null + */ + public function getDefaultResolver(): ?ResolverReference + { + return $this->defaultResolver; + } + + /** + * @param array|string|ResolverReference|null $defaultResolver + * @return Field + */ + public function setDefaultResolver($defaultResolver): self + { + if ($defaultResolver) { + $this->defaultResolver = $defaultResolver instanceof ResolverReference + ? $defaultResolver + : ResolverReference::create($defaultResolver); + } else { + $this->defaultResolver = null; + } + + return $this; + } + + /** + * @return ResolverRegistry + */ + public function getResolverRegistry(): ResolverRegistry + { + return $this->resolverRegistry; + } + + /** + * @param ResolverRegistry $resolverRegistry + * @return $this + */ + public function setResolverRegistry(ResolverRegistry $resolverRegistry): self + { + $this->resolverRegistry = $resolverRegistry; + return $this; + } + + /** + * @return array|null + */ + public function getResolverContext(): ?array + { + return $this->resolverContext; + } + + /** + * @param array|null $resolverContext + * @return Field + */ + public function setResolverContext(?array $resolverContext): self + { + $this->resolverContext = $resolverContext; + return $this; + } + + /** + * @param string $key + * @param $value + * @return Field + */ + public function addResolverContext(string $key, $value): self + { + $this->resolverContext[$key] = $value; + + return $this; + } + + /** + * @param array|string|ResolverReference|null $resolver + * @param array|null $context + * @return $this + */ + public function addResolverMiddleware($resolver, ?array $context = null): self + { + return $this->decorateResolver(EncodedResolver::MIDDLEWARE, $resolver, $context); + } + + /** + * @param array|string|ResolverReference|null $resolver + * @param array|null $context + * @return $this + */ + public function addResolverAfterware($resolver, ?array $context = null): self + { + return $this->decorateResolver(EncodedResolver::AFTERWARE, $resolver, $context); + } + + /** + * @return string + * @throws SchemaBuilderException + * @throws Exception + */ + public function getSignature(): string + { + $args = $this->getArgs(); + usort($args, function (Argument $a, Argument $z) { + return $a->getName() <=> $z->getName(); + }); + + $components = [ + $this->getName(), + $this->getEncodedType()->encode(), + $this->getEncodedResolver()->getExpression(), + $this->getDescription(), + $this->getSortedPlugins(), + array_map(function (Argument $arg) { + return $arg->getSignature(); + }, $args), + ]; + + return md5(json_encode($components)); + } + + /** + * @param string $pluginName + * @param $plugin + * @throws SchemaBuilderException + */ + public function validatePlugin(string $pluginName, $plugin): void + { + Schema::invariant( + $plugin instanceof FieldPlugin, + 'Plugin %s does not apply to field "%s"', + $pluginName, + $this->getName() + ); + } + + /** + * @param string $position + * @param array|string|ResolverReference|null $resolver + * @param array|null $context + * @return Field + */ + private function decorateResolver(string $position, $resolver, ?array $context = null): self + { + if ($resolver) { + $ref = $resolver instanceof ResolverReference + ? $resolver + : ResolverReference::create($resolver); + if ($position === EncodedResolver::MIDDLEWARE) { + $this->resolverMiddlewares[] = EncodedResolver::create($ref, $context); + } else if ($position === EncodedResolver::AFTERWARE) { + $this->resolverAfterwares[] = EncodedResolver::create($ref, $context); + } + } + + return $this; + } + + /** + * @return TypeReference + */ + private function getTypeRef(): TypeReference + { + return TypeReference::create($this->type); + } + +} diff --git a/src/Schema/Field/ModelAware.php b/src/Schema/Field/ModelAware.php new file mode 100644 index 000000000..26d3bc4ed --- /dev/null +++ b/src/Schema/Field/ModelAware.php @@ -0,0 +1,37 @@ +model; + } + + /** + * @param SchemaModelInterface $model + * @return ModelQuery + */ + public function setModel(SchemaModelInterface $model): self + { + $this->model = $model; + return $this; + } + +} diff --git a/src/Schema/Field/ModelField.php b/src/Schema/Field/ModelField.php new file mode 100644 index 000000000..d6216cd3c --- /dev/null +++ b/src/Schema/Field/ModelField.php @@ -0,0 +1,169 @@ +setModel($model); + Schema::invariant( + is_array($config) || is_string($config) || $config === true, + 'Config for field %s must be a string representing a type, a map of config or a value of true + to for type introspection from the model.', + $name + ); + if (is_string($config)) { + $config = ['type' => $config]; + } + if (!is_array($config)) { + $config = []; + } + + parent::__construct($name, $config); + } + + public function applyConfig(array $config) + { + $type = $config['type'] ?? null; + if ($type) { + $this->setType($type); + } + + if (isset($config['property'])) { + $this->setProperty($config['property']); + } + + $resolver = $config['resolver'] ?? null; + if ($resolver) { + $this->setResolver($resolver); + } + $defaultResolver = $config['defaultResolver'] ?? null; + if (!$defaultResolver) { + $config['defaultResolver'] = $this->getModel()->getDefaultResolver($this->getResolverContext()); + } + + $this->modelTypeFields = $config['fields'] ?? null; + + unset($config['fields']); + unset($config['operations']); + unset($config['property']); + + parent::applyConfig($config); + } + + /** + * @return array|null + */ + public function getResolverContext(): ?array + { + $context = []; + if ($this->getProperty() && $this->getProperty() !== $this->getName()) { + $context = [ + 'propertyMapping' => [ + $this->getName() => $this->getProperty(), + ] + ]; + } + + return array_merge(parent::getResolverContext(), $context); + } + + /** + * For nested field definitions + * Blog: + * fields: + * Comments: + * fields: + * Author: + * fields: + * Name: String + * @return ModelType|null + * @throws SchemaBuilderException + */ + public function getModelType(): ?ModelType + { + $model = $this->getModel()->getModelTypeForField($this->getName()); + if ($model) { + $config = []; + if ($this->modelTypeFields) { + $config['fields'] = $this->modelTypeFields; + } + $model->applyConfig($config); + } + + return $model; + } + + /** + * @return string|null + */ + public function getProperty(): ?string + { + return $this->property; + } + + /** + * @param string|null $property + * @return ModelField + */ + public function setProperty(?string $property): self + { + $this->property = $property; + + return $this; + } + + /** + * @return string + */ + public function getPropertyName(): string + { + return $this->getProperty() ?: $this->getName(); + } + + /** + * @param string $pluginName + * @param $plugin + * @throws SchemaBuilderException + */ + public function validatePlugin(string $pluginName, $plugin): void + { + Schema::invariant( + $plugin && ($plugin instanceof ModelFieldPlugin || $plugin instanceof FieldPlugin), + 'Plugin %s not found or does not apply to field "%s"', + $pluginName, + $this->getName() + ); + } +} diff --git a/src/Schema/Field/ModelMutation.php b/src/Schema/Field/ModelMutation.php new file mode 100644 index 000000000..93598c852 --- /dev/null +++ b/src/Schema/Field/ModelMutation.php @@ -0,0 +1,56 @@ +setModel($model); + parent::__construct($mutationName, $config); + } + + /** + * @param string $pluginName + * @param $plugin + * @throws SchemaBuilderException + */ + public function validatePlugin(string $pluginName, $plugin): void + { + Schema::invariant( + $plugin && ( + $plugin instanceof ModelMutationPlugin || + $plugin instanceof MutationPlugin || + $plugin instanceof FieldPlugin + ), + 'Plugin %s not found or does not apply to model mutation "%s"', + $pluginName, + ModelMutationPlugin::class, + MutationPlugin::class, + FieldPlugin::class + ); + } +} diff --git a/src/Schema/Field/ModelQuery.php b/src/Schema/Field/ModelQuery.php new file mode 100644 index 000000000..fb806f733 --- /dev/null +++ b/src/Schema/Field/ModelQuery.php @@ -0,0 +1,56 @@ +setModel($model); + parent::__construct($queryName, $config, $model); + } + + /** + * @param string $pluginName + * @param $plugin + * @throws SchemaBuilderException + */ + public function validatePlugin(string $pluginName, $plugin): void + { + Schema::invariant( + $plugin && ( + $plugin instanceof ModelQueryPlugin || + $plugin instanceof QueryPlugin || + $plugin instanceof ModelFieldPlugin || + $plugin instanceof FieldPlugin + ), + 'Plugin %s not found or does not apply to model query "%s"', + $pluginName, + $this->getName() + ); + + } + +} diff --git a/src/Schema/Field/Mutation.php b/src/Schema/Field/Mutation.php new file mode 100644 index 000000000..6f81793eb --- /dev/null +++ b/src/Schema/Field/Mutation.php @@ -0,0 +1,26 @@ +getName() + ); + } +} diff --git a/src/Schema/Field/Query.php b/src/Schema/Field/Query.php new file mode 100644 index 000000000..829dcfafd --- /dev/null +++ b/src/Schema/Field/Query.php @@ -0,0 +1,32 @@ +getName() + ); + } +} diff --git a/src/Schema/Interfaces/ConfigurationApplier.php b/src/Schema/Interfaces/ConfigurationApplier.php new file mode 100644 index 000000000..5af3ac6aa --- /dev/null +++ b/src/Schema/Interfaces/ConfigurationApplier.php @@ -0,0 +1,16 @@ + DataObjectModel + */ +interface SchemaModelCreatorInterface +{ + /** + * @param string $class + * @return bool + */ + public function appliesTo(string $class): bool; + + /** + * @param string $class + * @return SchemaModelInterface + */ + public function createModel(string $class): SchemaModelInterface; +} diff --git a/src/Schema/Interfaces/SchemaModelInterface.php b/src/Schema/Interfaces/SchemaModelInterface.php new file mode 100644 index 000000000..483528116 --- /dev/null +++ b/src/Schema/Interfaces/SchemaModelInterface.php @@ -0,0 +1,61 @@ +getType())->getNamedType(); + + $configFields = $config['fields'] ?? Schema::ALL; + + $modelType = $schema->getModel($typeName); + Schema::invariant( + $modelType, + 'Could not find model for query that uses %s. Were plugins applied before the schema was done loading?', + $typeName + ); + $fieldName = $this->config()->get('field_name'); + $prefix = $query->getName(); + if ($configFields === Schema::ALL) { + $configFields = $this->buildAllFieldsConfig($modelType, $schema); + $prefix = ''; + } + Schema::assertValidConfig($configFields); + $this->addInputTypesToSchema($modelType, $schema, $configFields, $prefix); + $rootTypeName = $prefix . static::getTypeName($modelType); + /* @var InputType $rootType */ + $rootType = $schema->getType($rootTypeName); + $pathMapping = $this->buildPathsFromInputType($rootType, $schema); + + $fieldMapping = []; + foreach ($pathMapping as $fieldPath => $propPath) { + if ($fieldPath !== $propPath) { + $fieldMapping[$fieldPath] = $propPath; + } + } + + $query->addArg($fieldName, $rootType->getName()); + + + $query->addResolverAfterware( + $this->getResolver(), + [ + 'fieldMapping' => $fieldMapping, + 'fieldName' => $this->getFieldName(), + ] + ); + + } + + /** + * @param ModelType $modelType + * @param Schema $schema + * @param int $level + * @return array + * @throws SchemaBuilderException + */ + protected function buildAllFieldsConfig(ModelType $modelType, Schema $schema, int $level = 1): array + { + $key = md5($schema->getSchemaKey() . $modelType->getName()); + $existing = $this->_allConfigCache[$key] ?? null; + if ($existing) { + return $existing; + } + $filters = []; + /* @var ModelField $fieldObj */ + foreach ($modelType->getFields() as $fieldObj) { + if (!$this->shouldAddField($fieldObj, $modelType)) { + continue; + } + $fieldName = $fieldObj->getPropertyName(); + if (!$modelType->getModel()->hasField($fieldName)) { + continue; + } + if ($relatedModelType = $fieldObj->getModelType()) { + if ($level > $this->config()->get('max_nesting')) { + continue; + } + $relatedModel = $schema->getModel($relatedModelType->getName()); + Schema::invariant( + $relatedModel, + 'Field %s on %s points to model %s which does not exist', + $fieldObj->getName(), + $modelType->getName(), + $relatedModelType->getName() + ); + // Prevent stupid recursion in self-referential relationships, e.g. Parent + if ($relatedModel->getName() === $modelType->getName()) { + $filters[$fieldObj->getName()] = $fieldObj->isList() + ? self::SELF_REFERENTIAL_LIST + : self::SELF_REFERENTIAL; + } else { + $filters[$fieldObj->getName()] = $this->buildAllFieldsConfig( + $relatedModel, + $schema, + $level + 1 + ); + } + } else { + $filters[$fieldObj->getName()] = true; + } + } + $this->_allConfigCache[$modelType->getName()] = $filters; + + return $filters; + } + + /** + * @param ModelType $parentModel + * @param Schema $schema + * @param array $fields + * @param string $prefix + * @throws SchemaBuilderException + */ + protected function addInputTypesToSchema( + ModelType $parentModel, + Schema $schema, + array $fields, + string $prefix = '' + ): void { + $parentInputTypeName = $prefix . static::getTypeName($parentModel); + $parentType = $schema->getType($parentInputTypeName); + if ($parentType) { + return; + } + + $parentType = InputType::create($parentInputTypeName); + $schema->addType($parentType); + + foreach ($fields as $fieldName => $data) { + if ($data === false) { + continue; + } + + /* @var ModelField $fieldObj */ + $fieldObj = $parentModel->getFieldByName($fieldName); + $modelType = $fieldObj->getModelType(); + if (!$this->shouldAddField($fieldObj, $parentModel)) { + continue; + } + + if ($data === self::SELF_REFERENTIAL) { + // Self-referential input type + $parentType->addField($fieldName, $parentType->getName()); + } else if ($data === self::SELF_REFERENTIAL_LIST) { + $parentType->addField($fieldName, '[' . $parentType->getName() . ']'); + } else if (!is_array($data)) { + // Regular field, e.g. scalar + $fieldType = $fieldObj->getNamedType(); + if (!$modelType && in_array($fieldType, Schema::getInternalTypes())) { + $parentType->addField( + $fieldName, + static::getLeafNodeType($fieldType) + ); + } + } else { + // Nested input. Recursion. + Schema::invariant( + $modelType, + 'Filter for field %s is declared as an array, but the field is not a nested object type', + $fieldName + ); + $relatedModel = $schema->getModel($modelType->getName()); + $nextInputTypeName = $prefix . static::getTypeName($relatedModel); + $parentType->addField($fieldName, $nextInputTypeName); + $this->addInputTypesToSchema($relatedModel, $schema, $data, $prefix); + } + } + } + + /** + * @param InputType $inputType + * @param Schema $schema + * @param array $fieldOrigin + * @param array $propOrigin + * @param int $level + * @return array + * @throws SchemaBuilderException + */ + protected function buildPathsFromInputType( + InputType $inputType, + Schema $schema, + array $fieldOrigin = [], + array $propOrigin = [], + int $level = 0 + ): array { + $allPaths = []; + /* @var Field $fieldObj */ + foreach ($inputType->getFields() as $fieldObj) { + $fieldName = $fieldObj->getName(); + $fieldPath = array_merge($fieldOrigin, [$fieldName]); + $modelName = static::getModelName($inputType); + /* @var ModelType $model */ + $model = $schema->getModel($modelName); + Schema::invariant( + $model, + 'Field "%s" on input type "%s" does not point to a valid model "%s"', + $fieldName, + $inputType->getName(), + $model + ); + $prop = static::getObjectProperty( + $model->getSourceClass(), + $fieldObj->getName(), + $schema + ); + $propPath = array_merge($propOrigin, [$prop]); + + $modelFieldType = $model->getFieldByName($fieldName)->getType(); + $fieldType = $fieldObj->getNamedType(); + $leafType = static::getLeafNodeType($modelFieldType); + // This is the leaf node type. Stop here. + if ($fieldType === $leafType) { + $allPaths[implode('.', $fieldPath)] = implode('.', $propPath); + continue; + } + // If not, it's a nested input. Keep recursing. + $nestedType = $schema->getType($fieldType); + $isMax = $level > $this->config()->get('max_nesting'); + if ($nestedType instanceof InputType && !$isMax) { + $allPaths = array_merge( + $allPaths, + $this->buildPathsFromInputType($nestedType, $schema, $fieldPath, $propPath, $level + 1) + ); + } else { + $allPaths[implode('.', $fieldPath)] = implode('.', $propPath); + } + + } + + return $allPaths; + } + + /** + * To be overloaded by subclass to get access a property on an object given an + * input field + * + * @param string $class + * @param string $fieldName + * @param Schema $schema + * @return string + */ + protected static function getObjectProperty(string $class, string $fieldName, Schema $schema): string + { + return $fieldName; + } + + /** + * When the input reaches a leaf node, get the type, e.g. for a filter this could be + * "String" -> { eq: String } + * + * @param string $internalType + * @return string + */ + protected static function getLeafNodeType(string $internalType): string + { + return $internalType; + } + + /** + * Public API that can be used by a resolver to flatten the input argument into + * dot.separated.paths that can be normalised against the context provided by + * buildPathsFromFieldMapping() + * + * @param array $argFilters + * @param array $origin + * @return array + */ + public static function buildPathsFromArgs(array $argFilters, array $origin = []): array + { + $allPaths = []; + foreach ($argFilters as $fieldName => $val) { + $path = array_merge($origin, [$fieldName]); + if (is_array($val)) { + $allPaths = array_merge($allPaths, static::buildPathsFromArgs($val, $path)); + } else { + $allPaths[implode('.', $path)] = $val; + } + } + + return $allPaths; + } + + /** + * Allows certain fields to be excluded + * + * @param ModelField $field + * @param ModelType $modelType + * @return bool + */ + protected function shouldAddField(ModelField $field, ModelType $modelType): bool + { + return true; + } + + /** + * @return array + */ + public function getFieldMapping(): array + { + return $this->fieldMapping; + } + + /** + * @return string + */ + abstract protected function getFieldName(): string; + + /** + * @return array + */ + abstract protected function getResolver(): array; + + /** + * @param ModelType $modelType + * @return string + */ + abstract public static function getTypeName(ModelType $modelType): string; + + /** + * @param InputType $inputType + * @return string + */ + abstract public static function getModelName(InputType $inputType): string; +} diff --git a/src/Schema/Plugin/AbstractQueryFilterPlugin.php b/src/Schema/Plugin/AbstractQueryFilterPlugin.php new file mode 100644 index 000000000..e6c259261 --- /dev/null +++ b/src/Schema/Plugin/AbstractQueryFilterPlugin.php @@ -0,0 +1,91 @@ +config()->get('field_name'); + } + + /** + * Creates all the { eq: String, lte: String }, { eq: Int, lte: Int } etc types for comparisons + * @param Schema $schema + * @throws SchemaBuilderException + */ + public static function updateSchema(Schema $schema): void + { + /* @var FieldFilterRegistry $registry */ + $registry = Injector::inst()->get(FilterRegistryInterface::class); + $filters = $registry->getAll(); + if (empty($filters)) { + return; + } + foreach (Schema::getInternalTypes() as $typeName) { + $type = InputType::create(static::getLeafNodeType($typeName)); + foreach ($filters as $id => $filterInstance) { + if ($filterInstance instanceof ListFieldFilterInterface) { + $type->addField($id, "[{$typeName}]"); + } else { + $type->addField($id, $typeName); + } + } + $schema->addType($type); + } + } + + /** + * @param ModelType $modelType + * @return string + */ + public static function getTypeName(ModelType $modelType): string + { + $modelTypeName = $modelType->getModel()->getTypeName(); + return $modelTypeName . 'FilterFields'; + } + + /** + * @param InputType $inputType + * @return string + */ + public static function getModelName(InputType $inputType): string + { + return preg_replace('/FilterFields$/', '', $inputType->getName()); + } + + /** + * @param string $internalType + * @return string + */ + protected static function getLeafNodeType(string $internalType): string + { + return sprintf('QueryFilter%sComparator', $internalType); + } + + +} diff --git a/src/Schema/Plugin/AbstractQuerySortPlugin.php b/src/Schema/Plugin/AbstractQuerySortPlugin.php new file mode 100644 index 000000000..1212a3a21 --- /dev/null +++ b/src/Schema/Plugin/AbstractQuerySortPlugin.php @@ -0,0 +1,76 @@ +config()->get('field_name'); + } + + + /** + * @param Schema $schema + */ + public static function updateSchema(Schema $schema): void + { + $type = Enum::create( + 'SortDirection', + [ + 'ASC' => 'ASC', + 'DESC' => 'DESC', + ] + ); + $schema->addEnum($type); + } + + /** + * @param ModelType $modelType + * @return string + */ + public static function getTypeName(ModelType $modelType): string + { + $modelTypeName = $modelType->getModel()->getTypeName(); + return $modelTypeName . 'SortFields'; + } + + /** + * @param InputType $inputType + * @return string + */ + public static function getModelName(InputType $inputType): string + { + return preg_replace('/SortFields$/', '', $inputType->getName()); + } + + /** + * @param string $internalType + * @return string + */ + protected static function getLeafNodeType(string $internalType): string + { + return 'SortDirection'; + } + +} diff --git a/src/Schema/Plugin/PaginationPlugin.php b/src/Schema/Plugin/PaginationPlugin.php new file mode 100644 index 000000000..7fc985b56 --- /dev/null +++ b/src/Schema/Plugin/PaginationPlugin.php @@ -0,0 +1,170 @@ +config()->get('resolver'); + Schema::invariant( + $resolver, + '%s has no resolver defined', + __CLASS__ + ); + + return ResolverReference::create($resolver)->toArray(); + } + + /** + * @param Schema $schema + * @throws SchemaBuilderException + */ + public static function updateSchema(Schema $schema): void + { + // Create the PageInfo type, which is universal + $pageinfoType = Type::create('PageInfo') + ->addField('totalCount', 'Int!') + ->addField('hasNextPage', 'Boolean') + ->addField('hasPreviousPage', 'Boolean') + ->setDescription('Information about pagination in a connection.'); + + $schema->addType($pageinfoType); + + } + + /** + * @param Field $field + * @param Schema $schema + * @param array|null $config + * @throws SchemaBuilderException + */ + public function apply(Field $field, Schema $schema, array $config = []): void + { + $defaultLimit = $config['defaultLimit'] ?? $this->config()->get('default_limit'); + $max = $this->config()->get('max_limit'); + $limit = min($defaultLimit, $max); + $field->addArg('limit', "Int = $limit") + ->addArg('offset', "Int = 0") + ->addResolverAfterware( + $this->getPaginationResolver($config), + ['maxLimit' => $max] + ); + + // Set the new return type + $plainType = $field->getNamedType(); + $field->setType($field->getName() . 'Connection'); + + // Create the edge type for this query + $edgeType = Type::create($field->getName() . 'Edge') + ->setDescription('The collections edge') + ->addField('node', $plainType, function (Field $field) { + $field->setResolver([static::class, 'noop']) + ->setDescription('The node at the end of the collections edge'); + }); + $schema->addType($edgeType); + + // Create the connection type for this query + $connectionType = Type::create($field->getName() . 'Connection') + ->addField('edges', "[{$edgeType->getName()}]!") + ->addField('nodes', "[$plainType]!") + ->addField('pageInfo', 'PageInfo!'); + + $schema->addType($connectionType); + } + + /** + * @param int $total + * @param iterable $limitedResults + * @param int $limit + * @param int $offset + * @return array + */ + public static function createPaginationResult( + int $total, + iterable $limitedResults, + int $limit, + int $offset + ): array { + $nextPage = false; + $previousPage = false; + + // Flag prev-next page + if ($limit && (($limit + $offset) < $total)) { + $nextPage = true; + } + if ($offset > 0) { + $previousPage = true; + } + return [ + 'edges' => $limitedResults, + 'nodes' => $limitedResults, + 'pageInfo' => [ + 'totalCount' => $total, + 'hasNextPage' => $nextPage, + 'hasPreviousPage' => $previousPage + ] + ]; + } + + /** + * "node" is just structural and should use a noop + * + * @param $obj + * @return mixed + */ + public static function noop ($obj) + { + return $obj; + } +} diff --git a/src/Schema/Plugin/PluginConsumer.php b/src/Schema/Plugin/PluginConsumer.php new file mode 100644 index 000000000..877f9b9c6 --- /dev/null +++ b/src/Schema/Plugin/PluginConsumer.php @@ -0,0 +1,260 @@ +plugins[$pluginName] = $config; + + return $this; + } + + /** + * @param string $pluginName + * @return $this + */ + public function removePlugin(string $pluginName): self + { + unset($this->plugins[$pluginName]); + + return $this; + } + + /** + * @param array $plugins + * @return $this + */ + public function mergePlugins(array $plugins): self + { + foreach ($plugins as $identifier => $config) { + if (isset($this->plugins[$identifier])) { + $this->plugins[$identifier] = array_merge( + $this->plugins[$identifier], + $config + ); + } else { + $this->plugins[$identifier] = $config; + } + } + + return $this; + } + + /** + * @param array $plugins + * @return $this + * @throws SchemaBuilderException + */ + public function setPlugins(array $plugins): self + { + Schema::assertValidConfig($plugins); + foreach ($plugins as $pluginName => $config) { + if ($config === false) { + $this->excludedPlugins[$pluginName] = true; + continue; + } + $pluginConfig = $config === true ? [] : $config; + $this->addPlugin($pluginName, $pluginConfig); + } + + return $this; + } + + /** + * @param array $plugins + * @return $this + * @throws SchemaBuilderException + */ + public function setDefaultPlugins(array $plugins): self + { + Schema::assertValidConfig($plugins); + foreach ($plugins as $pluginName => $config) { + if ($config === false) { + continue; + } + $pluginConfig = $config === true ? [] : $config; + $this->defaultPlugins[$pluginName] = $pluginConfig; + } + + return $this; + } + + /** + * @param bool $inheritDefaults + * @return array + */ + public function getPlugins(bool $inheritDefaults = true): array + { + $plugins = $inheritDefaults + ? array_replace_recursive($this->defaultPlugins, $this->plugins) + : $this->plugins; + $excluded = array_keys($this->excludedPlugins); + foreach ($excluded as $pluginName) { + unset($plugins[$pluginName]); + } + + return $plugins; + } + + /** + * @return array + */ + public function getDefaultPlugins(): array + { + return $this->defaultPlugins; + } + + /** + * @param string $identifier + * @return bool + */ + public function hasPlugin(string $identifier): bool + { + $ids = array_keys($this->getPlugins()); + + return in_array($identifier, $ids); + } + + /** + * @return PluginRegistry + */ + public function getPluginRegistry(): PluginRegistry + { + return Injector::inst()->get(PluginRegistry::class); + } + + /** + * Translates all the ID and config settings to first class instances + * + * @return Generator + * @throws SchemaBuilderException + * @throws CircularDependencyException + * @throws ElementNotFoundException + */ + public function loadPlugins(): Generator + { + foreach ($this->getSortedPlugins() as $pluginData) { + $pluginName = $pluginData['name']; + $config = $pluginData['config']; + $plugin = $this->getPluginRegistry()->getPluginByID($pluginName); + if ($this instanceof PluginValidator) { + $this->validatePlugin($pluginName, $plugin); + } else { + Schema::invariant( + $plugin, + 'Plugin %s not found', + $pluginName + ); + } + yield [$plugin, $config]; + } + } + + /** + * Sorts the before/after of plugins using topological sort + * + * @return array + * @throws CircularDependencyException + * @throws ElementNotFoundException + */ + protected function getSortedPlugins(): array + { + $dependencies = []; + $beforeAll = []; + $afterAll = []; + $allPlugins = $this->getPlugins(); + $allPluginNames = array_keys($allPlugins); + foreach ($allPlugins as $pluginName => $pluginConfig) { + $before = $pluginConfig['before'] ?? []; + if ($before === Schema::ALL) { + $beforeAll[] = $pluginName; + $allPluginNames = array_filter($allPluginNames, function ($name) use ($pluginName) { + return $name !== $pluginName; + }); + continue; + } + $before = !is_array($before) ? [$before] : $before; + $before = array_intersect($before, $allPluginNames); + + $after = $pluginConfig['after'] ?? []; + if ($after === Schema::ALL) { + $afterAll[] = $pluginName; + $allPluginNames = array_filter($allPluginNames, function ($name) use ($pluginName) { + return $name !== $pluginName; + }); + continue; + } + $after = !is_array($after) ? [$after] : $after; + $after = array_intersect($after, $allPluginNames); + + if (!isset($dependencies[$pluginName])) { + $dependencies[$pluginName] = []; + } + $dependencies[$pluginName] = array_merge($dependencies[$pluginName], $after); + + foreach ($before as $dependant) { + if (!isset($dependencies[$dependant])) { + $dependencies[$dependant] = []; + } + $dependencies[$dependant][] = $pluginName; + } + } + $sorter = new ArraySort($dependencies); + + $middle = $sorter->sort(); + + $sorted = array_merge( + $beforeAll, + $middle, + $afterAll + ); + $map = []; + foreach ($sorted as $pluginName) { + $map[] = [ + 'name' => $pluginName, + 'config' => $allPlugins[$pluginName] ?? [], + ]; + } + + return $map; + } + +} diff --git a/src/Schema/Registry/PluginRegistry.php b/src/Schema/Registry/PluginRegistry.php new file mode 100644 index 000000000..99a2edf5e --- /dev/null +++ b/src/Schema/Registry/PluginRegistry.php @@ -0,0 +1,55 @@ +plugins[$plugin->getIdentifier()] ?? null; + Schema::invariant( + !$existing || (get_class($existing) === get_class($plugin)), + 'Two different plugins are registered under identifier %s', + $plugin->getIdentifier() + ); + $this->plugins[$plugin->getIdentifier()] = $plugin; + } + } + + /** + * @param string $id + * @return PluginInterface|null + */ + public function getPluginByID(string $id): ?PluginInterface + { + return $this->plugins[$id] ?? null; + } +} diff --git a/src/Schema/Registry/ResolverRegistry.php b/src/Schema/Registry/ResolverRegistry.php new file mode 100644 index 000000000..e2d60e0a4 --- /dev/null +++ b/src/Schema/Registry/ResolverRegistry.php @@ -0,0 +1,100 @@ +addProviders($providers); + } + + /** + * @param string|null $typeName + * @param Field|null $field + * @return ResolverReference + */ + public function findResolver( + ?string $typeName = null, + ?Field $field = null + ): ResolverReference { + foreach ($this->resolverProviders as $provider) { + $resolver = $provider->getResolverMethod($typeName, $field); + if ($resolver) { + return ResolverReference::create([get_class($provider), $resolver]); + } + } + + $default = $field->getDefaultResolver(); + + return $default ?: ResolverReference::create( + $this->config()->get('default_resolver') + ); + } + + /** + * @param ResolverProvider[] $providers + * @return $this + */ + public function addProviders(array $providers): ResolverRegistry + { + $existing = array_map(function (ResolverProvider $provider) { + return get_class($provider); + }, $this->resolverProviders); + + foreach ($providers as $provider) { + if ($provider === false) { + continue; + } + if (!$provider instanceof ResolverProvider) { + throw new InvalidArgumentException(sprintf( + '%s only accepts implementations of %s', + __CLASS__, + ResolverProvider::class + )); + } + if (in_array(get_class($provider), $existing)) { + continue; + } + $this->resolverProviders[] = $provider; + } + + usort($this->resolverProviders, static function (ResolverProvider $a, ResolverProvider $b) { + return $b->getPriority() <=> $a->getPriority(); + }); + + return $this; + } + +} diff --git a/src/Schema/Registry/SchemaModelCreatorRegistry.php b/src/Schema/Registry/SchemaModelCreatorRegistry.php new file mode 100644 index 000000000..8762b7207 --- /dev/null +++ b/src/Schema/Registry/SchemaModelCreatorRegistry.php @@ -0,0 +1,121 @@ +addModelCreator($creator); + } + } + + /** + * @param SchemaModelCreatorInterface $creator + * @return $this + */ + public function addModelCreator(SchemaModelCreatorInterface $creator): self + { + $this->modelCreators[] = $creator; + + return $this; + } + + /** + * @param SchemaModelCreatorInterface $modelCreator + * @return $this + */ + public function removeModelCreator(SchemaModelCreatorInterface $modelCreator): self + { + $class = get_class($modelCreator); + $this->modelCreators = array_filter($this->modelCreators, function ($creator) use ($class) { + return !$creator instanceof $class; + }); + + return $this; + } + + /** + * @param string $class + * @return SchemaModelInterface|null + */ + public function getModel(string $class): ?SchemaModelInterface + { + $cached = static::$__cache[$class] ?? null; + if ($cached) { + return $cached; + } + foreach ($this->modelCreators as $creator) { + if ($creator->appliesTo($class)) { + $model = $creator->createModel($class); + if ($model && $model instanceof ModelConfigurationProvider) { + $id = $model::getIdentifier(); + $config = $this->configurations[$id] ?? []; + $model->applyModelConfig($config); + } + static::$__cache[$class] = $model; + + return $model; + } + } + + return null; + } + + /** + * @param array $config + * @return $this + */ + public function setConfigurations(array $config) + { + $configurations = []; + foreach ($config as $id => $data) { + $configurations[$id] = ModelConfiguration::create($data); + } + $this->configurations = $configurations; + + return $this; + } + + /** + * @param string $identifier + * @return ModelConfiguration|null + */ + public function getModelConfiguration(string $identifier): ?ModelConfiguration + { + return $this->configurations[$identifier] ?? null; + } +} diff --git a/src/Schema/Resolver/ComposedResolver.php b/src/Schema/Resolver/ComposedResolver.php new file mode 100644 index 000000000..8b2fb8f9a --- /dev/null +++ b/src/Schema/Resolver/ComposedResolver.php @@ -0,0 +1,45 @@ +fieldName; + $property = null; + + if (is_array($source) || $source instanceof ArrayAccess) { + if (isset($source[$fieldName])) { + $property = $source[$fieldName]; + } + } else if (is_object($source)) { + if (isset($source->{$fieldName})) { + $property = $source->{$fieldName}; + } + } + + return $property instanceof Closure ? $property($source, $args, $context, $info) : $property; + } + + public static function noop($obj) + { + return $obj; + } +} diff --git a/src/Schema/Resolver/DefaultResolverProvider.php b/src/Schema/Resolver/DefaultResolverProvider.php new file mode 100644 index 000000000..726a6469f --- /dev/null +++ b/src/Schema/Resolver/DefaultResolverProvider.php @@ -0,0 +1,78 @@ +get('priority'); + } + + /** + * @param string|null $typeName + * @param Field|null $field + * @return string|null + */ + public static function getResolverMethod(?string $typeName = null, ?Field $field = null): ?string + { + $fieldName = $field->getName(); + $candidates = array_filter([ + + // resolveHomePageContent() + $typeName && $fieldName ? + sprintf('resolve%s%s', ucfirst($typeName), ucfirst($fieldName)) : + null, + + // resolveHomePage() + $typeName ? sprintf('resolve%s', ucfirst($typeName)) : null, + + // resolveDataObjectContent() + /* @var ModelField $field */ + $field instanceof ModelField ? sprintf( + 'resolve%s%s', + ucfirst($field->getModel()->getIdentifier()), + ucfirst($fieldName) + ) : null, + + // resolveContent() + $fieldName ? sprintf('resolve%s', ucfirst($fieldName)) : null, + + // resolve() + 'resolve', + ]); + + foreach ($candidates as $method) { + $callable = [static::class, $method]; + $isCallable = is_callable($callable, false); + if ($isCallable) { + return $method; + } + } + + return null; + } +} diff --git a/src/Schema/Resolver/EncodedResolver.php b/src/Schema/Resolver/EncodedResolver.php new file mode 100644 index 000000000..7d3b52a8f --- /dev/null +++ b/src/Schema/Resolver/EncodedResolver.php @@ -0,0 +1,170 @@ +resolverRef = $resolver; + $this->context = $context; + } + + /** + * @return string + */ + public function encode(): string + { + return Encoder::create(__DIR__ . '/templates/resolver.inc.php', $this) + ->encode(); + } + + /** + * @return string + */ + public function getExpression(): string + { + $callable = sprintf( + "['%s', '%s']", + $this->resolverRef->getClass(), + $this->resolverRef->getMethod() + ); + if (empty($this->getContext())) { + return $callable; + } + + return sprintf( + 'call_user_func_array(%s, [%s])', + $callable, + $this->getContextArgs() + ); + } + + /** + * @return string|null + */ + public function getContextArgs(): ?string + { + return !empty($this->getContext()) ? var_export($this->getContext(), true) : null; + } + + /** + * @return array|null + */ + public function getContext(): ?array + { + return $this->context; + } + + /** + * @param array $context + * @return EncodedResolver + */ + public function setContext(array $context): self + { + $this->context = $context; + return $this; + } + + /** + * @param string $key + * @param $val + * @return EncodedResolver + * @throws SchemaBuilderException + */ + public function addContext(string $key, $val): self + { + Schema::invariant( + is_scalar($val) || is_array($val), + 'Resolver context must be a scalar value or an array on %s', + $this->resolverRef->toString() + ); + + $this->context[$key] = $val; + + return $this; + } + + /** + * @param EncodedResolver $ref + * @return EncodedResolver + */ + public function addMiddleware(EncodedResolver $ref): self + { + $this->middleware[] = $ref; + + return $this; + } + + /** + * @return array + */ + public function getResolverMiddlewares(): array + { + return $this->middleware; + } + + /** + * @param EncodedResolver $ref + * @return EncodedResolver + */ + public function addAfterware(EncodedResolver $ref): self + { + $this->afterware[] = $ref; + + return $this; + } + + /** + * @return array + */ + public function getResolverAfterwares(): array + { + return $this->afterware; + } + +} diff --git a/src/Schema/Resolver/ResolverReference.php b/src/Schema/Resolver/ResolverReference.php new file mode 100644 index 000000000..250b728a9 --- /dev/null +++ b/src/Schema/Resolver/ResolverReference.php @@ -0,0 +1,77 @@ +class = $class; + $this->method = $method; + } + + /** + * @return array + */ + public function toArray(): array + { + return [$this->class, $this->method]; + } + + /** + * @return string + */ + public function toString(): string + { + return sprintf('%s::%s', $this->class, $this->method); + } + + /** + * @return string + */ + public function getClass(): string + { + return $this->class; + } + + /** + * @return string + */ + public function getMethod(): string + { + return $this->method; + } +} diff --git a/src/Schema/Resolver/templates/_manifest_exclude b/src/Schema/Resolver/templates/_manifest_exclude new file mode 100644 index 000000000..e69de29bb diff --git a/src/Schema/Resolver/templates/resolver.inc.php b/src/Schema/Resolver/templates/resolver.inc.php new file mode 100644 index 000000000..f50f4fada --- /dev/null +++ b/src/Schema/Resolver/templates/resolver.inc.php @@ -0,0 +1,22 @@ + + +getResolverMiddlewares()) || !empty($resolver->getResolverAfterwares())): ?> + ComposedResolver::create( + getExpression(); ?>, + [ + getResolverMiddlewares() as $middleware): ?> + getExpression(); ?>, + + ], + [ + getResolverAfterwares() as $afterware): ?> + getExpression(); ?>, + + ] + ) + + getExpression(); ?> + diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php new file mode 100644 index 000000000..fa8ab1cc0 --- /dev/null +++ b/src/Schema/Schema.php @@ -0,0 +1,1052 @@ +setSchemaKey($schemaKey); + $store = Injector::inst()->get(SchemaStorageCreator::class) + ->createStore($schemaKey); + + $this->setStore($store); + } + + /** + * @param array $schemaConfig + * @return Schema + * @throws SchemaBuilderException + */ + public function applyConfig(array $schemaConfig): Schema + { + Benchmark::start('apply-config'); + $defaults = $schemaConfig[self::DEFAULTS] ?? []; + $types = $schemaConfig[self::TYPES] ?? []; + $queries = $schemaConfig[self::QUERIES] ?? []; + $mutations = $schemaConfig[self::MUTATIONS] ?? []; + $interfaces = $schemaConfig[self::INTERFACES] ?? []; + $unions = $schemaConfig[self::UNIONS] ?? []; + $models = $schemaConfig[self::MODELS] ?? []; + $enums = $schemaConfig[self::ENUMS] ?? []; + $scalars = $schemaConfig[self::SCALARS] ?? []; + $modelConfig = $schemaConfig[self::MODEL_CONFIG] ?? []; + + $this->defaults = $defaults; + + // Configure the models + SchemaModelCreatorRegistry::singleton() + ->setConfigurations($modelConfig); + + static::assertValidConfig($types); + foreach ($types as $typeName => $typeConfig) { + static::assertValidName($typeName); + $input = $typeConfig['input'] ?? false; + unset($typeConfig['input']); + $type = $input + ? InputType::create($typeName, $typeConfig) + : Type::create($typeName, $typeConfig); + $this->addType($type); + } + + static::assertValidConfig($queries); + foreach ($queries as $queryName => $queryConfig) { + $query = Query::create($queryName, $queryConfig); + $this->queryFields[$query->getName()] = $query; + } + + static::assertValidConfig($mutations); + foreach ($mutations as $mutationName => $mutationConfig) { + $mutation = Mutation::create($mutationName, $mutationConfig); + $this->mutationFields[$mutation->getName()] = $mutation; + } + + static::assertValidConfig($interfaces); + foreach ($interfaces as $interfaceName => $interfaceConfig) { + static::assertValidName($interfaceName); + $interface = InterfaceType::create($interfaceName, $interfaceConfig); + $this->addInterface($interface); + } + + static::assertValidConfig($unions); + foreach ($unions as $unionName => $unionConfig) { + static::assertValidName($unionName); + $union = UnionType::create($unionName, $unionConfig); + $this->addUnion($union); + } + + static::assertValidConfig($models); + foreach ($models as $modelName => $modelConfig) { + $model = ModelType::create($modelName, $modelConfig); + $this->addModel($model); + } + + static::assertValidConfig($enums); + foreach ($enums as $enumName => $enumConfig) { + Schema::assertValidConfig($enumConfig, ['values', 'description']); + $values = $enumConfig['values'] ?? null; + Schema::invariant($values, 'No values passed to enum %s', $enumName); + $description = $enumConfig['description'] ?? null; + $enum = Enum::create($enumName, $enumConfig['values'], $description); + $this->addEnum($enum); + } + + static::assertValidConfig($scalars); + foreach ($scalars as $scalarName => $scalarConfig) { + $scalar = Scalar::create($scalarName, $scalarConfig); + $this->addScalar($scalar); + } + + Benchmark::start('procedural-updates'); + $this->applyProceduralUpdates($schemaConfig['builders'] ?? []); + Benchmark::end('procedural-updates'); + + Benchmark::start('process-models'); + $this->processModels(); + Benchmark::end('process-models'); + + Benchmark::start('schema-updates'); + $this->applySchemaUpdates(); + echo Benchmark::end('schema-updates') . PHP_EOL; + foreach ($this->models as $modelType) { + $this->addType($modelType); + } + + $queryType = Type::create(self::QUERY_TYPE, [ + 'fields' => $this->queryFields, + ]); + $this->types[self::QUERY_TYPE] = $queryType; + + if (!empty($this->mutationFields)) { + $mutationType = Type::create(self::MUTATION_TYPE, [ + 'fields' => $this->mutationFields, + ]); + $this->types[self::MUTATION_TYPE] = $mutationType; + } + echo Benchmark::end('apply-config') . PHP_EOL; + return $this; + } + + /** + * @throws SchemaBuilderException + */ + private function processModels(): void + { + foreach ($this->getModels() as $modelType) { + // Apply default plugins + $model = $modelType->getModel(); + if ($model instanceof ModelConfigurationProvider) { + $plugins = $model->getModelConfig()->get('plugins', []); + $modelType->setDefaultPlugins($plugins); + } + $modelType->buildOperations(); + + foreach ($modelType->getOperations() as $operationName => $operationType) { + Schema::invariant( + $operationType instanceof ModelOperation, + 'Invalid operation defined on %s. Must implement %s', + $modelType->getName(), + ModelOperation::class + ); + + if ($operationType instanceof ModelQuery) { + $this->queryFields[$operationType->getName()] = $operationType; + } else { + if ($operationType instanceof ModelMutation) { + $this->mutationFields[$operationType->getName()] = $operationType; + } + } + } + } + + } + + /** + * @param array $builders + * @throws SchemaBuilderException + */ + private function applyProceduralUpdates(array $builders): void + { + foreach ($builders as $builderClass) { + static::invariant( + is_subclass_of($builderClass, SchemaUpdater::class), + 'The schema builder %s is not an instance of %s', + $builderClass, + SchemaUpdater::class + ); + $builderClass::updateSchema($this); + } + } + + /** + * @throws SchemaBuilderException + * @throws Exception + */ + private function applySchemaUpdates(): void + { + // Create a map of all the lists we need to apply plugins to, and their + // required plugin interface(s) + $allTypeFields = []; + $allModelFields = []; + foreach ($this->types as $type) { + $pluggedFields = array_filter($type->getFields(), function (Field $field) { + return !empty($field->getPlugins()); + }); + $allTypeFields = array_merge($allTypeFields, $pluggedFields); + } + foreach ($this->models as $model) { + $pluggedFields = array_filter($model->getFields(), function (Field $field) { + return !empty($field->getPlugins()); + }); + $allModelFields = array_merge($allModelFields, $pluggedFields); + } + + $allComponents = [ + 'types' => $this->types, + 'models' => $this->models, + 'queries' => $this->queryFields, + 'mutations' => $this->mutationFields, + 'fields' => $allTypeFields, + 'model fields' => $allModelFields, + ]; + $schemaUpdates = []; + foreach($allComponents as $components) { + /* @var SchemaComponent $component */ + foreach ($components as $component) { + foreach ($component->loadPlugins() as $data) { + list ($plugin) = $data; + if ($plugin instanceof SchemaUpdater) { + $schemaUpdates[get_class($plugin)] = get_class($plugin); + } + } + } + } + /* @var SchemaUpdater $builder */ + foreach ($schemaUpdates as $class) { + $class::updateSchema($this); + } + foreach ($allComponents as $name => $components) { + /* @var SchemaComponent $component */ + foreach ($components as $component) { + foreach ($component->loadPlugins() as $data) { + /* @var QueryPlugin|MutationPlugin|TypePlugin|ModelTypePlugin $plugin */ + list ($plugin, $config) = $data; + + // Duck programming here just because there is such an exhaustive list of possible + // interfaces, and they can't have a common ancestor until PHP 7.4 allows it. + // https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters + if (!method_exists($plugin, 'apply')) { + continue; + } + + try { + $plugin->apply($component, $this, $config); + } catch (SchemaBuilderException $e) { + throw new SchemaBuilderException(sprintf( + 'Failed to apply plugin %s to %s. Got error %s', + get_class($plugin), + $component->getName(), + $e->getMessage() + )); + } catch (TypeError $e) { + throw new SchemaBuilderException(sprintf( + 'Plugin %s does not apply to component "%s" (category: %s)', + $plugin->getIdentifier(), + $component->getName(), + $name + )); + } + } + } + } + } + + /** + * Builds the configuration graph from all the different sources + * + * @param bool $cached + * @return array + * @throws SchemaBuilderException + */ + public function getSchemaConfiguration($cached = true): array + { + if ($cached && $this->_cachedConfig) { + return $this->_cachedConfig; + } + $schemas = $this->config()->get('schemas'); + static::invariant($schemas, 'There are no schemas defined in the config'); + $schema = $schemas[$this->schemaKey] ?? null; + static::invariant($schema, 'Schema "%s" is not configured', $this->schemaKey); + + // Gather all the global config first + $mergedSchema = $schemas[self::ALL] ?? []; + + // Flushless global sources + $globalSrcs = $mergedSchema['src'] ?? []; + if (is_string($globalSrcs)) { + $globalSrcs = [Schema::ALL => $globalSrcs]; + } + + Schema::assertValidConfig($globalSrcs); + foreach ($globalSrcs as $configSrc => $data) { + if ($data === false) { + continue; + } + $sourcedConfig = $this->loadConfigFromSource($data); + $mergedSchema = array_replace_recursive($mergedSchema, $sourcedConfig); + } + + // Schema-specific flushless sources + $configSrcs = $schema['src'] ?? []; + if (is_string($configSrcs)) { + $configSrcs = [$this->schemaKey => $configSrcs]; + } + Schema::assertValidConfig($configSrcs); + foreach ($configSrcs as $configSrc => $data) { + if ($data === false) { + continue; + } + $sourcedConfig = $this->loadConfigFromSource($data); + $mergedSchema = array_replace_recursive($mergedSchema, $sourcedConfig); + } + + // Finally, apply the standard _config schema + $mergedSchema = array_replace_recursive($mergedSchema, $schema); + $this->_cachedConfig = $mergedSchema; + + return $this->_cachedConfig; + } + + /** + * @param bool $cache + * @return array + * @throws SchemaBuilderException + */ + public function getModelConfiguration($cache = true): array + { + $config = $this->getSchemaConfiguration($cache); + + return $config[self::MODEL_CONFIG] ?? []; + } + + /** + * @return Schema + * @throws SchemaBuilderException + */ + public function loadFromConfig(): Schema + { + $config = $this->getSchemaConfiguration(); + $this->applyConfig($config); + + return $this; + } + + /** + * @param string $dir + * @return array + * @throws SchemaBuilderException + */ + public function loadConfigFromSource(string $dir): array + { + $resolvedDir = ModuleResourceLoader::singleton()->resolvePath($dir); + $absConfigSrc = Path::join(BASE_PATH, $resolvedDir); + static::invariant( + is_dir($absConfigSrc), + 'Source config directory %s does not exist on schema %s', + $absConfigSrc, + $this->schemaKey + ); + + $config = [ + self::DEFAULTS => [], + self::MODEL_CONFIG => [], + self::TYPES => [], + self::MODELS => [], + self::QUERIES => [], + self::MUTATIONS => [], + self::ENUMS => [], + self::INTERFACES => [], + self::UNIONS => [], + self::SCALARS => [], + ]; + + $finder = new Finder(); + $yamlFiles = $finder->files()->in($absConfigSrc)->name('*.yml'); + + /* @var SplFileInfo $yamlFile */ + foreach ($yamlFiles as $yamlFile) { + try { + $contents = $yamlFile->getContents(); + // fail gracefully on empty files + if (empty($contents)) { + continue; + } + $yaml = Yaml::parseFile($yamlFile->getPathname()); + } catch (ParseException $e) { + throw new SchemaBuilderException(sprintf( + 'Could not parse YAML config for schema %s on file %s. Got error: %s', + $this->schemaKey, + $yamlFile->getPathname(), + $e->getMessage() + )); + } + // Friendly check to see if the config was accidentally keyed to a schema + Schema::invariant( + !isset($yaml[$this->schemaKey]), + 'Sourced config file %s does not need a schema key. It is implicitly "%s".', + $yamlFile->getPathname(), + $this->schemaKey + ); + // If the file is in the root src dir, e.g. _graphql/models.yml, + // then allow the filename to be the namespace. + if ($yamlFile->getPath() === $absConfigSrc) { + $namespace = $yamlFile->getBasename('.yml'); + } else { + // Otherwise, the directory name is the namespace, e.g _graphql/models/myfile.yml + $namespace = basename($yamlFile->getPath()); + } + + // if the yaml file was in a namespace directory, e.g. "models/" or "types/", the key is implied. + if (isset($config[$namespace])) { + $config[$namespace] = array_merge_recursive($config[$namespace], $yaml); + } else { + $config = array_merge_recursive($config, $yaml); + } + } + + return $config; + } + + /** + * @throws SchemaBuilderException + */ + public function save(): void + { + $this->validate(); + $this->getStore()->persistSchema($this); + } + + /** + * @return array + */ + public function mapTypeNames(): array + { + $typeMapping = []; + foreach ($this->getModels() as $modelType) { + $typeMapping[$modelType->getSourceClass()] = $modelType->getName(); + } + + return $typeMapping; + } + + /** + * @param string $class + * @return string|null + */ + public function getTypeNameForClass(string $class): ?string + { + $mapping = $this->getStore()->getTypeMapping(); + $typeName = $mapping[$class] ?? null; + + return $typeName; + } + + /** + * @return GraphQLSchema + */ + public function build(): GraphQLSchema + { + return $this->getStore()->getSchema(); + } + + /** + * @param string $key + * @return Schema + * @throws SchemaBuilderException + */ + public static function fetch(string $key): self + { + return static::create($key)->loadFromConfig(); + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + $allNames = array_merge( + array_keys($this->types), + array_keys($this->enums), + array_keys($this->interfaces), + array_keys($this->unions), + array_keys($this->scalars) + ); + $dupes = []; + foreach(array_count_values($allNames) as $val => $count) { + if ($count > 1) { + $dupes[] = $val; + } + } + + static::invariant( + empty($dupes), + 'Your schema has multiple types with the same name. See %s', + implode(', ', $dupes) + ); + + $validators = array_merge( + $this->types, + $this->queryFields, + $this->mutationFields, + $this->enums, + $this->interfaces, + $this->unions, + $this->scalars + ); + /* @var SchemaValidator $validator */ + foreach ($validators as $validator) { + $validator->validate(); + } + } + + /** + * @return string + */ + public function getSchemaKey(): string + { + return $this->schemaKey; + } + + /** + * @param string $key + * @return $this + */ + public function setSchemaKey(string $key): self + { + $this->schemaKey = $key; + + return $this; + } + + /** + * @param Type $type + * @param callable|null $callback + * @return Schema + * @throws SchemaBuilderException + */ + public function addType(Type $type, ?callable $callback = null): Schema + { + $existing = $this->types[$type->getName()] ?? null; + $typeObj = $existing ? $existing->mergeWith($type) : $type; + $this->types[$type->getName()] = $typeObj; + if ($callback) { + $callback($typeObj); + } + return $this; + } + + /** + * @param string $name + * @return Type|null + */ + public function getType(string $name): ?Type + { + return $this->types[$name] ?? null; + } + + /** + * @param string $name + * @return Type + * @throws SchemaBuilderException + */ + public function findOrMakeType(string $name): Type + { + $existing = $this->getType($name); + if ($existing) { + return $existing; + } + $this->addType(Type::create($name)); + + return $this->getType($name); + } + + /** + * @return Type[] + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param Enum $enum + * @return $this + */ + public function addEnum(Enum $enum): self + { + $this->enums[$enum->getName()] = $enum; + + return $this; + } + + /** + * @return Enum[] + */ + public function getEnums(): array + { + return $this->enums; + } + + /** + * @param $name + * @return Enum|null + */ + public function getEnum(string $name): ?Enum + { + return $this->enums[$name] ?? null; + } + + /** + * @return array + */ + public function getScalars(): array + { + return $this->scalars; + } + + /** + * @param string $name + * @return Scalar|null + */ + public function getScalar(string $name): ?Scalar + { + return $this->scalars[$name] ?? null; + } + + /** + * @param Scalar $scalar + * @return $this + */ + public function addScalar(Scalar $scalar): self + { + $this->scalars[$scalar->getName()] = $scalar; + + return $this; + } + + /** + * @param ModelType $modelType + * @param callable|null $callback + * @return Schema + * @throws SchemaBuilderException + */ + public function addModel(ModelType $modelType, ?callable $callback = null): Schema + { + $existing = $this->models[$modelType->getName()] ?? null; + $typeObj = $existing + ? $existing->mergeWith($modelType) + : $modelType; + $this->models[$modelType->getName()] = $typeObj; + foreach ($modelType->getExtraTypes() as $type) { + if ($type instanceof ModelType) { + $this->addModel($type); + } else { + $this->addType($type); + } + } + if ($callback) { + $callback($typeObj); + } + + return $this; + } + + /** + * @param string $name + * @return ModelType|null + */ + public function getModel(string $name): ?ModelType + { + return $this->models[$name] ?? null; + } + + /** + * @param string $class + * @return ModelType|null + */ + public function getModelByClassName(string $class): ?ModelType + { + foreach ($this->getModels() as $modelType) { + if ($modelType->getModel()->getSourceClass() === $class) { + return $modelType; + } + } + + return null; + } + + /** + * @return ModelType[] + */ + public function getModels(): array + { + return $this->models; + } + + /** + * @param string $class + * @return ModelType + * @throws SchemaBuilderException + */ + public function findOrMakeModel(string $class): ModelType + { + $newModel = ModelType::create($class); + $name = $newModel->getName(); + $existing = $this->getModel($name); + if ($existing) { + return $existing; + } + $this->addModel($newModel); + + return $this->getModel($name); + } + + /** + * @param InterfaceType $type + * @param callable|null $callback + * @return $this + * @throws SchemaBuilderException + */ + public function addInterface(InterfaceType $type, ?callable $callback = null): self + { + $existing = $this->interfaces[$type->getName()] ?? null; + $typeObj = $existing ? $existing->mergeWith($type) : $type; + $this->interfaces[$type->getName()] = $typeObj; + if ($callback) { + $callback($typeObj); + } + return $this; + + } + + /** + * @param string $name + * @return InterfaceType|null + */ + public function getInterface(string $name): ?InterfaceType + { + return $this->interfaces[$name] ?? null; + } + + /** + * @return InterfaceType[] + */ + public function getInterfaces(): array + { + return $this->interfaces; + } + + /** + * @param UnionType $union + * @param callable|null $callback + * @return $this + */ + public function addUnion(UnionType $union, ?callable $callback = null): self + { + $existing = $this->unions[$union->getName()] ?? null; + $typeObj = $existing ? $existing->mergeWith($union) : $union; + $this->unions[$union->getName()] = $typeObj; + if ($callback) { + $callback($typeObj); + } + return $this; + } + + /** + * @param string $name + * @return UnionType|null + */ + public function getUnion(string $name): ?UnionType + { + return $this->unions[$name] ?? null; + } + + /** + * @return array + */ + public function getUnions(): array + { + return $this->unions; + } + + /** + * @return array + */ + public static function getInternalTypes(): array + { + return ['String', 'Boolean', 'Int', 'Float', 'ID']; + } + + /** + * Pluralise a name + * + * @param string $typeName + * @return string + * @throws SchemaBuilderException + */ + public static function pluralise($typeName): string + { + $callable = static::config()->get('pluraliser'); + Schema::invariant( + is_callable($callable), + 'Schema does not have a valid callable "pluraliser" property set in its config' + ); + + return call_user_func_array($callable, [$typeName]); + } + + /** + * @param string $typeName + * @return string + */ + public static function pluraliser(string $typeName): string + { + // Ported from DataObject::plural_name() + if (preg_match('/[^aeiou]y$/i', $typeName)) { + $typeName = substr($typeName, 0, -1) . 'ie'; + } + $typeName .= 's'; + return $typeName; + } + + /** + * @param array $config + * @param array $allowedKeys + * @param array $requiredKeys + * @throws SchemaBuilderException + */ + public static function assertValidConfig(array $config, $allowedKeys = [], $requiredKeys = []): void + { + static::invariant( + empty($config) || ArrayLib::is_associative($config), + '%s configurations must be key value pairs of names to configurations. + Did you include an indexed array in your config?', + static::class + ); + + if (!empty($allowedKeys)) { + $invalidKeys = array_diff(array_keys($config), $allowedKeys); + static::invariant( + empty($invalidKeys), + 'Config contains invalid keys: %s', + implode(',', $invalidKeys) + ); + } + + if (!empty($requiredKeys)) { + $missingKeys = array_diff($requiredKeys, array_keys($config)); + static::invariant( + empty($missingKeys), + 'Config is missing required keys: %s', + implode(',', $missingKeys) + ); + } + } + + /** + * @param $name + * @throws SchemaBuilderException + */ + public static function assertValidName($name): void + { + static::invariant( + preg_match(' /[_A-Za-z][_0-9A-Za-z]*/', $name), + 'Invalid name: %s. Names must only use underscores and alphanumeric characters, and cannot + begin with a number.', + $name + ); + } + + /** + * @param $test + * @param string $message + * @param mixed ...$params + * @throws SchemaBuilderException + */ + public static function invariant($test, $message = '', ...$params): void + { + if (!$test) { + $message = sprintf($message, ...$params); + throw new SchemaBuilderException($message); + } + } + + + /** + * @return SchemaStorageInterface + */ + public function getStore(): SchemaStorageInterface + { + return $this->schemaStore; + } + + /** + * @param SchemaStorageInterface $store + * @return $this + */ + public function setStore(SchemaStorageInterface $store): self + { + $this->schemaStore = $store; + + return $this; + } + + /** + * Used for logging in tasks + * @param string $message + */ + public static function message(string $message): void + { + if (Director::is_cli()) { + fwrite(STDOUT, $message . PHP_EOL); + } else { + echo $message . "
"; + } + } + +} diff --git a/src/Schema/Storage/AbstractTypeRegistry.php b/src/Schema/Storage/AbstractTypeRegistry.php new file mode 100644 index 000000000..cfeceeb4b --- /dev/null +++ b/src/Schema/Storage/AbstractTypeRegistry.php @@ -0,0 +1,91 @@ +name = $name; + $this->setCache($cache); + } + + /** + * @param Schema $schema + * @throws Exception + * @throws InvalidArgumentException + * @throws RuntimeException + */ + public function persistSchema(Schema $schema): void + { + Benchmark::start('render'); + $fs = new Filesystem(); + $finder = new Finder(); + $temp = $this->getTempDirectory(); + $dest = $this->getDirectory(); + if ($fs->exists($dest)) { + Schema::message('Moving current schema to temp folder'); + $fs->copy($dest, $temp); + } else { + Schema::message('Creating new schema'); + try { + $fs->mkdir($temp); + // Ensure none of these files get loaded into the manifest + $fs->touch($temp . DIRECTORY_SEPARATOR . '_manifest_exclude'); + $warningFile = $temp . DIRECTORY_SEPARATOR . '__DO_NOT_MODIFY'; + $fs->dumpFile( + $warningFile, + '*** This directory contains generated code for the GraphQL schema. Do not modify. ***' + ); + } catch (IOException $e) { + throw new RuntimeException(sprintf( + 'Could not persist schema. Failed to create directory %s. Full message: %s', + $temp, + $e->getMessage() + )); + } + } + + $templateDir = static::getTemplateDir(); + $globals = [ + 'typeClassName' => self::TYPE_CLASS_NAME, + 'namespace' => $this->getNamespace(), + ]; + + $typeMapping = $schema->mapTypeNames(); + $typeMappingFile = $this->getTempTypeMappingFilename(); + + try { + $fs->dumpFile($typeMappingFile, + 'getMessage() + )); + } + + $allComponents = array_merge( + $schema->getTypes(), + $schema->getEnums(), + $schema->getInterfaces(), + $schema->getUnions(), + $schema->getScalars() + ); + $encoder = Encoder::create(Path::join($templateDir, 'registry.inc.php'), $allComponents, $globals); + $code = $encoder->encode(); + $schemaFile = $this->getTempSchemaFilename(); + try { + $fs->dumpFile($schemaFile, $this->toCode($code)); + } catch (IOException $e) { + throw new RuntimeException(sprintf( + 'Could not persist schema. Failed to write to file %s. Full message: %s', + $schemaFile, + $e->getMessage() + )); + } + + $fields = [ + 'Types' => 'type.inc.php', + 'Interfaces' => 'interface.inc.php', + 'Unions' => 'union.inc.php', + 'Enums' => 'enum.inc.php', + 'Scalars' => 'scalar.inc.php', + ]; + $touched = []; + $built = []; + $total = 0; + foreach ($fields as $field => $template) { + $method = 'get' . $field; + /* @var Type|InterfaceType|UnionType|Enum $type */ + foreach ($schema->$method() as $type) { + $total++; + $name = $type->getName(); + $sig = $type->getSignature(); + if ($this->getCache()->has($name)) { + $cached = $this->getCache()->get($name); + if ($sig === $cached) { + $touched[] = $name; + continue; + } + } + $file = Path::join($temp, $name . '.php'); + $encoder = Encoder::create(Path::join($templateDir, $template), $type, $globals); + $code = $encoder->encode(); + $fs->dumpFile($file, $this->toCode($code)); + $this->getCache()->set($name, $sig); + $touched[] = $name; + $built[] = $name; + } + } + $deleted = []; + // Reconcile the directory for deletions + $currentFiles = $finder + ->files() + ->in($temp) + ->name('*.php') + ->notName($this->config()->get('schemaFilename')) + ->notName($this->config()->get('typeMappingFilename')); + + /* @var SplFileInfo $file */ + foreach ($currentFiles as $file) { + $type = $file->getBasename('.php'); + if (!in_array($type, $touched)) { + $fs->remove($file->getPathname()); + $this->getCache()->delete($type); + $deleted[] = $type; + } + } + + // Move the new schema into the proper destination + if ($fs->exists($dest)) { + Schema::message('Deleting current schema'); + $fs->remove($dest); + } + + Schema::message('Migrating new schema'); + $fs->mirror($temp, $dest); + Schema::message('Deleting temp schema'); + $fs->remove($temp); + + Schema::message("Total types: $total"); + Schema::message(sprintf('Types built: %s', count($built))); + $snapshot = array_slice($built, 0, 10); + foreach ($snapshot as $type) { + Schema::message('*' . $type); + } + $diff = count($built) - count($snapshot); + if ($diff > 0) { + Schema::message(sprintf('(... and %s more)', $diff)); + } + + Schema::message(sprintf('Types deleted: %s', count($deleted))); + $snapshot = array_slice($deleted, 0, 10); + foreach ($snapshot as $type) { + Schema::message('*' . $type); + } + $diff = count($deleted) - count($snapshot); + if ($diff > 0) { + Schema::message(sprintf('(... and %s more)', $diff)); + } + + Schema::message(Benchmark::end('render', 'Generated code in %sms')); + } + + /** + * @return GraphQLSchema + */ + public function getSchema(): GraphQLSchema + { + require_once($this->getSchemaFilename()); + + $registryClass = $this->getClassName(self::TYPE_CLASS_NAME); + + $hasMutations = method_exists($registryClass, Schema::MUTATION_TYPE); + $schemaConfig = new SchemaConfig(); + $callback = call_user_func([$registryClass, Schema::QUERY_TYPE]); + $schemaConfig->setQuery($callback); + $schemaConfig->setTypeLoader([$registryClass, 'get']); + if ($hasMutations) { + $callback = call_user_func([$registryClass, Schema::MUTATION_TYPE]); + $schemaConfig->setMutation($callback); + } + return new GraphQLSchema($schemaConfig); + } + + /** + * @return array + */ + public function getTypeMapping(): array + { + if (file_exists($this->getTypeMappingFilename())) { + $mapping = require($this->getTypeMappingFilename()); + + return $mapping; + } + + return []; + } + + public function clear(): void + { + $fs = new Filesystem(); + $fs->remove($this->getDirectory()); + $this->getCache()->clear(); + } + + /** + * @return CacheInterface + */ + public function getCache(): CacheInterface + { + return $this->cache; + } + + /** + * @param CacheInterface $cache + * @return CodeGenerationStore + */ + public function setCache(CacheInterface $cache): CodeGenerationStore + { + $this->cache = $cache; + return $this; + } + + /** + * @return string + */ + public function getRootDir(): string + { + return $this->rootDir; + } + + /** + * @param string $rootDir + * @return CodeGenerationStore + */ + public function setRootDir(string $rootDir): CodeGenerationStore + { + $this->rootDir = $rootDir; + return $this; + } + + /** + * @return string + */ + public static function getTemplateDir(): string + { + return Path::join(__DIR__, 'templates'); + } + + /** + * @return string + */ + private function getDirectory(): string + { + return Path::join( + $this->getRootDir(), + $this->config()->get('dirName'), + $this->name + ); + } + + /** + * @return string + */ + private function getTempDirectory(): string + { + return Path::join( + TEMP_FOLDER, + $this->config()->get('dirName'), + $this->name + ); + } + + /** + * @return string + */ + private function getNamespace(): string + { + return $this->config()->get('namespacePrefix') . md5($this->name); + } + + /** + * @param string $className + * @return string + */ + private function getClassName(string $className): string + { + return $this->getNamespace() . '\\' . $className; + } + + /** + * @return string + */ + private function getSchemaFilename(): string + { + return Path::join( + $this->getDirectory(), + $this->config()->get('schemaFilename') + ); + } + + /** + * @return string + */ + private function getTypeMappingFilename(): string + { + return Path::join( + $this->getDirectory(), + $this->config()->get('typeMappingFilename') + ); + } + + /** + * @return string + */ + private function getTempSchemaFilename(): string + { + return Path::join( + $this->getTempDirectory(), + $this->config()->get('schemaFilename') + ); + } + + /** + * @return string + */ + private function getTempTypeMappingFilename(): string + { + return Path::join( + $this->getTempDirectory(), + $this->config()->get('typeMappingFilename') + ); + } + + /** + * @param string $rawCode + * @return string + */ + private function toCode(string $rawCode): string + { + $code = preg_replace("/(^[\r\n]*|[\r\n]+)[\s\t]*[\r\n]+/", "\n", $rawCode); + return "create(CacheFactory::class, [ + 'namespace' => $name + ]); + $cache = $factory->create(CacheInterface::class); + return CodeGenerationStore::create($name, $cache); + } +} diff --git a/src/Schema/Storage/Encoder.php b/src/Schema/Storage/Encoder.php new file mode 100644 index 000000000..4abdb6b14 --- /dev/null +++ b/src/Schema/Storage/Encoder.php @@ -0,0 +1,61 @@ +includeFile = $includeFile; + $this->scope = $scope; + $this->globals = $globals; + } + + public function encode(): string + { + ob_start(); + $scope = $this->scope; + $globals = $this->globals; + include($this->includeFile); + $content = ob_get_contents(); + ob_end_clean(); + + return $content; + } + +} diff --git a/src/Schema/Storage/templates/_manifest_exclude b/src/Schema/Storage/templates/_manifest_exclude new file mode 100644 index 000000000..e69de29bb diff --git a/src/Schema/Storage/templates/enum.inc.php b/src/Schema/Storage/templates/enum.inc.php new file mode 100644 index 000000000..6d8c82426 --- /dev/null +++ b/src/Schema/Storage/templates/enum.inc.php @@ -0,0 +1,32 @@ + + +namespace ; + +use GraphQL\Type\Definition\EnumType; + +class getName(); ?> extends EnumType +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName(); ?>', + 'values' => [ + getValueList() as $valueItem): ?> + '' => [ + 'value' => '', + + 'description' => '', + + ], + + ], + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + ]); + } +} diff --git a/src/Schema/Storage/templates/interface.inc.php b/src/Schema/Storage/templates/interface.inc.php new file mode 100644 index 000000000..008666cae --- /dev/null +++ b/src/Schema/Storage/templates/interface.inc.php @@ -0,0 +1,52 @@ + + +namespace ; + +use GraphQL\Type\Definition\InterfaceType; + +class getName(); ?> extends InterfaceType +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName(); ?>', + 'resolveType' => function ($obj) { + $type = call_user_func_array(getEncodedTypeResolver()->encode(); ?>, [$obj]); + return call_user_func([__NAMESPACE__ . '\\', $type]); + }, + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + 'fields' => function () { + return [ + getFields() as $field): ?> + [ + 'name' => 'getName(); ?>', + 'type' => getEncodedType()->encode() ?>, + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + getArgs())): ?> + 'args' => [ + getArgs() as $arg): ?> + [ + 'name' => 'getName(); ?>', + 'type' => getEncodedType()->encode(); ?>, + getDefaultValue() !== null): ?> + 'defaultValue' => getDefaultValue(), true); ?>, + + ], + + ], + + ], + + ]; + }, + ]); + } +} diff --git a/src/Schema/Storage/templates/registry.inc.php b/src/Schema/Storage/templates/registry.inc.php new file mode 100644 index 000000000..7be63d99b --- /dev/null +++ b/src/Schema/Storage/templates/registry.inc.php @@ -0,0 +1,26 @@ + + +namespace ; + +use SilverStripe\GraphQL\Schema\Storage\AbstractTypeRegistry; + +class extends AbstractTypeRegistry +{ + protected static function getSourceDirectory(): string + { + return __DIR__; + } + + protected static function getSourceNamespace(): string + { + return __NAMESPACE__; + } + + +public static function getName(); ?>() { return static::get('getName(); ?>'); } + + +} diff --git a/src/Schema/Storage/templates/scalar.inc.php b/src/Schema/Storage/templates/scalar.inc.php new file mode 100644 index 000000000..2e7593964 --- /dev/null +++ b/src/Schema/Storage/templates/scalar.inc.php @@ -0,0 +1,22 @@ + + +namespace ; + +use GraphQL\Type\Definition\CustomScalarType; + +class getName(); ?> extends CustomScalarType +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName() ?>', + 'serialize' => getEncodedSerialiser()->getExpression() ?>, + 'parseValue' => getEncodedValueParser()->getExpression() ?>, + 'parseLiteral' => getEncodedLiteralParser()->getExpression() ?>, + ]); + } +} diff --git a/src/Schema/Storage/templates/type.inc.php b/src/Schema/Storage/templates/type.inc.php new file mode 100644 index 000000000..51fea3267 --- /dev/null +++ b/src/Schema/Storage/templates/type.inc.php @@ -0,0 +1,59 @@ + + +namespace ; + +use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\InputObjectType; +use SilverStripe\GraphQL\Schema\Resolver\ComposedResolver; + + +class getName() ?> extends getIsInput()): ?>InputObjectTypeObjectType +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName() ?>', + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + getInterfaces())): ?> + 'interfaces' => function () { + return array_map(function ($interface) { + return call_user_func([__NAMESPACE__ . '\\', $interface]); + }, getEncodedInterfaces(); ?>); + }, + +'fields' => function () { + return [ + getFields() as $field): ?> + [ + 'name' => 'getName(); ?>', + 'type' => getEncodedType()->encode() ?>, + 'resolve' => getEncodedResolver($type->getName())->encode(); ?>, + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + getArgs())): ?> + 'args' => [ + getArgs() as $arg): ?> + [ + 'name' => 'getName(); ?>', + 'type' => getEncodedType()->encode(); ?>, + getDefaultValue() !== null): ?> + 'defaultValue' => getDefaultValue(), true); ?>, + + ], + + ], + + ], + + ]; + }, + ]); + } +} diff --git a/src/Schema/Storage/templates/union.inc.php b/src/Schema/Storage/templates/union.inc.php new file mode 100644 index 000000000..502bcf53d --- /dev/null +++ b/src/Schema/Storage/templates/union.inc.php @@ -0,0 +1,31 @@ + + +namespace ; + +use GraphQL\Type\Definition\UnionType; + +class getName() ?> extends UnionType +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName(); ?>', + 'types' => function () { + return array_map(function ($type) { + return call_user_func([__NAMESPACE__ . '\\', $type]); + }, getEncodedTypes(); ?>); + }, + 'resolveType' => function ($obj) { + $type = call_user_func_array(getEncodedTypeResolver()->encode(); ?>, [$obj]); + return call_user_func([__NAMESPACE__ . '\\', $type]); + }, + getDescription())): ?> + 'description' => 'getDescription()); ?>', + + ]); + } +} diff --git a/src/Schema/Type/EncodedType.php b/src/Schema/Type/EncodedType.php new file mode 100644 index 000000000..222d883a8 --- /dev/null +++ b/src/Schema/Type/EncodedType.php @@ -0,0 +1,65 @@ + 'listOf', + NodeKind::NON_NULL_TYPE => 'nonNull', + ]; + + /** + * EncodedType constructor. + * @param TypeReference $ref + */ + public function __construct(TypeReference $ref) + { + $this->ref = $ref; + } + + /** + * @return string + * @throws SchemaBuilderException + */ + public function encode(): string + { + list ($named, $path) = $this->ref->getTypeName(); + Schema::invariant($named, 'No named type was found on %s', $this->ref->getRawType()); + + $code = ''; + foreach ($path as $token) { + $func = static::$typeMap[$token] ?? null; + Schema::invariant($func, 'Node kind %s is invalid on %s', $token, $this->ref->getRawType()); + $code .= CodeGenerationStore::TYPE_CLASS_NAME . '::' . $func . '('; + } + $code .= CodeGenerationStore::TYPE_CLASS_NAME . '::' . $named . '()'; + $code .= str_repeat(')', count($path)); + + return $code; + } +} diff --git a/src/Schema/Type/Enum.php b/src/Schema/Type/Enum.php new file mode 100644 index 000000000..a05d01736 --- /dev/null +++ b/src/Schema/Type/Enum.php @@ -0,0 +1,151 @@ +setValues($values); + $this->setDescription($description); + } + + /** + * @return array + * @throws SchemaBuilderException + */ + public function getValueList(): array + { + $list = []; + foreach ($this->getValues() as $key => $val) { + $value = null; + $description = null; + if (is_array($val)) { + Schema::assertValidConfig($val, ['value', 'description']); + $value = $val['value']; + $description = $val['description'] ?? null; + } else { + $value = $val; + } + $list[] = [ + 'Key' => static::sanitise($key), + 'Value' => $value, + 'Description' => $description, + ]; + } + + return $list; + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + Schema::invariant( + !empty($this->getValueList()), + 'Enum type %s has no values defined', + $this->getName() + ); + } + + /** + * @return array + */ + public function getValues(): array + { + return $this->values; + } + + /** + * @param array $values + * @return Enum + */ + public function setValues(array $values): self + { + $this->values = $values; + return $this; + } + + /** + * @param $key + * @param null $val + * @return Enum + */ + public function addValue($key, $val = null): self + { + if ($val === null) { + $this->values[$key] = $key; + } else { + $this->values[$key] = $val; + } + + return $this; + } + + /** + * @param string $key + * @return Enum + */ + public function removeValue(string $key): self + { + unset($this->values[$key]); + + return $this; + } + + /** + * @return string + */ + public function getSignature(): string + { + $components = [ + $this->getName(), + $this->values, + $this->getDescription(), + ]; + + return md5(json_encode($components)); + } + + public static function sanitise(string $str): string + { + $str = preg_replace('/\s+/', '_', $str); + $str = preg_replace('/[^A-Za-z0-9_]/', '', $str); + + return $str; + } + +} diff --git a/src/Schema/Type/InputType.php b/src/Schema/Type/InputType.php new file mode 100644 index 000000000..c77269bca --- /dev/null +++ b/src/Schema/Type/InputType.php @@ -0,0 +1,15 @@ +setTypeResolver($config['typeResolver']); + } + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + $fields = $config['fields'] ?? []; + $this->setFields($fields); + } + + /** + * @return EncodedResolver + */ + public function getEncodedTypeResolver(): EncodedResolver + { + return EncodedResolver::create($this->typeResolver); + } + + /** + * @param array|string|ResolverReference|null $resolver + * @return $this + */ + public function setTypeResolver($resolver): self + { + if ($resolver) { + $this->typeResolver = $resolver instanceof ResolverReference + ? $resolver + : ResolverReference::create($resolver); + } else { + $this->typeResolver = null; + } + + return $this; + } + + public function validate(): void + { + Schema::invariant( + !empty($this->getFields()), + 'Interface %s has no fields', + $this->getName() + ); + + Schema::invariant( + $this->typeResolver, + 'Interface %s has no type resolver', + $this->getName() + ); + } + + /** + * @return string + * @throws Exception + */ + public function getSignature(): string + { + $fields = $this->getFields(); + usort($fields, function (Field $a, Field $z) { + return $a->getName() <=> $z->getName(); + }); + + $components = [ + $this->getName(), + $this->typeResolver->toString(), + $this->getDescription(), + $this->getSortedPlugins(), + array_map(function (Field $field) { + return $field->getSignature(); + }, $fields), + ]; + + return md5(json_encode($components)); + } +} diff --git a/src/Schema/Type/ModelType.php b/src/Schema/Type/ModelType.php new file mode 100644 index 000000000..2b04d8288 --- /dev/null +++ b/src/Schema/Type/ModelType.php @@ -0,0 +1,478 @@ +get(SchemaModelCreatorRegistry::class); + $model = $registry->getModel($class); + Schema::invariant($model, 'No model found for class %s', $class); + + $this->setModel($model); + $this->setSourceClass($class); + + $type = $this->getModel()->getTypeName(); + Schema::invariant( + $type, + 'Could not determine type for model %s', + $this->getSourceClass() + ); + + /* @var SchemaModelInterface&ModelBlacklist $model */ + $this->blacklistedFields = $model instanceof ModelBlacklist ? + array_map('strtolower', $model->getBlacklistedFields()) : + []; + + parent::__construct($type); + + $this->applyConfig($config); + } + + /** + * @param array $config + * @throws SchemaBuilderException + */ + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, ['fields', 'operations', 'plugins']); + + $fieldConfig = $config['fields'] ?? []; + if ($fieldConfig === Schema::ALL) { + $this->addAllFields(); + } else { + $fields = array_merge($this->getBaseFields(), $fieldConfig); + Schema::assertValidConfig($fields); + + foreach ($fields as $fieldName => $data) { + if ($data === false) { + unset($this->fields[$fieldName]); + continue; + } + // Allow * as a field, so you can override a subset of fields + if ($fieldName === Schema::ALL) { + $this->addAllFields(); + } else { + $this->addField($fieldName, $data); + } + } + } + + $operations = $config['operations'] ?? null; + if ($operations) { + if ($operations === Schema::ALL) { + $this->addAllOperations(); + } else { + $this->applyOperationsConfig($operations); + } + } + + if (isset($config['plugins'])) { + $this->setPlugins($config['plugins']); + } + } + + /** + * @param string $fieldName + * @param array|string|Field|boolean $fieldConfig + * @param callable|null $callback + * @return Type + * @throws SchemaBuilderException + */ + public function addField(string $fieldName, $fieldConfig = true, ?callable $callback = null): Type + { + $fieldObj = null; + if ($fieldConfig instanceof ModelField) { + $fieldObj = $fieldConfig; + } else { + $field = ModelField::create($fieldName, $fieldConfig, $this->getModel()); + $fieldObj = $this->getModel()->getField($field->getPropertyName()); + if ($fieldObj) { + $fieldObj->setName($field->getName()); + if (is_array($fieldConfig)) { + $fieldObj->applyConfig($fieldConfig); + } else if (is_string($fieldConfig)) { + $fieldObj->setType($fieldConfig); + } + } else { + $fieldObj = ModelField::create($fieldName, $fieldConfig, $this->getModel()); + } + } + Schema::invariant( + $fieldObj, + 'Could not get field "%s" on "%s"', + $fieldName, + $this->getName() + ); + + Schema::invariant( + !in_array(strtolower($fieldObj->getName()), $this->blacklistedFields), + 'Field %s is not allowed on %s', + $fieldObj->getName(), + $this->getModel()->getSourceClass() + ); + + $this->fields[$fieldObj->getName()] = $fieldObj; + if ($callback) { + call_user_func_array($callback, [$fieldObj]); + } + return $this; + } + + /** + * @param array $fields + * @return $this + * @throws SchemaBuilderException + */ + public function addFields(array $fields): self + { + if (ArrayLib::is_associative($fields)) { + foreach ($fields as $fieldName => $config) { + $this->addField($fieldName, $config); + } + } else { + foreach ($fields as $fieldName) { + $this->addField($fieldName, true); + } + } + + return $this; + } + + /** + * @return ModelType + * @throws SchemaBuilderException + */ + public function addAllFields(): self + { + /* @var SchemaModelInterface&DefaultFieldsProvider $model */ + $model = $this->getModel(); + $defaultFields = $model instanceof DefaultFieldsProvider ? $model->getDefaultFields() : []; + foreach ($defaultFields as $fieldName => $fieldType) { + $this->addField($fieldName, $fieldType); + } + $allFields = $this->getModel()->getAllFields(); + foreach ($allFields as $fieldName) { + $this->addField($fieldName, $this->getModel()->getField($fieldName)); + } + return $this; + } + + /** + * @return ModelType + * @throws SchemaBuilderException + */ + public function addAllOperations(): self + { + Schema::invariant( + $this->getModel() instanceof OperationProvider, + 'Model for %s does not implement %s. No operations are allowed', + $this->getName(), + OperationProvider::class + ); + /* @var SchemaModelInterface&OperationProvider $model */ + $model = $this->getModel(); + + $operations = []; + foreach ($model->getAllOperationIdentifiers() as $id) { + $operations[$id] = true; + } + $this->applyOperationsConfig($operations); + + return $this; + } + + + /** + * @param array $operations + * @return ModelType + * @throws SchemaBuilderException + */ + public function applyOperationsConfig(array $operations): ModelType + { + Schema::assertValidConfig($operations); + foreach ($operations as $operationName => $data) { + if ($data === false) { + unset($this->operationCreators[$operationName]); + continue; + } + // Allow * as an operation so individual operations can be overridden + if ($operationName === Schema::ALL) { + $this->addAllOperations(); + continue; + } + Schema::invariant( + is_array($data) || $data === true, + 'Operation data for %s must be a map of config or true for a generic implementation', + $operationName + ); + + $config = ($data === true) ? [] : $data; + $this->addOperation($operationName, $config); + } + + return $this; + } + + /** + * @param string $fieldName + * @return Field|null + */ + public function getFieldByName(string $fieldName): ?Field + { + /* @var ModelField $fieldObj */ + foreach ($this->getFields() as $fieldObj) { + if ($fieldObj->getName() === $fieldName) { + return $fieldObj; + } + } + return null; + } + + /** + * @param Type $type + * @return Type + * @throws SchemaBuilderException + */ + public function mergeWith(Type $type): Type + { + if ($type instanceof ModelType) { + foreach ($type->getOperationCreators() as $name => $config) { + $this->addOperation($name, $config); + } + } + + return parent::mergeWith($type); + } + + /** + * @param string $operationName + * @param array $config + * @return ModelType + */ + public function addOperation(string $operationName, array $config = []): self + { + $this->operationCreators[$operationName] = $config; + + return $this; + } + + /** + * @param string $operationName + * @return ModelType + */ + public function removeOperation(string $operationName): self + { + unset($this->operationCreators[$operationName]); + + return $this; + } + + /** + * @param string $operationName + * @param array $config + * @return ModelType + * @throws SchemaBuilderException + */ + public function updateOperation(string $operationName, array $config = []): self + { + Schema::invariant( + isset($this->operationCreators[$operationName]), + 'Cannot update nonexistent operation %s on %s', + $operationName, + $this->getName() + ); + + $this->operationCreators[$operationName] = array_merge( + $this->operationCreators[$operationName], + $config + ); + + return $this; + } + + /** + * @throws SchemaBuilderException + */ + public function buildOperations(): void + { + $operations = []; + foreach ($this->operationCreators as $operationName => $config) { + $operationCreator = $this->getOperationCreator($operationName); + $operation = $operationCreator->createOperation( + $this->getModel(), + $this->getName(), + $config + ); + if ($operation) { + if ($operation instanceof Field && $this->getModel() instanceof ModelConfigurationProvider) { + $operationsConfig = $this->getModel()->getModelConfig()->getOperationConfig($operationName); + $defaultPlugins = $operationsConfig['plugins'] ?? []; + $operation->setDefaultPlugins($defaultPlugins); + } + $operations[$operationName] = $operation; + } + } + + $this->operations = $operations; + } + + /** + * @return array + */ + public function getOperations(): array + { + return $this->operations; + } + + /** + * @return array + */ + public function getOperationCreators(): array + { + return $this->operationCreators; + } + + /** + * @return Type[] + * @throws SchemaBuilderException + */ + public function getExtraTypes(): array + { + $extraTypes = $this->extraTypes; + foreach ($this->operationCreators as $operationName => $config) { + $operationCreator = $this->getOperationCreator($operationName); + if (!$operationCreator instanceof InputTypeProvider) { + continue; + } + $types = $operationCreator->provideInputTypes( + $this->getModel(), + $this->getName(), + $config + ); + foreach ($types as $type) { + Schema::invariant( + $type instanceof InputType, + 'Input types must be instances of %s on %s', + InputType::class, + $this->getName() + ); + $extraTypes[] = $type; + } + } + foreach ($this->getFields() as $field) { + if (!$field instanceof ModelField) { + continue; + } + if ($modelType = $field->getModelType()) { + $extraTypes = array_merge($extraTypes, $modelType->getExtraTypes()); + $extraTypes[] = $modelType; + } + } + if ($this->getModel() instanceof ExtraTypeProvider) { + $extraTypes = array_merge($extraTypes, $this->getModel()->getExtraTypes()); + } + + return $extraTypes; + } + + /** + * @return string + */ + public function getSourceClass(): string + { + return $this->sourceClass; + } + + /** + * @param string $sourceClass + * @return ModelType + */ + public function setSourceClass(string $sourceClass): ModelType + { + $this->sourceClass = $sourceClass; + return $this; + } + + /** + * @return array + */ + private function getBaseFields(): array + { + $model = $this->getModel(); + /* @var SchemaModelInterface&DefaultFieldsProvider $model */ + return $model instanceof DefaultFieldsProvider ? $model->getDefaultFields() : []; + } + + /** + * @param string $operationName + * @return OperationCreator + * @throws SchemaBuilderException + */ + private function getOperationCreator(string $operationName): OperationCreator + { + $operationCreator = $this->getModel()->getOperationCreatorByIdentifier($operationName); + Schema::invariant($operationCreator, 'Invalid operation: %s', $operationName); + + return $operationCreator; + } +} diff --git a/src/Schema/Type/Scalar.php b/src/Schema/Type/Scalar.php new file mode 100644 index 000000000..8c9c7cf40 --- /dev/null +++ b/src/Schema/Type/Scalar.php @@ -0,0 +1,202 @@ +setName($fieldName); + $this->applyConfig($config); + } + + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, [ + 'name', + 'serialiser', + 'valueParser', + 'literalParser', + ]); + + if (isset($config['name'])) { + $this->setName($config['name']); + } + + if (isset($config['serialiser'])) { + $this->setSerialiser(ResolverReference::create($config['serialiser'])); + } + + if (isset($config['valueParser'])) { + $this->setValueParser(ResolverReference::create($config['valueParser'])); + } + + if (isset($config['literalParser'])) { + $this->setLiteralParser(ResolverReference::create($config['literalParser'])); + } + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string $name + * @return Scalar + * @throws SchemaBuilderException + */ + public function setName(string $name): self + { + Schema::assertValidName($name); + $this->name = $name; + return $this; + } + + /** + * @return ResolverReference + */ + public function getSerialiser(): ResolverReference + { + return $this->serialiser; + } + + /** + * @return EncodedResolver + */ + public function getEncodedSerialiser(): EncodedResolver + { + return EncodedResolver::create($this->getSerialiser()); + } + + /** + * @param ResolverReference $serialiser + * @return Scalar + */ + public function setSerialiser(ResolverReference $serialiser): Scalar + { + $this->serialiser = $serialiser; + return $this; + } + + /** + * @return ResolverReference + */ + public function getValueParser(): ResolverReference + { + return $this->valueParser; + } + + /** + * @return EncodedResolver + */ + public function getEncodedValueParser(): EncodedResolver + { + return EncodedResolver::create($this->getValueParser()); + } + + /** + * @param ResolverReference $valueParser + * @return Scalar + */ + public function setValueParser(ResolverReference $valueParser): Scalar + { + $this->valueParser = $valueParser; + return $this; + } + + /** + * @return ResolverReference + */ + public function getLiteralParser(): ResolverReference + { + return $this->literalParser; + } + + /** + * @return EncodedResolver + */ + public function getEncodedLiteralParser(): EncodedResolver + { + return EncodedResolver::create($this->getLiteralParser()); + } + + /** + * @param ResolverReference $literalParser + * @return Scalar + */ + public function setLiteralParser(ResolverReference $literalParser): Scalar + { + $this->literalParser = $literalParser; + return $this; + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + Schema::invariant( + $this->getSerialiser() && $this->getLiteralParser() && $this->getValueParser(), + 'Scalar type %s must have serialiser, literalParser, and valueParser functions defined' + ); + } + + /** + * @return string + */ + public function getSignature(): string + { + return md5(json_encode([ + $this->getName(), + $this->getSerialiser()->toArray(), + $this->getLiteralParser()->toArray(), + $this->getValueParser()->toArray(), + ])); + } +} diff --git a/src/Schema/Type/Type.php b/src/Schema/Type/Type.php new file mode 100644 index 000000000..217a347b7 --- /dev/null +++ b/src/Schema/Type/Type.php @@ -0,0 +1,369 @@ +setName($name); + if ($config) { + $this->applyConfig($config); + } + } + + /** + * @param array $config + * @throws SchemaBuilderException + */ + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, [ + 'fields', + 'description', + 'interfaces', + 'isInput', + 'fieldResolver', + 'plugins', + ]); + if (isset($config['fieldResolver'])) { + $this->setFieldResolver($config['fieldResolver']); + } + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + if (isset($config['interfaces'])) { + $this->setInterfaces($config['interfaces']); + } + if (isset($config['isInput'])) { + $this->setIsInput($config['isInput']); + } + if (isset($config['plugins'])) { + $this->setPlugins($config['plugins']); + } + + $fields = $config['fields'] ?? []; + $this->setFields($fields); + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string $name + * @return Type + */ + public function setName(string $name): self + { + $this->name = ucfirst($name); + return $this; + } + + /** + * @return Field[] + */ + public function getFields(): array + { + return $this->fields; + } + + /** + * @param array $fields + * @return Type + * @throws SchemaBuilderException + */ + public function setFields(array $fields): self + { + Schema::assertValidConfig($fields); + foreach ($fields as $fieldName => $fieldConfig) { + if ($fieldConfig === false) { + continue; + } + $this->addField($fieldName, $fieldConfig); + } + + return $this; + } + + /** + * @param string $fieldName + * @param string|array|Field $fieldConfig + * @param callable|null $callback + * @return Type + */ + public function addField(string $fieldName, $fieldConfig, ?callable $callback = null): self + { + if (!$fieldConfig instanceof Field) { + $config = is_string($fieldConfig) ? ['type' => $fieldConfig] : $fieldConfig; + $fieldObj = Field::create($fieldName, $config); + } else { + $fieldObj = $fieldConfig; + } + + $defaultResolver = $this->getFieldResolver(); + if ($defaultResolver) { + $fieldObj->setDefaultResolver($defaultResolver); + } + $this->fields[$fieldObj->getName()] = $fieldObj; + if ($callback) { + call_user_func_array($callback, [$fieldObj]); + } + return $this; + } + + /** + * @param string $field + * @return Type + */ + public function removeField(string $field): self + { + unset($this->fields[$field]); + + return $this; + } + + /** + * @param string $fieldName + * @return Field|null + */ + public function getFieldByName(string $fieldName): ?Field + { + return $this->fields[$fieldName] ?? null; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param Type $type + * @return Type + * @throws SchemaBuilderException + */ + public function mergeWith(Type $type): self + { + Schema::invariant( + $type->getIsInput() === $this->getIsInput(), + 'Cannot merge an input type %s with an object type %s', + $type->getName(), + $this->getName() + ); + foreach ($type->getFields() as $field) { + $existing = $this->fields[$field->getName()] ?? null; + if (!$existing) { + $this->fields[$field->getName()] = $field; + } else { + $this->fields[$field->getName()] = $existing->mergeWith($field); + } + } + + $this->mergePlugins($type->getPlugins()); + + $this->setInterfaces(array_merge($this->interfaces, $type->getInterfaces())); + + return $this; + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + + { + Schema::invariant( + !empty($this->getFields()), + 'Fields cannot be empty for type %s', $this->getName() + ); + foreach ($this->getFields() as $field) { + $field->validate(); + } + } + /** + * @param mixed $description + * @return Type + */ + public function setDescription($description) + { + $this->description = $description; + return $this; + } + + /** + * @return array + */ + public function getInterfaces(): array + { + return $this->interfaces; + } + + /** + * @return string + */ + public function getEncodedInterfaces(): string + { + return var_export($this->interfaces, true); + } + + /** + * @param array $interfaces + * @return Type + */ + public function setInterfaces(array $interfaces): self + { + $this->interfaces = $interfaces; + return $this; + } + + /** + * @param string $name + * @return $this + */ + public function addInterface(string $name): self + { + if (!in_array($name, $this->interfaces)) { + $this->interfaces[] = $name; + } + + return $this; + } + + /** + * @return bool + */ + public function getIsInput(): bool + { + return $this->isInput; + } + + /** + * @param bool $isInput + * @return Type + */ + public function setIsInput(bool $isInput): self + { + $this->isInput = $isInput; + return $this; + } + + /** + * @return ResolverReference|null + */ + public function getFieldResolver(): ?ResolverReference + { + return $this->fieldResolver; + } + + /** + * @param array|string|ResolverReference|null $fieldResolver + * @return Type + */ + public function setFieldResolver($fieldResolver): self + { + if ($fieldResolver) { + $this->fieldResolver = $fieldResolver instanceof ResolverReference + ? $fieldResolver + : ResolverReference::create($fieldResolver); + } else { + $this->fieldResolver = null; + } + return $this; + } + + /** + * A deterministic representation of everything that gets encoded into the template. + * Used as a cache key. This method will need to be updated if new data is added + * to the generated code. + * @return string + * @throws Exception + */ + public function getSignature(): string + { + $interfaces = $this->getInterfaces(); + sort($interfaces); + $fields = $this->getFields(); + usort($fields, function (Field $a, Field $z) { + return $a->getName() <=> $z->getName(); + }); + $components = [ + $this->getName(), + (int) $this->getIsInput(), + $this->getDescription(), + $this->getSortedPlugins(), + $interfaces, + array_map(function (Field $field) { + // The field resolver can change depending on what type it's on, so + // we need to augment the Field signature here to be type specific. + return $field->getSignature() . $field->getEncodedResolver($this->getName())->getExpression(); + }, $fields), + ]; + + return md5(json_encode($components)); + } +} diff --git a/src/Schema/Type/TypeReference.php b/src/Schema/Type/TypeReference.php new file mode 100644 index 000000000..a0aebb25a --- /dev/null +++ b/src/Schema/Type/TypeReference.php @@ -0,0 +1,120 @@ +defaultValue = trim($defaultValue); + $this->typeStr = trim($type); + } else { + $this->typeStr = $typeStr; + } + } + + /** + * @return Node + */ + public function toAST(): Node + { + return Parser::parseType($this->typeStr, ['noLocation' => true]); + } + + /** + * @return mixed + */ + public function getDefaultValue() + { + return $this->defaultValue; + } + + /** + * @return bool + */ + public function isList(): bool + { + return $this->hasWrapper(NodeKind::LIST_TYPE); + } + + /** + * @return bool + */ + public function isRequired(): bool + { + return $this->hasWrapper(NodeKind::NON_NULL_TYPE); + } + + /** + * @param string $nodeKind + * @return bool + */ + private function hasWrapper(string $nodeKind): bool + { + list ($named, $path) = $this->getTypeName(); + + if (empty($path)) { + return false; + } + + return $path[0] === $nodeKind; + } + + /** + * @return array + */ + public function getTypeName(): array + { + $node = $this->toAST(); + $path = []; + while($node && !$node instanceof NamedTypeNode) { + $path[] = $node->kind; + $node = $node->type; + } + + $named = $node ? $node->name->value : null; + + return [$named, $path]; + } + + /** + * @return string + */ + public function getNamedType(): string + { + return $this->getTypeName()[0]; + } + + /** + * @return string + */ + public function getRawType(): string + { + return $this->typeStr; + } + +} diff --git a/src/Schema/Type/UnionType.php b/src/Schema/Type/UnionType.php new file mode 100644 index 000000000..27806d29e --- /dev/null +++ b/src/Schema/Type/UnionType.php @@ -0,0 +1,212 @@ +setName($name); + if ($config) { + $this->applyConfig($config); + } + } + + /** + * @param array $config + * @throws SchemaBuilderException + */ + public function applyConfig(array $config) + { + Schema::assertValidConfig($config, ['typeResolver', 'types', 'description']); + if (isset($config['typeResolver'])) { + $this->setTypeResolver($config['typeResolver']); + } + if (isset($config['types'])) { + $this->setTypes($config['types']); + } + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + } + + /** + * @return mixed|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @return UnionType + * @throws SchemaBuilderException + */ + public function setName(string $name) + { + Schema::assertValidName($name); + $this->name = $name; + return $this; + } + + /** + * @return array + */ + public function getTypes(): array + { + return $this->types; + } + + /** + * @param array $types + * @return UnionType + */ + public function setTypes(array $types): UnionType + { + $this->types = $types; + return $this; + } + + /** + * @return string + */ + public function getEncodedTypes(): string + { + return var_export($this->types, true); + } + + /** + * @return mixed + */ + public function getTypeResolver() + { + return $this->typeResolver; + } + + /** + * @param array|string|ResolverReference|null $resolver + * @return $this + */ + public function setTypeResolver($resolver): self + { + if ($resolver) { + $this->typeResolver = $resolver instanceof ResolverReference + ? $resolver + : ResolverReference::create($resolver); + } else { + $this->typeResolver = null; + } + + return $this; + } + + /** + * @return EncodedResolver + */ + public function getEncodedTypeResolver(): EncodedResolver + { + return EncodedResolver::create($this->typeResolver); + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + * @return UnionType + */ + public function setDescription(?string $description): UnionType + { + $this->description = $description; + return $this; + } + + /** + * @throws SchemaBuilderException + */ + public function validate(): void + { + Schema::invariant( + $this->typeResolver, + 'Union %s has no type resolver', + $this->getName() + ); + + Schema::invariant( + count($this->types), + 'Union %s has no types', + $this->getName() + ); + } + + /** + * @return string + */ + public function getSignature(): string + { + $types = $this->getTypes(); + sort($types); + $components = [ + $this->getName(), + $types, + $this->typeResolver->toString(), + $this->getDescription(), + ]; + + return md5(json_encode($components)); + } +} diff --git a/src/TypeCreator.php b/src/TypeCreator.php deleted file mode 100644 index c5b121b81..000000000 --- a/src/TypeCreator.php +++ /dev/null @@ -1,195 +0,0 @@ -manager = $manager; - } - - /** - * Returns any fixed attributes for this type. E.g. 'name' or 'description' - * - * @return array - */ - public function attributes() - { - return []; - } - - /** - * Returns the internal field structures, without field resolution. - * - * @return array A map of field names to type instances in the GraphQL\Type\Definition namespace - */ - public function fields() - { - return []; - } - - /** - * Returns the list of interfaces (or function to evaluate this list) - * which this type implements. - * - * @return array|callable - */ - public function interfaces() - { - return []; - } - - /** - * Returns field structure with field resolvers added. - * Note that to declare a field resolver for a particular field, - * create a resolveField() method to your subclass. - * - * @return array - */ - public function getFields() - { - $fields = $this->fields(); - $allFields = []; - - foreach ($fields as $name => $field) { - $resolver = $this->getFieldResolver($name, $field); - if ($resolver) { - $field['resolve'] = $resolver; - } - $allFields[$name] = $field; - } - - return $allFields; - } - - /** - * True if this is an input object, which accepts new field values. - * - * @return bool - */ - public function isInputObject() - { - return $this->inputObject; - } - - /** - * Build the constructed type backing this object. - * - * @return Type - */ - public function toType() - { - if ($this->isInputObject()) { - return new InputObjectType($this->toArray()); - } - - return new ObjectType($this->toArray()); - } - - /** - * Convert this silverstripe graphql type into an array format accepted by the - * type constructor. - * - * @see InterfaceType::__construct - * @see ObjectType::__construct - * - * @return array - */ - public function toArray() - { - return $this->getAttributes(); - } - - /** - * Gets the list of all computed attributes for this type. - * - * @return array - */ - public function getAttributes() - { - $interfaces = $this->interfaces(); - - $attributes = array_merge( - $this->attributes(), - [ - 'fields' => function () { - return $this->getFields(); - }, - ] - ); - - if (!empty($interfaces)) { - $attributes['interfaces'] = $interfaces; - } - - return $attributes; - } - - /** - * Locate potential callback for resolving this field at runtime. - * E.g. A callback for retrieving the list of child files for a folder - * Will automatically inspect itself for methods named either resolveField(), - * or resolveField(). - * - * @param string $name Name of the field - * @param array $field Field array specification - * @return callable|null The callback, or null if there is no field resolver - */ - protected function getFieldResolver($name, $field) - { - // Preconfigured method - if (isset($field['resolve'])) { - return $field['resolve']; - } - $candidateMethods = [ - 'resolve'.ucfirst($name).'Field', - 'resolveField', - ]; - foreach ($candidateMethods as $resolveMethod) { - if (!method_exists($this, $resolveMethod)) { - continue; - } - - // Method for a particular field - $resolver = array($this, $resolveMethod); - return function () use ($resolver) { - $args = func_get_args(); - // See 'resolveType' on https://github.com/webonyx/graphql-php - return call_user_func_array($resolver, $args); - }; - } - - return null; - } -} diff --git a/src/Util/CaseInsensitiveFieldAccessor.php b/src/Util/CaseInsensitiveFieldAccessor.php deleted file mode 100644 index 83438ab17..000000000 --- a/src/Util/CaseInsensitiveFieldAccessor.php +++ /dev/null @@ -1,167 +0,0 @@ - true] - * @return mixed - */ - public function getValue(ViewableData $object, $fieldName, $opts = []) - { - $opts = $opts ?: []; - $opts = array_merge([ - self::HAS_METHOD => true, - self::HAS_FIELD => true, - self::HAS_SETTER => false, - self::DATAOBJECT => true, - ], $opts); - - $objectFieldName = $this->getObjectFieldName($object, $fieldName, $opts); - - if (!$objectFieldName) { - throw new InvalidArgumentException(sprintf( - 'Field name or method "%s" does not exist on %s', - $fieldName, - (string)$object - )); - } - - // Correct case for methods (e.g. canView) - if ($object->hasMethod($objectFieldName)) { - return $object->{$objectFieldName}(); - } - - // Correct case (and getters) - if ($object->hasField($objectFieldName)) { - return $object->{$objectFieldName}; - } - - return null; - } - - /** - * @param ViewableData $object The parent resolved object - * @param string $fieldName Name of the field/getter/method - * @param mixed $value - * @param array $opts Map of which lookups to use (class constants to booleans). - * Example: [ViewableDataCaseInsensitiveFieldMapper::HAS_METHOD => true] - * @return mixed - */ - public function setValue(ViewableData $object, $fieldName, $value, $opts = []) - { - $opts = $opts ?: []; - $opts = array_merge([ - self::HAS_METHOD => true, - self::HAS_FIELD => true, - self::HAS_SETTER => true, - self::DATAOBJECT => true, - ], $opts); - - $objectFieldName = $this->getObjectFieldName($object, $fieldName, $opts); - - if (!$objectFieldName) { - throw new InvalidArgumentException(sprintf( - 'Field name "%s" does not exist on %s', - $fieldName, - (string)$object - )); - } - - if ($object->hasMethod($objectFieldName)) { - // Correct case for methods (e.g. canView) - $object->{$objectFieldName}($value); - } elseif ($object->hasField($objectFieldName)) { - // Correct case (and getters) - $object->{$objectFieldName} = $value; - } elseif ($object instanceof DataObject) { - // Infer casing - $object->setField($objectFieldName, $value); - } - - return null; - } - - /** - * @param ViewableData $object The object to resolve a name on - * @param string $fieldName Name in different casing - * @param array $opts Map of which lookups to use (class constants to booleans). - * Example: [ViewableDataCaseInsensitiveFieldMapper::HAS_METHOD => true] - * @return null|string Name in actual casing on $object - */ - public function getObjectFieldName(ViewableData $object, $fieldName, $opts = []) - { - $opts = $opts ?: []; - $opts = array_merge([ - self::HAS_METHOD => true, - self::HAS_FIELD => true, - self::HAS_SETTER => true, - self::DATAOBJECT => true, - ], $opts); - - $optFn = function ($type) use (&$opts) { - return (in_array($type, $opts) && $opts[$type] === true); - }; - - // Correct case (and getters) - if ($optFn(self::HAS_FIELD) && $object->hasField($fieldName)) { - return $fieldName; - } - - // Infer casing from DataObject fields - if ($optFn(self::DATAOBJECT) && $object instanceof DataObject) { - $parents = ClassInfo::ancestry($object, true); - foreach ($parents as $parent) { - $fields = DataObject::getSchema()->databaseFields($parent); - foreach ($fields as $objectFieldName => $fieldClass) { - if (strcasecmp($objectFieldName, $fieldName) === 0) { - return $objectFieldName; - } - } - } - } - - // Setters - // TODO Support for Object::$extra_methods (case sensitive array key check) - $setterName = "set" . ucfirst($fieldName); - if ($optFn(self::HAS_SETTER) && $object->hasMethod($setterName)) { - return $setterName; - } - - // Correct case for methods (e.g. canView) - method_exists() is case insensitive - if ($optFn(self::HAS_METHOD) && $object->hasMethod($fieldName)) { - return $fieldName; - } - - return null; - } -} diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php deleted file mode 100644 index c4810ea79..000000000 --- a/tests/ConnectionTest.php +++ /dev/null @@ -1,263 +0,0 @@ - [ - 'TypeCreatorFake' => TypeCreatorFake::class, - ], - 'queries' => [ - 'paginatedquery' => PaginatedQueryFake::class, - ], - ]; - - $this->manager = Manager::create()->applyConfig($config); - $this->connection = Connection::create('testConnection') - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }) - ->setConnectionResolver(function () { - $result = new ArrayList(); - $result->push([ - 'ID' => 10, - 'MyValue' => 'testMyValidResolverValue' - ]); - - return $result; - }); - - - $fakeObject = new DataObjectFake([ - 'MyField' => 'object1' - ]); - - $fakeObject->write(); - - $fakeObject = new DataObjectFake([ - 'MyField' => 'object2' - ]); - - $fakeObject->write(); - } - - public function testResolveList() - { - $list = DataObjectFake::get(); - - $connection = Connection::create('testFake') - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }); - - $result = $connection->resolveList($list, []); - - $this->assertEquals(2, $result['edges']->count()); - $this->assertEquals(2, $result['pageInfo']['totalCount']); - $this->assertFalse($result['pageInfo']['hasNextPage']); - $this->assertFalse($result['pageInfo']['hasPreviousPage']); - - $this->assertEquals('object1', $result['edges']->first()->MyField); - - // test a resolution with the limit - $result = $connection->resolveList($list, ['limit' => 1]); - - $this->assertEquals(1, $result['edges']->count()); - $this->assertEquals(2, $result['pageInfo']['totalCount']); - $this->assertTrue($result['pageInfo']['hasNextPage']); - $this->assertFalse($result['pageInfo']['hasPreviousPage']); - } - - public function testResolveListSort() - { - $list = DataObjectFake::get(); - - $connection = Connection::create('testFake') - ->setSortableFields(['MyField']) - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }); - - // test a resolution with the limit - $result = $connection->resolveList( - $list, - ['sortBy' => [['field' => 'MyField', 'direction' => 'DESC']]] - ); - - $this->assertEquals('object2', $result['edges']->first()->MyField); - $this->assertEquals('object1', $result['edges']->last()->MyField); - - $result = $connection->resolveList( - $list, - ['sortBy' => [['field' => 'MyField', 'direction' => 'ASC']]] - ); - - $this->assertEquals('object1', $result['edges']->first()->MyField); - } - - public function testResolveListSortWithCustomMapping() - { - $list = DataObjectFake::get(); - - $connection = Connection::create('testFake') - ->setSortableFields(['MyFieldAlias' => 'MyField']) - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }); - - // test a resolution with the limit - $result = $connection->resolveList( - $list, - ['sortBy' => [['field' => 'MyFieldAlias', 'direction' => 'DESC']]] - ); - - $this->assertEquals('object2', $result['edges']->first()->MyField); - $this->assertEquals('object1', $result['edges']->last()->MyField); - - $result = $connection->resolveList( - $list, - ['sortBy' => [['field' => 'MyFieldAlias', 'direction' => 'ASC']]] - ); - - $this->assertEquals('object1', $result['edges']->first()->MyField); - } - - public function testSortByInvalidColumnThrowsException() - { - $this->expectException(InvalidArgumentException::class); - - $list = DataObjectFake::get(); - - $connection = Connection::create('testFake') - ->setSortableFields(['MyField']) - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }); - - // test a resolution with the limit - $connection->resolveList( - $list, - ['sortBy' => [['field' => 'ID', 'direction' => 'DESC']]] - ); - } - - public function testToType() - { - $type = $this->connection->toType(); - - $this->assertInstanceOf(FieldDefinition::class, $type->getField('pageInfo'), 'pageInfo should exist'); - $this->assertInstanceOf(FieldDefinition::class, $type->getField('edges'), 'edges should exist'); - } - - public function testGetEdgeTypeResolver() - { - $edge = $this->connection->getEdgeType(); - - $this->assertInstanceOf(ObjectType::class, $edge, 'Edge should be an ObjectType'); - $node = $edge->getField('node'); - - $this->assertInstanceOf(FieldDefinition::class, $node, 'Node should exist'); - } - - public function testCollectionResolves() - { - $resolve = $this->connection->resolve(null, [], [], new ResolveInfo([])); - $item = $resolve['edges']->first(); - $this->assertEquals('testMyValidResolverValue', $item['MyValue']); - } - - public function testCollectionWithLimits() - { - $list = DataObjectFake::get(); - - $connection = Connection::create('testFakeConnection') - ->setMaximumLimit(1) - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }); - - // test a resolution with the limit - $result = $connection->resolveList( - $list, - ['offset' => 1] - ); - - $this->assertEquals(1, $result['edges']->count(), 'We set maximum limit of 1'); - $this->assertTrue($result['pageInfo']['hasPreviousPage']); - } - - public function testSortInputTypeRendersType() - { - $type = new SortInputTypeCreator('TestSort'); - $type->setSortableFields(['ID', 'Title']); - - $built = $type->toType(); - $this->assertInstanceOf(InputObjectField::class, $built->getField('field')); - } - - public function testArgsAsArray() - { - $connection = Connection::create('testFakeConnection') - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }) - ->setArgs([ - 'arg1' => [ - 'type' => Type::int() - ] - ]); - - $this->assertArrayHasKey('arg1', $connection->args()); - } - - public function testArgsAsCallable() - { - $connection = Connection::create('testFakeConnection') - ->setConnectionType(function () { - return $this->manager->getType('TypeCreatorFake'); - }) - ->setArgs(function () { - return [ - 'arg1' => [ - 'type' => Type::int() - ] - ]; - }); - - $this->assertArrayHasKey('arg1', $connection->args()); - } -} diff --git a/tests/FieldCreatorTest.php b/tests/FieldCreatorTest.php deleted file mode 100644 index 6a427434c..000000000 --- a/tests/FieldCreatorTest.php +++ /dev/null @@ -1,23 +0,0 @@ -getMockBuilder(FieldCreator::class) - ->setMethods(['resolve']) - ->getMock(); - $mock->method('resolve')->willReturn(function () { - }); - - $attrs = $mock->getAttributes(); - - $this->assertArrayHasKey('resolve', $attrs); - } -} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php deleted file mode 100644 index 0b901a955..000000000 --- a/tests/ManagerTest.php +++ /dev/null @@ -1,245 +0,0 @@ -get(IdentityStore::class); - $store->logOut(); - } - - - public function testCreateFromConfig() - { - $config = [ - 'types' => [ - 'mytype' => TypeCreatorFake::class, - ], - 'queries' => [ - 'myquery' => QueryCreatorFake::class, - ], - 'mutations' => [ - 'mymutation' => MutationCreatorFake::class, - ], - ]; - $manager = Manager::create()->applyConfig($config); - $this->assertInstanceOf( - Type::class, - $manager->getType('mytype') - ); - $this->assertInstanceOf( - 'Closure', - $manager->getQuery('myquery') - ); - $this->assertInstanceOf( - 'Closure', - $manager->getMutation('mymutation') - ); - } - - public function testSchema() - { - $manager = new Manager(); - $manager->addType($this->getType($manager), 'mytype'); - $manager->addQuery($this->getQuery($manager), 'myquery'); - $manager->addMutation($this->getMutation($manager), 'mymutation'); - - $schema = $manager->schema(); - $this->assertInstanceOf(Schema::class, $schema); - $this->assertNotNull($schema->getType('TypeCreatorFake')); - $this->assertNotNull($schema->getMutationType()->getField('mymutation')); - $this->assertNotNull($schema->getQueryType()->getField('myquery')); - } - - public function testAddTypeAsNamedObject() - { - $manager = new Manager(); - $type = $this->getType($manager); - $manager->addType($type, 'mytype'); - $this->assertEquals( - $type, - $manager->getType('mytype') - ); - } - - public function testAddTypeAsUnnamedObject() - { - $manager = new Manager(); - $type = $this->getType($manager); - $manager->addType($type); - $this->assertEquals( - $type, - $manager->getType((string)$type) - ); - } - - public function testAddQuery() - { - $manager = new Manager(); - $type = $this->getType($manager); - $manager->addType($type, 'mytype'); - - $query = $this->getQuery($manager); - $manager->addQuery($query, 'myquery'); - - $this->assertEquals( - $query, - $manager->getQuery('myquery') - ); - $this->assertEquals( - $type, - $manager->getType('mytype') - ); - } - - public function testAddMutation() - { - $manager = new Manager(); - $type = $this->getType($manager); - $manager->addType($type, 'mytype'); - - $mutation = $this->getMutation($manager); - $manager->addMutation($mutation, 'mymutation'); - - $this->assertEquals( - $mutation, - $manager->getMutation('mymutation') - ); - } - - public function testSchemaKey() - { - $manager = new Manager(); - $this->assertNull($manager->getSchemaKey()); - $manager->setSchemaKey('test'); - $this->assertEquals('test', $manager->getSchemaKey()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/must be a string/'); - $manager->setSchemaKey(['test']); - $this->assertEquals('test', $manager->getSchemaKey()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/cannnot be empty/'); - $manager->setSchemaKey(''); - $this->assertEquals('test', $manager->getSchemaKey()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/alphanumeric/'); - $manager->setSchemaKey('completely % invalid #key'); - $this->assertEquals('test', $manager->getSchemaKey()); - } - - public function testConfigure() - { - $mock = $this->getMockBuilder(Manager::class) - ->setMethods(['applyConfig', 'getSchemaKey']) - ->getMock(); - $mock->expects($this->once()) - ->method('applyConfig') - ->with( - ['some' => 'setting'] - ); - $mock->expects($this->any()) - ->method('getSchemaKey') - ->willReturn('testKey'); - - Config::modify()->set( - Manager::class, - 'schemas', - [ - 'testKey' => [ - 'some' => 'setting' - ] - ] - ); - - $mock->configure(); - } - - public function testQueryWithError() - { - /** @var Manager $mock */ - $mock = $this->getMockBuilder(Manager::class) - ->setMethods(['queryAndReturnResult']) - ->getMock(); - $responseData = new \stdClass(); - $responseData->data = null; - $responseData->errors = [ - Error::createLocatedError( - 'Something went wrong' - ), - ]; - $mock->method('queryAndReturnResult') - ->willReturn($responseData); - - $response = $mock->query(''); - $this->assertArrayHasKey('errors', $response); - } - - /** - * Test the getter and setter for the Member. If not set, Member should be retrieved from the session. - */ - public function testGetAndSetMember() - { - $manager = new Manager; - $this->assertNull($manager->getMember()); - - $member = Member::create(); - $manager->setMember($member); - $this->assertSame($member, $manager->getMember()); - } - - public function testGetPersistedQueryByID() - { - $stub = $this->createMock(PersistedQueryMappingProvider::class); - $stub->expects($this->once()) - ->method('getByID') - ->with( - $this->equalTo('someID'), - $this->equalTo('default') - ) - ->willReturn('someQuery'); - Injector::inst()->registerService($stub, PersistedQueryMappingProvider::class); - - $manager = new Manager(); - - $result = $manager->getQueryFromPersistedID('someID'); - $this->assertEquals('someQuery', $result); - } - - protected function getType(Manager $manager) - { - return (new TypeCreatorFake($manager))->toType(); - } - - protected function getQuery(Manager $manager) - { - return (new QueryCreatorFake($manager))->toArray(); - } - - protected function getMutation(Manager $manager) - { - return (new MutationCreatorFake($manager))->toArray(); - } -} diff --git a/tests/Middleware/DummyResponseMiddleware.php b/tests/Middleware/DummyResponseMiddleware.php index 7441e5045..53a01095b 100644 --- a/tests/Middleware/DummyResponseMiddleware.php +++ b/tests/Middleware/DummyResponseMiddleware.php @@ -3,12 +3,12 @@ namespace SilverStripe\GraphQL\Tests\Middleware; use GraphQL\Type\Schema; -use SilverStripe\GraphQL\Middleware\QueryMiddleware; +use SilverStripe\GraphQL\Middleware\Middleware; -class DummyResponseMiddleware implements QueryMiddleware +class DummyResponseMiddleware implements Middleware { - public function process(Schema $schema, $query, $context, $params, callable $next) + public function process(array $params, callable $next) { - return ['result' => "It was me, {$params['name']}!"]; + return ['result' => "It was me, {$params['vars']['name']}!"]; } } diff --git a/tests/Middleware/MiddlewareProcessTestBase.php b/tests/Middleware/MiddlewareProcessTestBase.php index 456c2f0e1..15a2983ea 100644 --- a/tests/Middleware/MiddlewareProcessTestBase.php +++ b/tests/Middleware/MiddlewareProcessTestBase.php @@ -6,7 +6,7 @@ use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; use SilverStripe\Dev\SapphireTest; -use SilverStripe\GraphQL\Middleware\QueryMiddleware; +use SilverStripe\GraphQL\Middleware\Middleware; abstract class MiddlewareProcessTestBase extends SapphireTest { @@ -23,7 +23,7 @@ protected function setUp() : void }; } protected function simulateMiddlewareProcess( - QueryMiddleware $middleware, + Middleware $middleware, $query, $context = [], $params = [], diff --git a/tests/Scaffolding/Extensions/TypeCreatorExtensionTest.php b/tests/Scaffolding/Extensions/TypeCreatorExtensionTest.php deleted file mode 100644 index c969d5cbf..000000000 --- a/tests/Scaffolding/Extensions/TypeCreatorExtensionTest.php +++ /dev/null @@ -1,76 +0,0 @@ -merge(DBInt::class, 'graphql_type', 'Int'); - $manager = $this->getMockBuilder(Manager::class) - ->setMethods(['hasType','getType','addType']) - ->getMock(); - $manager->expects($this->never()) - ->method('addType'); - $manager->expects($this->never()) - ->method('getType'); - - $fake = new DBInt(5); - $extension = new TypeCreatorExtension(); - $extension->setOwner($fake); - - $parser = $extension->createTypeParser(); - $this->assertInstanceOf(TypeParserInterface::class, $parser); - $this->assertInstanceOf(IntType::class, $parser->getType()); - $this->assertTrue($extension->isInternalGraphQLType()); - $extension->addToManager($manager); - $this->assertInstanceOf(IntType::class, $extension->getGraphQLType($manager)); - } - - public function testGraphQLTypeAsArray() - { - Config::modify()->merge(DBInt::class, 'graphql_type', [ - 'FieldOne' => 'String', - 'FieldTwo' => 'Int' - ]); - $typeName = StaticSchema::inst()->typeName(DBInt::class); - - $mockManager = $this->getMockBuilder(Manager::class) - ->setMethods(['getType','addType']) - ->getMock(); - $mockManager->expects($this->once()) - ->method('addType') - ->with( - $this->isInstanceOf(ObjectType::class), - $this->equalTo($typeName) - ); - $mockManager->expects($this->once()) - ->method('getType') - ->with($typeName); - $manager = new Manager(); - - $fake = new DBInt(0); - $extension = new TypeCreatorExtension(); - $extension->setOwner($fake); - - $parser = $extension->createTypeParser(); - $this->assertInstanceOf(TypeParserInterface::class, $parser); - $this->assertInstanceOf(ObjectType::class, $parser->getType()); - $this->assertFalse($extension->isInternalGraphQLType()); - $extension->addToManager($mockManager); - $extension->getGraphQLType($mockManager); - - $extension->addToManager($manager); - $this->assertTrue($manager->hasType($typeName)); - } -} diff --git a/tests/Scaffolding/Scaffolders/ArgumentScaffolderTest.php b/tests/Scaffolding/Scaffolders/ArgumentScaffolderTest.php deleted file mode 100644 index 37c336eaa..000000000 --- a/tests/Scaffolding/Scaffolders/ArgumentScaffolderTest.php +++ /dev/null @@ -1,70 +0,0 @@ -setRequired(false) - ->setDescription('Description') - ->setDefaultValue('Default'); - - $this->assertEquals('Description', $scaffolder->getDescription()); - $this->assertEquals('Default', $scaffolder->getDefaultValue()); - $this->assertFalse($scaffolder->isRequired()); - - $scaffolder->applyConfig([ - 'description' => 'Foo', - 'default' => 'Bar', - 'required' => true, - ]); - - $this->assertEquals('Foo', $scaffolder->getDescription()); - $this->assertEquals('Bar', $scaffolder->getDefaultValue()); - $this->assertTrue($scaffolder->isRequired()); - - $arr = $scaffolder->toArray(); - - $this->assertEquals('Foo', $arr['description']); - $this->assertEquals('Bar', $arr['defaultValue']); - $this->assertInstanceOf(NonNull::class, $arr['type']); - $this->assertInstanceOf(StringType::class, $arr['type']->getWrappedType()); - - $scaffolder->setDefaultValue(null); - $arr = $scaffolder->toArray(); - $this->assertArrayNotHasKey('defaultValue', $arr); - } - - public function testNonInternalType() - { - $manager = new Manager(); - $manager->addType(new InputObjectType([ - 'name' => 'MyType', - 'fields' => [ - 'test' => ['type' => Type::string()] - ] - ]), 'MyType'); - $scaffolder = new ArgumentScaffolder('Test', 'MyType'); - - $result = $scaffolder->toArray($manager); - $this->assertInstanceOf(InputObjectType::class, $result['type']); - } - - public function testNonInternalTypeNoManager() - { - $this->expectException(InvalidArgumentException::class); - $scaffolder = new ArgumentScaffolder('Test', 'MyType'); - $scaffolder->toArray(); - } -} diff --git a/tests/Scaffolding/Scaffolders/CRUD/CreateTest.php b/tests/Scaffolding/Scaffolders/CRUD/CreateTest.php deleted file mode 100644 index a0173161b..000000000 --- a/tests/Scaffolding/Scaffolders/CRUD/CreateTest.php +++ /dev/null @@ -1,154 +0,0 @@ -setTypeNames([]); - // Make sure we're only testing the native features - foreach (Create::get_extensions() as $className) { - Create::remove_extension($className); - } - } - - protected function tearDown() : void - { - StaticSchema::inst()->setTypeNames([]); - parent::tearDown(); - } - - public function getExtensionDataProvider() - { - return [ - [false], - [true], - ]; - } - - /** - * @dataProvider getExtensionDataProvider - * - * @param bool $shouldExtend - */ - public function testCreateOperationResolver($shouldExtend) - { - if ($shouldExtend) { - Create::add_extension(FakeCRUDExtension::class); - } - StaticSchema::inst()->setTypeNames([ - DataObjectFake::class => 'FakeObject', - ]); - $create = new Create(DataObjectFake::class); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'FakeObject']), 'FakeObject'); - $create->addToManager($manager); - $scaffold = $create->scaffold($manager); - - $newRecord = $scaffold['resolve']( - null, - [ - 'Input' => ['MyField' => '__testing__'], - ], - [ - 'currentUser' => Member::create(), - ], - new ResolveInfo([]) - ); - - if ($shouldExtend) { - $this->assertNull($newRecord); - } else { - $this->assertGreaterThan(0, $newRecord->ID); - $this->assertEquals('__testing__', $newRecord->MyField); - } - } - - public function testCreateOperationInputType() - { - StaticSchema::inst()->setTypeNames([ - DataObjectFake::class => 'FakeObject', - ]); - $create = new Create(DataObjectFake::class); - $create->addArg('MyField', 'String'); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'FakeObject']), 'FakeObject'); - $create->addToManager($manager); - $scaffold = $create->scaffold($manager); - - // Test args - $args = $scaffold['args']; - $this->assertEquals(['Input', 'MyField'], array_keys($args)); - - // Custom field - $this->assertArrayHasKey('MyField', $args); - $this->assertInstanceOf(StringType::class, $args['MyField']['type']); - - /** @var NonNull $inputType */ - $inputType = $args['Input']['type']; - $this->assertInstanceOf(NonNull::class, $inputType); - /** @var InputObjectType $inputTypeWrapped */ - $inputTypeWrapped = $inputType->getWrappedType(); - $this->assertInstanceOf(InputObjectType::class, $inputTypeWrapped); - $this->assertEquals('FakeObjectCreateInputType', $inputTypeWrapped->toString()); - ; - - // Check fields - $config = $inputTypeWrapped->config; - $fieldMap = []; - foreach ($config['fields']() as $name => $fieldData) { - $fieldMap[$name] = $fieldData['type']; - } - $this->assertArrayHasKey('Created', $fieldMap, 'Includes fixed_fields'); - $this->assertArrayHasKey('MyField', $fieldMap); - $this->assertArrayHasKey('MyInt', $fieldMap); - $this->assertArrayNotHasKey('ID', $fieldMap); - $this->assertInstanceOf(StringType::class, $fieldMap['MyField']); - $this->assertInstanceOf(IntType::class, $fieldMap['MyInt']); - } - - public function testCreateOperationPermissionCheck() - { - StaticSchema::inst()->setTypeNames([ - RestrictedDataObjectFake::class => 'RestrictedFakeObject', - ]); - $create = new Create(RestrictedDataObjectFake::class); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'RestrictedFakeObject']), 'RestrictedFakeObject'); - $create->addToManager($manager); - $scaffold = $create->scaffold($manager); - - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Cannot create/'); - - $scaffold['resolve']( - null, - [], - ['currentUser' => Member::create()], - new ResolveInfo([]) - ); - } -} diff --git a/tests/Scaffolding/Scaffolders/CRUD/DeleteTest.php b/tests/Scaffolding/Scaffolders/CRUD/DeleteTest.php deleted file mode 100644 index 41d55b085..000000000 --- a/tests/Scaffolding/Scaffolders/CRUD/DeleteTest.php +++ /dev/null @@ -1,136 +0,0 @@ -addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $delete->addToManager($manager); - $scaffold = $delete->scaffold($manager); - - $record = DataObjectFake::create(); - $ID1 = $record->write(); - - $record = DataObjectFake::create(); - $ID2 = $record->write(); - - $record = DataObjectFake::create(); - $ID3 = $record->write(); - - $scaffold['resolve']( - $record, - [ - 'IDs' => [$ID1, $ID2], - ], - [ - 'currentUser' => Member::create(), - ], - new ResolveInfo([]) - ); - - if ($shouldExtend) { - $this->assertNotNull(DataObjectFake::get()->byID($ID1)); - $this->assertNotNull(DataObjectFake::get()->byID($ID2)); - $this->assertInstanceOf(DataObjectFake::class, DataObjectFake::get()->byID($ID3)); - } else { - $this->assertNull(DataObjectFake::get()->byID($ID1)); - $this->assertNull(DataObjectFake::get()->byID($ID2)); - $this->assertInstanceOf(DataObjectFake::class, DataObjectFake::get()->byID($ID3)); - } - } - - public function testDeleteOperationArgs() - { - $delete = new Delete(DataObjectFake::class); - $delete->addArg('MyField', 'String'); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $delete->addToManager($manager); - $scaffold = $delete->scaffold($manager); - - // Test args - $args = $scaffold['args']; - $this->assertEquals(['IDs', 'MyField'], array_keys($args)); - - /** @var NonNull $idType */ - $idType = $args['IDs']['type']; - $this->assertInstanceOf(NonNull::class, $idType); - /** @var ListOfType $idTypeWrapped */ - $idTypeWrapped = $idType->getWrappedType(); - $this->assertInstanceOf(ListOfType::class, $idTypeWrapped); - $this->assertInstanceOf(IDType::class, $idTypeWrapped->getWrappedType()); - - // Custom field - $this->assertArrayHasKey('MyField', $args); - $this->assertInstanceOf(StringType::class, $args['MyField']['type']); - } - - public function testDeleteOperationPermissionCheck() - { - $delete = new Delete(RestrictedDataObjectFake::class); - $restrictedDataobject = RestrictedDataObjectFake::create(); - $ID = $restrictedDataobject->write(); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'SilverStripeRestrictedDataObjectFake']), 'SilverStripeRestrictedDataObjectFake'); - - $scaffold = $delete->scaffold($manager); - - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Cannot delete/'); - - $scaffold['resolve']( - $restrictedDataobject, - ['IDs' => [$ID]], - ['currentUser' => Member::create()], - new ResolveInfo([]) - ); - } -} diff --git a/tests/Scaffolding/Scaffolders/CRUD/ReadOneTest.php b/tests/Scaffolding/Scaffolders/CRUD/ReadOneTest.php deleted file mode 100644 index b604d5bed..000000000 --- a/tests/Scaffolding/Scaffolders/CRUD/ReadOneTest.php +++ /dev/null @@ -1,82 +0,0 @@ -addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $read->addToManager($manager); - $scaffold = $read->scaffold($manager); - - DataObjectFake::get()->removeAll(); - - $record = DataObjectFake::create(); - $record->MyField = 'Test'; - $ID = $record->write(); - - - $response = $scaffold['resolve']( - null, - ['ID' => $ID], - [ - 'currentUser' => Member::create(), - ], - new ResolveInfo([]) - ); - $this->assertInstanceOf(DataObjectFake::class, $response); - $this->assertEquals($ID, $response->ID); - $this->assertEquals('Test', $response->MyField); - } - - public function testReadOneOperationArgs() - { - $read = new ReadOne(DataObjectFake::class); - $read->addArg('MyField', 'String'); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $read->addToManager($manager); - $scaffold = $read->scaffold($manager); - - // Check all args - $args = $scaffold['args']; - $this->assertEquals(['ID', 'MyField'], array_keys($args)); - - /** @var NonNull $idType */ - $idType = $args['ID']['type']; - $this->assertInstanceOf(IDType::class, $idType); - - // Check custom arg - $this->assertArrayHasKey('MyField', $args); - $this->assertInstanceOf(StringType::class, $args['MyField']['type']); - } -} diff --git a/tests/Scaffolding/Scaffolders/CRUD/ReadTest.php b/tests/Scaffolding/Scaffolders/CRUD/ReadTest.php deleted file mode 100644 index c63287090..000000000 --- a/tests/Scaffolding/Scaffolders/CRUD/ReadTest.php +++ /dev/null @@ -1,82 +0,0 @@ -addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $read->addToManager($manager); - $scaffold = $read->scaffold($manager); - - DataObjectFake::get()->removeAll(); - - $record1 = DataObjectFake::create(); - $record1->MyField = 'AA First'; - $ID1 = $record1->write(); - - $record2 = DataObjectFake::create(); - $record2->MyField = 'ZZ Last'; - $ID2 = $record2->write(); - - $record3 = DataObjectFake::create(); - $record3->MyField = 'BB Middle'; - $ID3 = $record3->write(); - - $response = $scaffold['resolve']( - null, - [], - [ - 'currentUser' => Member::create(), - ], - new ResolveInfo([]) - ); - - $this->assertArrayHasKey('edges', $response); - /** @var DataList $edges */ - $edges = $response['edges']; - $this->assertEquals([$ID1, $ID3, $ID2], $edges->column('ID')); - } - - public function testReadOperationArgs() - { - $read = new Read(DataObjectFake::class); - $read->addArg('MyField', 'String'); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'GraphQL_DataObjectFake']), 'GraphQL_DataObjectFake'); - $read->addToManager($manager); - $scaffold = $read->scaffold($manager); - - $args = $scaffold['args']; - $this->assertArrayHasKey('MyField', $args); - $this->assertInstanceOf(StringType::class, $args['MyField']['type']); - } -} diff --git a/tests/Scaffolding/Scaffolders/CRUD/UpdateTest.php b/tests/Scaffolding/Scaffolders/CRUD/UpdateTest.php deleted file mode 100644 index e87c8fed0..000000000 --- a/tests/Scaffolding/Scaffolders/CRUD/UpdateTest.php +++ /dev/null @@ -1,154 +0,0 @@ -addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $update->addToManager($manager); - $scaffold = $update->scaffold($manager); - - $record = DataObjectFake::create([ - 'MyField' => 'old', - ]); - $ID = $record->write(); - - $scaffold['resolve']( - $record, - [ - 'Input' => [ - 'ID' => $ID, - 'MyField' => 'new' - ], - ], - [ - 'currentUser' => Member::create(), - ], - new ResolveInfo([]) - ); - - /** @var DataObjectFake $updatedRecord */ - $updatedRecord = DataObjectFake::get()->byID($ID); - if ($shouldExtend) { - $this->assertEquals('old', $updatedRecord->MyField); - } else { - $this->assertEquals('new', $updatedRecord->MyField); - } - } - - public function testUpdateOperationInputType() - { - $update = new Update(DataObjectFake::class); - $update->addArg('MyField', 'String'); - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'SilverStripeDataObjectFake']), 'SilverStripeDataObjectFake'); - $update->addToManager($manager); - $scaffold = $update->scaffold($manager); - - // Test args - $args = $scaffold['args']; - $this->assertEquals(['Input', 'MyField'], array_keys($args)); - - /** @var NonNull $inputType */ - $inputType = $args['Input']['type']; - $this->assertInstanceOf(NonNull::class, $inputType); - /** @var InputObjectType $inputTypeWrapped */ - $inputTypeWrapped = $inputType->getWrappedType(); - $this->assertInstanceOf(InputObjectType::class, $inputTypeWrapped); - $this->assertEquals('SilverStripeDataObjectFakeUpdateInputType', $inputTypeWrapped->toString()); - - // Custom field - $this->assertInstanceOf(StringType::class, $args['MyField']['type']); - - // Test fields - $config = $inputTypeWrapped->config; - $fieldMap = []; - foreach ($config['fields']() as $name => $fieldData) { - $fieldMap[$name] = $fieldData['type']; - } - $this->assertArrayHasKey('Created', $fieldMap, 'Includes fixed_fields'); - $this->assertArrayHasKey('MyField', $fieldMap); - $this->assertArrayHasKey('MyInt', $fieldMap); - $this->assertArrayHasKey('ID', $fieldMap); - $this->assertInstanceOf(NonNull::class, $fieldMap['ID']); - $this->assertInstanceOf(IDType::class, $fieldMap['ID']->getWrappedType()); - $this->assertInstanceOf(StringType::class, $fieldMap['MyField']); - $this->assertInstanceOf(IntType::class, $fieldMap['MyInt']); - } - - public function testUpdateOperationPermissionCheck() - { - $update = new Update(RestrictedDataObjectFake::class); - $restrictedDataobject = RestrictedDataObjectFake::create(); - $ID = $restrictedDataobject->write(); - - $manager = new Manager(); - $manager->addType(new ObjectType(['name' => 'SilverStripeRestrictedDataObjectFake']), 'SilverStripeRestrictedDataObjectFake'); - $update->addToManager($manager); - $scaffold = $update->scaffold($manager); - - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Cannot edit/'); - - $scaffold['resolve']( - $restrictedDataobject, - [ - 'Input' => [ - 'ID' => $ID, - ], - ], - ['currentUser' => Member::create()], - new ResolveInfo([]) - ); - } -} diff --git a/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php b/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php deleted file mode 100644 index d0f088e9e..000000000 --- a/tests/Scaffolding/Scaffolders/DataObjectScaffolderTest.php +++ /dev/null @@ -1,508 +0,0 @@ -getFakeScaffolder(); - $this->assertEquals(DataObjectFake::class, $scaffolder->getDataObjectClass()); - $this->assertInstanceOf(DataObjectFake::class, $scaffolder->getDataObjectInstance()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/Non-existent classname/i'); - new DataObjectScaffolder('fail'); - } - - public function testDataObjectScaffolderFields() - { - $scaffolder = $this->getFakeScaffolder(); - - $scaffolder->addField('MyField'); - $this->assertEquals(['MyField'], $scaffolder->getFields()->column('Name')); - - $scaffolder->addField('MyField', 'Some description'); - $this->assertEquals( - 'Some description', - $scaffolder->getFieldDescription('MyField') - ); - - $scaffolder->addFields([ - 'MyField', - 'MyInt' => 'Int description', - ]); - - $this->assertEquals(['MyField', 'MyInt'], $scaffolder->getFields()->column('Name')); - $this->assertNull( - $scaffolder->getFieldDescription('MyField') - ); - $this->assertEquals( - 'Int description', - $scaffolder->getFieldDescription('MyInt') - ); - - $scaffolder->setFieldDescription('MyInt', 'New int description'); - $this->assertEquals( - 'New int description', - $scaffolder->getFieldDescription('MyInt') - ); - - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addAllFields(); - $this->assertEquals( - ['ID', 'ClassName', 'LastEdited', 'Created', 'MyField', 'MyInt'], - $scaffolder->getFields()->column('Name') - ); - - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addAllFields(true); - $this->assertEquals( - ['ID', 'ClassName', 'LastEdited', 'Created', 'MyField', 'MyInt', 'Author'], - $scaffolder->getFields()->column('Name') - ); - - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addAllFieldsExcept('MyInt'); - $this->assertEquals( - ['ID', 'ClassName', 'LastEdited', 'Created', 'MyField'], - $scaffolder->getFields()->column('Name') - ); - - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addAllFieldsExcept('MyInt', true); - $this->assertEquals( - ['ID', 'ClassName', 'LastEdited', 'Created', 'MyField', 'Author'], - $scaffolder->getFields()->column('Name') - ); - - $scaffolder->removeField('ClassName'); - $this->assertEquals( - ['ID', 'LastEdited', 'Created', 'MyField', 'Author'], - $scaffolder->getFields()->column('Name') - ); - $scaffolder->removeFields(['LastEdited', 'Created']); - $this->assertEquals( - ['ID', 'MyField', 'Author'], - $scaffolder->getFields()->column('Name') - ); - } - - public function testDataObjectScaffolderOperations() - { - $scaffolder = $this->getFakeScaffolder(); - $op = $scaffolder->operation(SchemaScaffolder::CREATE); - - $this->assertInstanceOf(OperationScaffolder::class, $op); - - // Ensure we get back the same reference - $op->Test = true; - $op = $scaffolder->operation(SchemaScaffolder::CREATE); - $this->assertEquals(true, $op->Test); - - // Ensure duplicates aren't created - $scaffolder->operation(SchemaScaffolder::DELETE); - $this->assertEquals(2, $scaffolder->getOperations()->count()); - - $scaffolder->removeOperation(SchemaScaffolder::DELETE); - $this->assertEquals(1, $scaffolder->getOperations()->count()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/Invalid operation/'); - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->operation('fail'); - } - - public function testDataObjectScaffolderNestedQueries() - { - $scaffolder = $this->getFakeScaffolder(); - $query = $scaffolder->nestedQuery('Files'); - - $this->assertInstanceOf(QueryScaffolder::class, $query); - - // Ensure we get back the same reference - $query->Test = true; - $query = $scaffolder->nestedQuery('Files'); - $this->assertEquals(true, $query->Test); - - // Ensure duplicates aren't created - $this->assertCount(1, $scaffolder->getNestedQueries()); - - // Ensure nested queries are not added to manager - $managerMock = $this->getMockBuilder(Manager::class) - ->setMethods(['addQuery']) - ->getMock(); - $managerMock - ->expects($this->never()) - ->method('addQuery'); - $managerMock->addType(new ObjectType([ - 'name' => 'File', - 'fields' => [] - ])); - $scaffolder->addToManager($managerMock); - - // Can't add a nested query for a regular field - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/returns a DataList or ArrayList/'); - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->nestedQuery('MyField'); - } - - public function testDataObjectScaffolderDependentClasses() - { - $scaffolder = $this->getFakeScaffolder(); - $this->assertEquals([], $scaffolder->getDependentClasses()); - $scaffolder->nestedQuery('Files'); - $this->assertEquals( - [$scaffolder->getDataObjectInstance()->Files()->dataClass()], - $scaffolder->getDependentClasses() - ); - - $scaffolder->addField('Author'); - $this->assertEquals( - [ - get_class($scaffolder->getDataObjectInstance()->Author()), - $scaffolder->getDataObjectInstance()->Files()->dataClass(), - ], - $scaffolder->getDependentClasses() - ); - } - - public function testDataObjectScaffolderAncestralClasses() - { - $scaffolder = new DataObjectScaffolder(FakeRedirectorPage::class); - $classes = $scaffolder->getAncestralClasses(); - - $this->assertEquals([ - FakePage::class, - FakeSiteTree::class, - ], $classes); - } - - public function testDataObjectScaffolderApplyConfig() - { - /** @var DataObjectScaffolder $observer */ - $observer = $this->getMockBuilder(DataObjectScaffolder::class) - ->setConstructorArgs([DataObjectFake::class]) - ->setMethods(['addFields', 'removeFields', 'operation', 'nestedQuery', 'setFieldDescription']) - ->getMock(); - - $observer->expects($this->once()) - ->method('addFields') - ->with($this->equalTo(['ID', 'MyField', 'MyInt'])); - - $observer->expects($this->once()) - ->method('removeFields') - ->with($this->equalTo(['ID'])); - - $observer->expects($this->exactly(2)) - ->method('operation') - ->withConsecutive( - [$this->equalTo(SchemaScaffolder::CREATE)], - [$this->equalTo(SchemaScaffolder::READ)] - ) - ->will($this->returnValue( - $this->getMockBuilder(Create::class) - ->disableOriginalConstructor() - ->getMock() - )); - - $observer->expects($this->once()) - ->method('nestedQuery') - ->with($this->equalTo('Files')) - ->will($this->returnValue( - $this->getMockBuilder(QueryScaffolder::class) - ->disableOriginalConstructor() - ->getMock() - )); - - $observer->expects($this->once()) - ->method('setFieldDescription') - ->with( - $this->equalTo('MyField'), - $this->equalTo('This is myfield') - ); - - $observer->applyConfig([ - 'fields' => ['ID', 'MyField', 'MyInt'], - 'excludeFields' => ['ID'], - 'operations' => ['create' => true, 'read' => true], - 'nestedQueries' => ['Files' => true], - 'fieldDescriptions' => [ - 'MyField' => 'This is myfield', - ], - ]); - } - - public function testDataObjectScaffolderApplyConfigNoFieldsException() - { - $scaffolder = $this->getFakeScaffolder(); - - // Must have "fields" defined - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/^No fields or nestedQueries/'); - $scaffolder->applyConfig([ - 'operations' => ['create' => true], - ]); - } - - public function testDataObjectScaffolderApplyConfigInvalidFieldsException() - { - $scaffolder = $this->getFakeScaffolder(); - - // Invalid fields - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Fields must be an array/'); - $scaffolder->applyConfig([ - 'fields' => 'fail', - ]); - } - - public function testDataObjectScaffolderApplyConfigInvalidFieldsExceptException() - { - $scaffolder = $this->getFakeScaffolder(); - - // Invalid fieldsExcept - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/"excludeFields" must be an enumerated list/'); - $scaffolder->applyConfig([ - 'fields' => ['MyField'], - 'excludeFields' => 'fail', - ]); - } - - public function testDataObjectScaffolderApplyConfigInvalidOperationsException() - { - $scaffolder = $this->getFakeScaffolder(); - - // Invalid operations - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Operations field must be a map/'); - $scaffolder->applyConfig([ - 'fields' => ['MyField'], - 'operations' => ['create'], - ]); - } - - public function testDataObjectScaffolderApplyConfigInvalidNestedQueriesException() - { - $scaffolder = $this->getFakeScaffolder(); - - // Invalid nested queries - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/"nestedQueries" must be a map of relation name/'); - $scaffolder->applyConfig([ - 'fields' => ['MyField'], - 'nestedQueries' => ['Files'], - ]); - } - - public function testDataObjectScaffolderWildcards() - { - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->applyConfig([ - 'fields' => SchemaScaffolder::ALL, - 'operations' => SchemaScaffolder::ALL, - ]); - $ops = $scaffolder->getOperations(); - - $this->assertInstanceOf(Create::class, $ops->findByIdentifier(SchemaScaffolder::CREATE)); - $this->assertInstanceOf(Delete::class, $ops->findByIdentifier(SchemaScaffolder::DELETE)); - $this->assertInstanceOf(Read::class, $ops->findByIdentifier(SchemaScaffolder::READ)); - $this->assertInstanceOf(Update::class, $ops->findByIdentifier(SchemaScaffolder::UPDATE)); - - $this->assertEquals( - ['ID', 'ClassName', 'LastEdited', 'Created', 'MyField', 'MyInt', 'Author'], - $scaffolder->getFields()->column('Name') - ); - } - - public function testDataObjectScaffolderScaffold() - { - $manager = $this->getMockBuilder(Manager::class) - ->setMethods(['getType', 'hasType']) - ->getMock(); - $manager->method('getType') - ->will($this->returnValue([])); - $manager->method('hasType') - ->will($this->returnValue(true)); - - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addFields(['MyField', 'Author']) - ->nestedQuery('Files'); - - $objectType = $scaffolder->scaffold($manager); - - $this->assertInstanceof(ObjectType::class, $objectType); - $config = $objectType->config; - - $this->assertEquals($scaffolder->getTypeName(), $config['name']); - $this->assertEquals(['MyField', 'Author', 'Files'], array_keys($config['fields']())); - } - - public function testDataObjectScaffolderScaffoldFieldException() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/Invalid field/'); - $scaffolder = $this->getFakeScaffolder() - ->addFields(['not a field']) - ->scaffold(new Manager()); - $scaffolder->config['fields'](); - } - - public function testDataObjectScaffolderScaffoldNestedQueryException() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/returns a list/'); - $scaffolder = $this->getFakeScaffolder() - ->addFields(['Files']) - ->scaffold(new Manager()); - $scaffolder->config['fields'](); - } - - public function testDataObjectScaffolderAddToManager() - { - $manager = new Manager(); - /** @var DataObjectScaffolder $scaffolder */ - $scaffolder = $this->getFakeScaffolder() - ->addFields(['MyField']) - ->operation(SchemaScaffolder::CREATE) - ->setName('Create and Barrel') - ->end() - ->operation(SchemaScaffolder::READ) - ->setName('Ready McRead') - ->end(); - - $scaffolder->addToManager($manager); - - $schema = $manager->schema(); - $queryConfig = $schema->getQueryType()->config; - $mutationConfig = $schema->getMutationType()->config; - - $this->assertArrayHasKey( - 'Ready McRead', - $queryConfig['fields']() - ); - - $this->assertArrayHasKey( - 'Create and Barrel', - $mutationConfig['fields']() - ); - - $this->assertTrue($manager->hasType($scaffolder->getTypeName())); - } - - public function testDataObjectScaffolderSimpleFieldTypes() - { - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addField('MyInt'); - $manager = new Manager(); - $result = $scaffolder->scaffold($manager); - $fields = $result->config['fields'](); - $myIntResolver = $fields['MyInt']['resolve']; - $fake = new DataObjectFake(['MyInt' => 5]); - $value = $myIntResolver($fake, [], null, new ResolveInfo(['fieldName' => 'MyInt'])); - - $this->assertEquals(5, $value); - Config::modify()->merge(DBInt::class, 'graphql_type', [ - 'FieldOne' => 'String', - 'FieldTwo' => 'Int', - ]); - } - - public function testDataObjectScaffolderComplexFieldTypes() - { - Config::modify()->merge(DBInt::class, 'graphql_type', [ - 'FieldOne' => 'String', - 'FieldTwo' => 'Int', - ]); - $fake = new DataObjectFake(['MyInt' => 5]); - $manager = new Manager(); - /** @var DBInt|TypeCreatorExtension $dbInt */ - $dbInt = new DBInt(0); - $dbInt->addToManager($manager); - - $this->assertInstanceOf(ObjectType::class, $fake->obj('MyInt')->getGraphQLType($manager)); - $scaffolder = $this->getFakeScaffolder(); - $scaffolder->addField('MyInt'); - $result = $scaffolder->scaffold($manager); - $fields = $result->config['fields'](); - $myIntResolver = $fields['MyInt']['resolve']; - - $value = $myIntResolver($fake, [], null, new ResolveInfo(['fieldName' => 'MyInt'])); - - $this->assertInstanceOf(DBInt::class, $value); - } - - public function testCloneTo() - { - $scaffolder = new DataObjectScaffolder(FakeRedirectorPage::class); - $scaffolder->addFields(['Title', 'RedirectionType', 'ID']); - $scaffolder->operation(SchemaScaffolder::READ); - $scaffolder->operation(SchemaScaffolder::UPDATE); - - $target = new DataObjectScaffolder(FakeSiteTree::class); - $this->assertNotContains('Title', $target->getFields()->column('Name')); - $this->assertNotContains('RedirectionType', $target->getFields()->column('Name')); - $this->assertEmpty($target->getOperations()); - - $target = $scaffolder->cloneTo($target); - - $this->assertContains('Title', $target->getFields()->column('Name')); - $this->assertNotContains('RedirectionType', $target->getFields()->column('Name')); - $this->assertCount(0, $target->getOperations(), 'Does not clone operations by default'); - } - - public function testCloneToWithCloneable() - { - $scaffolder = new DataObjectScaffolder(FakeSiteTree::class); - $scaffolder->addFields(['Title', 'ID']); - $scaffolder->operation(SchemaScaffolder::READ); - $scaffolder->operation(SchemaScaffolder::UPDATE)->setCloneable(true); - - $target = new DataObjectScaffolder(FakeRedirectorPage::class); - $target = $scaffolder->cloneTo($target); - $this->assertEquals( - ['updateSilverStripeFakeRedirectorPage'], - $target->getOperations()->column('getName') - ); - } - - /** - * @return DataObjectScaffolder - */ - protected function getFakeScaffolder() - { - return new DataObjectScaffolder(DataObjectFake::class); - } -} diff --git a/tests/Scaffolding/Scaffolders/InheritanceScaffolderTest.php b/tests/Scaffolding/Scaffolders/InheritanceScaffolderTest.php deleted file mode 100644 index c8606d19c..000000000 --- a/tests/Scaffolding/Scaffolders/InheritanceScaffolderTest.php +++ /dev/null @@ -1,73 +0,0 @@ -expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/not exist/'); - - $scaffolder = new InheritanceScaffolder('fail'); - } - - public function testThrowsOnNonDataObjectClass() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/subclass of/'); - - $scaffolder = new InheritanceScaffolder(Controller::class); - } - - public function testGettersAndSetters() - { - $scaffolder = new InheritanceScaffolder(DataObjectFake::class); - $this->assertEquals(DataObjectFake::class, $scaffolder->getRootClass()); - $scaffolder->setRootClass(FakePage::class); - $this->assertEquals(FakePage::class, $scaffolder->getRootClass()); - $scaffolder->setSuffix('test'); - $this->assertEquals('test', $scaffolder->getSuffix()); - } - - public function testScaffolding() - { - $schema = StaticSchema::inst(); - $manager = new Manager(); - $manager->addType(new ObjectType([ - 'name' => $schema->typeNameForDataObject(FakeSiteTree::class) - ])); - $manager->addType(new ObjectType([ - 'name' => $schema->typeNameForDataObject(FakePage::class) - ])); - $manager->addType(new ObjectType([ - 'name' => $schema->typeNameForDataObject(FakeRedirectorPage::class) - ])); - - $scaffolder = new InheritanceScaffolder(FakeSiteTree::class, 'TheSuffix'); - $scaffold = $scaffolder->scaffold($manager); - $typeName = StaticSchema::inst()->typeNameForDataObject(FakeSiteTree::class); - $this->assertEquals($typeName . 'TheSuffix', $scaffold->config['name']); - - $nestedTypes = array_map(function ($type) { - return $type->config['name']; - }, $scaffold->getTypes()); - - $this->assertContains( - StaticSchema::inst()->typeNameForDataObject(FakeSiteTree::class), - $nestedTypes - ); - } -} diff --git a/tests/Scaffolding/Scaffolders/ItemQueryScaffolderTest.php b/tests/Scaffolding/Scaffolders/ItemQueryScaffolderTest.php deleted file mode 100644 index 943e69448..000000000 --- a/tests/Scaffolding/Scaffolders/ItemQueryScaffolderTest.php +++ /dev/null @@ -1,94 +0,0 @@ -getMockBuilder(Manager::class) - ->setMethods(['addQuery']) - ->getMock(); - $scaffolder = new ItemQueryScaffolder('testQuery', 'test'); - $scaffolder->setDescription('My description'); - $scaffolder->addArgs(['Test' => 'String']); - $manager = new Manager(); - $manager->addType($o = new ObjectType([ - 'name' => 'test', - 'fields' => [], - ])); - $o->Test = true; - - $scaffold = $scaffolder->scaffold($manager); - - $this->assertEquals('testQuery', $scaffold['name']); - $this->assertEquals('My description', $scaffold['description']); - $this->assertArrayHasKey('Test', $scaffold['args']); - $this->assertTrue(is_callable($scaffold['resolve'])); - $this->assertTrue($scaffold['type']->Test); - - $observer->expects($this->once()) - ->method('addQuery') - ->with( - function ($arg) use ($scaffold) { - return $arg() === $scaffold; - }, - $this->equalTo('testQuery') - ); - - $scaffolder->addToManager($observer); - } - - /** - * @dataProvider permissionProvider - * @param bool|null $allow - */ - public function testPermissionCheck($allow) - { - $resolver = function () { - return new ArrayData(['Foo' => 'Bar']); - }; - $manager = new Manager(); - $manager->addType(new ObjectType([ - 'name' => 'testType', - 'fields' => [], - ])); - - $scaffolder = new ItemQueryScaffolder('testQuery', 'testType', $resolver); - if ($allow !== null) { - $scaffolder->setPermissionChecker(new FakeQueryPermissionChecker($allow)); - } - - $scaffolder->addToManager($manager); - $arr = $scaffolder->scaffold($manager); - if ($allow === false) { - $this->expectException('Exception'); - } - $result = $arr['resolve'](null, [], ['currentUser' => null], new ResolveInfo([])); - if ($allow !== false) { - $this->assertNotNull($result); - $this->assertEquals('Bar', $result->Foo); - } - } - - /** - * @return array - */ - public function permissionProvider() - { - return [ - [null], - [true], - [false] - ]; - } -} diff --git a/tests/Scaffolding/Scaffolders/ListQueryScaffolderTest.php b/tests/Scaffolding/Scaffolders/ListQueryScaffolderTest.php deleted file mode 100644 index 7523ab2a2..000000000 --- a/tests/Scaffolding/Scaffolders/ListQueryScaffolderTest.php +++ /dev/null @@ -1,218 +0,0 @@ -getMockBuilder(Manager::class) - ->setMethods(['addQuery']) - ->getMock(); - $scaffold = new ListQueryScaffolder($observer, 'test'); - - $this->assertEquals(100, $scaffold->getPaginationLimit()); - - $scaffold->setPaginationLimit(200); - $this->assertEquals(100, $scaffold->getPaginationLimit()); - - $scaffold->setPaginationLimit(25); - $this->assertEquals(25, $scaffold->getPaginationLimit()); - } - - public function testMaximumPaginationLimit() - { - /** @var Manager $observer */ - $observer = $this->getMockBuilder(Manager::class) - ->setMethods(['addQuery']) - ->getMock(); - $scaffold = new ListQueryScaffolder($observer, 'test'); - - $this->assertEquals(100, $scaffold->getMaximumPaginationLimit()); - - $scaffold->setMaximumPaginationLimit(200); - $this->assertEquals(200, $scaffold->getMaximumPaginationLimit()); - - $scaffold->setMaximumPaginationLimit(25); - $this->assertEquals(25, $scaffold->getPaginationLimit()); - } - - public function testListQueryScaffolderUnpaginated() - { - /** @var Manager $observer */ - $observer = $this->getMockBuilder(Manager::class) - ->setMethods(['addQuery']) - ->getMock(); - $scaffolder = new ListQueryScaffolder('testQuery', 'test'); - $scaffolder->setDescription('My description'); - $scaffolder->setUsePagination(false); - $scaffolder->addArgs(['Test' => 'String']); - $manager = new Manager(); - $manager->addType($o = new ObjectType([ - 'name' => 'test', - 'fields' => [], - ])); - $o->Test = true; - - $scaffold = $scaffolder->scaffold($manager); - - - $this->assertEquals('testQuery', $scaffold['name']); - $this->assertEquals('My description', $scaffold['description']); - $this->assertArrayHasKey('Test', $scaffold['args']); - $this->assertTrue(is_callable($scaffold['resolve'])); - $this->assertTrue($scaffold['type']->getWrappedType()->Test); - - $observer->expects($this->once()) - ->method('addQuery') - ->with( - function ($arg) use ($scaffold) { - return $arg() === $scaffold; - }, - $this->equalTo('testQuery') - ); - - $scaffolder->addToManager($observer); - } - - public function testListQueryScaffolderPaginated() - { - $scaffolder = new ListQueryScaffolder('testQuery', 'test'); - $scaffolder->setUsePagination(true); - $scaffolder->setPaginationLimit(25); - $scaffolder->setMaximumPaginationLimit(110); - $scaffolder->addArgs(['Test' => 'String']); - $scaffolder->addSortableFields(['test']); - $manager = new Manager(); - $manager->addType(new ObjectType([ - 'name' => 'test', - 'fields' => [], - ])); - $scaffolder->addToManager($manager); - $scaffold = $scaffolder->scaffold($manager); - $config = $scaffold['type']->config; - - $this->assertEquals('testQueryConnection', $config['name']); - $this->assertArrayHasKey('pageInfo', $config['fields']()); - $this->assertArrayHasKey('edges', $config['fields']()); - } - - public function testListQueryScaffolderApplyConfig() - { - /** @var ListQueryScaffolder $mock */ - $mock = $this->getMockBuilder(ListQueryScaffolder::class) - ->setConstructorArgs(['testQuery', 'testType']) - ->setMethods([ - 'addSortableFields', - 'setUsePagination', - 'setPaginationLimit', - 'setMaximumPaginationLimit', - ]) - ->getMock(); - $mock->expects($this->once()) - ->method('addSortableFields') - ->with(['Test1', 'Test2']); - $mock->expects($this->exactly(3)) - ->method('setUsePagination') - ->withConsecutive([false], [[ - 'limit' => 25, - 'maximumLimit' => 110 - ]], [[ - 'defaultLimit' => 25, - 'maximumLimit' => 110 - ]]); - - $mock->applyConfig([ - 'sortableFields' => ['Test1', 'Test2'], - 'paginate' => false, - ]); - - $mock->expects($this->exactly(2)) - ->method('setPaginationLimit') - ->with(25); - $mock->expects($this->exactly(2)) - ->method('setMaximumPaginationLimit') - ->with(110); - - $mock->applyConfig([ - 'paginate' => [ - 'limit' => 25, - 'maximumLimit' => 110 - ], - ]); - - $mock->applyConfig([ - 'paginate' => [ - 'defaultLimit' => 25, - 'maximumLimit' => 110 - ], - ]); - } - - public function testListQueryScaffolderApplyConfigThrowsOnBadSortableFields() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/sortableFields must be an array/'); - $scaffolder = new ListQueryScaffolder('testQuery', 'testType'); - $scaffolder->applyConfig([ - 'sortableFields' => 'fail', - ]); - } - - /** - * @dataProvider permissionProvider - * @param bool|null $allow - */ - public function testPermissionCheck($allow) - { - $resolver = function () { - return new ArrayList([ - new ArrayData(['Foo' => 'Bar']) - ]); - }; - $manager = new Manager(); - $manager->addType(new ObjectType([ - 'name' => 'testType', - 'fields' => [], - ])); - - $scaffolder = new ListQueryScaffolder('testQuery', 'testType', $resolver); - $scaffolder->setUsePagination(false); - if ($allow !== null) { - $scaffolder->setPermissionChecker(new FakeQueryPermissionChecker($allow)); - } - - $scaffolder->addToManager($manager); - $arr = $scaffolder->scaffold($manager); - $result = $arr['resolve'](null, [], ['currentUser' => null], new ResolveInfo([])); - $this->assertNotNull($result); - $expected = $allow === false ? 0 : 1; - $this->assertCount($expected, $result); - if ($allow !== false) { - $this->assertEquals('Bar', $result->first()->Foo); - } - } - - /** - * @return array - */ - public function permissionProvider() - { - return [ - [null], - [true], - [false] - ]; - } -} diff --git a/tests/Scaffolding/Scaffolders/MutationScaffolderTest.php b/tests/Scaffolding/Scaffolders/MutationScaffolderTest.php deleted file mode 100644 index d9f30c107..000000000 --- a/tests/Scaffolding/Scaffolders/MutationScaffolderTest.php +++ /dev/null @@ -1,50 +0,0 @@ -getMockBuilder(Manager::class) - ->setMethods(['addMutation','getType']) - ->getMock(); - $observer->method('getType') - ->will($this->returnValue(new ObjectType(['name' => 'test']))); - - $scaffolder = new MutationScaffolder('testMutation', 'test'); - $scaffolder->setDescription('My description'); - $scaffolder->addArgs(['Test' => 'String']); - $manager = new Manager(); - $manager->addType($o = new ObjectType([ - 'name' => 'test', - 'fields' => [], - ])); - $o->Test = true; - - $scaffold = $scaffolder->scaffold($manager); - - $this->assertEquals('testMutation', $scaffold['name']); - $this->assertEquals('My description', $scaffold['description']); - $this->assertArrayHasKey('Test', $scaffold['args']); - $this->assertTrue(is_callable($scaffold['resolve'])); - $this->assertTrue($scaffold['type']->Test); - - $observer->expects($this->once()) - ->method('addMutation') - ->with( - function ($arg) use ($scaffold) { - return $arg() === $scaffold; - }, - $this->equalTo('testMutation') - ); - - $scaffolder->addToManager($observer); - } -} diff --git a/tests/Scaffolding/Scaffolders/OperationScaffolderTest.php b/tests/Scaffolding/Scaffolders/OperationScaffolderTest.php deleted file mode 100644 index b3b2b7871..000000000 --- a/tests/Scaffolding/Scaffolders/OperationScaffolderTest.php +++ /dev/null @@ -1,249 +0,0 @@ -assertEquals( - Read::class, - OperationScaffolder::getClassFromIdentifier(SchemaScaffolder::READ) - ); - $this->assertEquals( - Update::class, - OperationScaffolder::getClassFromIdentifier(SchemaScaffolder::UPDATE) - ); - $this->assertEquals( - Delete::class, - OperationScaffolder::getClassFromIdentifier(SchemaScaffolder::DELETE) - ); - $this->assertEquals( - Create::class, - OperationScaffolder::getClassFromIdentifier(SchemaScaffolder::CREATE) - ); - } - - public function testOperationScaffolderArgs() - { - $scaffolder = new OperationScaffolderFake('testOperation', 'testType'); - - $this->assertEquals('testOperation', $scaffolder->getName()); - - $scaffolder->setName('changedOperation'); - $this->assertEquals('changedOperation', $scaffolder->getName()); - - $scaffolder->addArgs([ - 'One' => 'String', - 'Two' => 'Boolean', - ]); - $scaffolder->addArg( - 'One', - 'String', - 'One description', - 'One default' - ); - - $this->assertEquals([], array_diff( - $scaffolder->getArgs()->column('argName'), - ['One', 'Two'] - )); - - $argument = $scaffolder->getArgs()->find('argName', 'One'); - $this->assertInstanceOf(ArgumentScaffolder::class, $argument); - $arr = $argument->toArray(); - $this->assertEquals('One description', $arr['description']); - $this->assertEquals('One default', $arr['defaultValue']); - - $scaffolder->setArgDescriptions([ - 'One' => 'Foo', - 'Two' => 'Bar', - ]); - - $scaffolder->setArgDefaults([ - 'One' => 'Feijoa', - 'Two' => 'Kiwifruit', - ]); - - $scaffolder->setArgsRequired([ - 'One' => true, - 'Two' => false, - ]); - - $argument = $scaffolder->getArgs()->find('argName', 'One'); - $arr = $argument->toArray(); - $this->assertEquals('Foo', $arr['description']); - $this->assertEquals('Feijoa', $arr['defaultValue']); - $this->assertInstanceOf(NonNull::class, $arr['type']); - - $argument = $scaffolder->getArgs()->find('argName', 'Two'); - $arr = $argument->toArray(); - $this->assertEquals('Bar', $arr['description']); - $this->assertEquals('Kiwifruit', $arr['defaultValue']); - $this->assertInstanceof(BooleanType::class, $arr['type']); - - $scaffolder->setArgDescription('One', 'Tui') - ->setArgDefault('One', 'Moa'); - - $argument = $scaffolder->getArgs()->find('argName', 'One'); - $arr = $argument->toArray(); - $this->assertEquals('Tui', $arr['description']); - $this->assertEquals('Moa', $arr['defaultValue']); - - $scaffolder->removeArg('One'); - $this->assertEquals(['Two'], $scaffolder->getArgs()->column('argName')); - $scaffolder->addArg('Test', 'String'); - $scaffolder->removeArgs(['Two', 'Test']); - $this->assertFalse($scaffolder->getArgs()->exists()); - - $ex = null; - try { - $scaffolder->setArgDescription('Nothing', 'Test'); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceOf(InvalidArgumentException::class, $ex); - $this->assertRegExp('/Tried to set description/', $ex->getMessage()); - - $ex = null; - try { - $scaffolder->setArgDefault('Nothing', 'Test'); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceOf(InvalidArgumentException::class, $ex); - $this->assertRegExp('/Tried to set default/', $ex->getMessage()); - - $ex = null; - try { - $scaffolder->setArgRequired('Nothing', true); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceOf(InvalidArgumentException::class, $ex); - $this->assertRegExp('/Tried to make arg [A-Za-z0-9]+ required/', $ex->getMessage()); - } - - public function testOperationScaffolderResolver() - { - $scaffolder = new OperationScaffolderFake('testOperation', 'testType'); - - try { - $scaffolder->setResolver(function () { - }); - $scaffolder->setResolver(FakeResolver::class); - $scaffolder->setResolver(new FakeResolver()); - $success = true; - } catch (Exception $e) { - $success = false; - } - - $this->assertTrue($success); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/closures, instances of/'); - $scaffolder->setResolver('fail'); - } - - public function testOperationScaffolderAppliesConfig() - { - $scaffolder = new OperationScaffolderFake('testOperation', 'testType'); - - $scaffolder->applyConfig([ - 'args' => [ - 'One' => 'String', - 'Two' => [ - 'type' => 'String', - 'default' => 'Foo', - 'description' => 'Bar', - ], - ], - 'resolver' => FakeResolver::class, - 'name' => 'theGreatestOperation', - ]); - - $this->assertEquals([], array_diff( - $scaffolder->getArgs()->column('argName'), - ['One', 'Two'] - )); - - $this->assertEquals('theGreatestOperation', $scaffolder->getName()); - - $arg = $scaffolder->getArgs()->find('argName', 'Two'); - $this->assertInstanceof(ArgumentScaffolder::class, $arg); - $arr = $arg->toArray(); - $this->assertInstanceOf(StringType::class, $arr['type']); - - $ex = null; - try { - $scaffolder->applyConfig([ - 'args' => 'fail', - ]); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceof(Exception::class, $ex); - $this->assertRegExp('/args must be an array/', $ex->getMessage()); - - $ex = null; - try { - $scaffolder->applyConfig([ - 'args' => [ - 'One' => [ - 'default' => 'Foo', - 'description' => 'Bar', - ], - ], - ]); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceof(Exception::class, $ex); - $this->assertRegExp('/must have a type/', $ex->getMessage()); - - $ex = null; - try { - $scaffolder->applyConfig([ - 'args' => [ - 'One' => false, - ], - ]); - } catch (Exception $e) { - $ex = $e; - } - - $this->assertInstanceof(Exception::class, $ex); - $this->assertRegExp('/should be mapped to a string or an array/', $ex->getMessage()); - } -} diff --git a/tests/Scaffolding/Scaffolders/SchemaScaffolderTest.php b/tests/Scaffolding/Scaffolders/SchemaScaffolderTest.php deleted file mode 100644 index 87052eb59..000000000 --- a/tests/Scaffolding/Scaffolders/SchemaScaffolderTest.php +++ /dev/null @@ -1,373 +0,0 @@ -type(DataObjectFake::class); - $type->Test = true; - $type2 = $scaffolder->type(DataObjectFake::class); - - $query = $scaffolder->query('testQuery', DataObjectFake::class); - $query->Test = true; - $query2 = $scaffolder->query('testQuery', DataObjectFake::class); - - $mutation = $scaffolder->mutation('testMutation', DataObjectFake::class); - $mutation->Test = true; - $mutation2 = $scaffolder->mutation('testMutation', DataObjectFake::class); - - $this->assertEquals(1, count($scaffolder->getTypes())); - $this->assertTrue($type2->Test); - - $this->assertEquals(1, $scaffolder->getQueries()->count()); - $this->assertTrue($query2->Test); - - $this->assertEquals(1, $scaffolder->getMutations()->count()); - $this->assertTrue($mutation2->Test); - - $scaffolder->removeQuery('testQuery'); - $this->assertEquals(0, $scaffolder->getQueries()->count()); - - $scaffolder->removeMutation('testMutation'); - $this->assertEquals(0, $scaffolder->getMutations()->count()); - } - - public function testSchemaScaffolderAddToManager() - { - Config::modify()->merge(FakePage::class, 'db', [ - 'TestPageField' => 'Varchar', - ]); - - $manager = new Manager(); - $scaffolder = (new SchemaScaffolder()) - ->type(FakeRedirectorPage::class) - ->addFields(['Created', 'TestPageField', 'RedirectionType']) - ->operation(SchemaScaffolder::CREATE) - ->setCloneable(true) - ->end() - ->operation(SchemaScaffolder::READ) - ->setCloneable(true) - ->end() - ->end() - ->type(DataObjectFake::class) - ->addFields(['Author']) - ->nestedQuery('Files') - ->end() - ->end() - ->query('testQuery', DataObjectFake::class) - ->end() - ->mutation('testMutation', DataObjectFake::class) - ->end(); - - $scaffolder->addToManager($manager); - $queries = $scaffolder->getQueries(); - $mutations = $scaffolder->getMutations(); - $types = $scaffolder->getTypes(); - - $classNames = array_map(function (DataObjectScaffolder $scaffold) { - return $scaffold->getDataObjectClass(); - }, $types); - - $expectedTypes = []; - $explicitTypes = [ - FakeRedirectorPage::class, - DataObjectFake::class, - Member::class, - File::class, - ]; - foreach ($explicitTypes as $className) { - $expectedTypes = array_merge( - [$className], - $expectedTypes, - StaticSchema::inst()->getDescendants($className), - StaticSchema::inst()->getAncestry($className) - ); - } - - sort($expectedTypes); - sort($classNames); - $this->assertEquals($expectedTypes, $classNames); - - $this->assertEquals( - ['Created', 'TestPageField', 'RedirectionType'], - $scaffolder->type(FakeRedirectorPage::class)->getFields()->column('Name') - ); - - $this->assertEquals( - ['Created', 'TestPageField'], - $scaffolder->type(FakePage::class)->getFields()->column('Name') - ); - - $this->assertEquals( - ['Created'], - $scaffolder->type(FakeSiteTree::class)->getFields()->column('Name') - ); - - $this->assertEquals('testQuery', $queries->first()->getName()); - $this->assertEquals('testMutation', $mutations->first()->getName()); - - $this->assertInstanceof( - Read::class, - $scaffolder->type(FakeRedirectorPage::class)->getOperations()->findByIdentifier(SchemaScaffolder::READ) - ); - $this->assertInstanceof( - Read::class, - $scaffolder->type(FakePage::class)->getOperations()->findByIdentifier(SchemaScaffolder::READ) - ); - $this->assertInstanceof( - Read::class, - $scaffolder->type(FakeSiteTree::class)->getOperations()->findByIdentifier(SchemaScaffolder::READ) - ); - - $this->assertInstanceof( - Create::class, - $scaffolder->type(FakeRedirectorPage::class)->getOperations()->findByIdentifier(SchemaScaffolder::CREATE) - ); - $this->assertInstanceof( - Create::class, - $scaffolder->type(FakePage::class)->getOperations()->findByIdentifier(SchemaScaffolder::CREATE) - ); - $this->assertInstanceof( - Create::class, - $scaffolder->type(FakeSiteTree::class)->getOperations()->findByIdentifier(SchemaScaffolder::CREATE) - ); - } - - public function testSchemaScaffolderCreateFromConfigThrowsIfBadTypes() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/"types" must be a map of class name to settings/'); - SchemaScaffolder::createFromConfig([ - 'types' => ['fail'], - ]); - } - - public function testSchemaScaffolderCreateFromConfigThrowsIfBadQueries() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/must be a map of operation name to settings/'); - SchemaScaffolder::createFromConfig([ - 'types' => [ - DataObjectFake::class => [ - 'fields' => '*', - ], - ], - 'queries' => ['fail'], - ]); - } - - public function testSchemaScaffolderCreateFromConfig() - { - $observer = $this->getMockBuilder(SchemaScaffolder::class) - ->setMethods(['query', 'mutation', 'type']) - ->getMock(); - - $observer->expects($this->once()) - ->method('query') - ->will($this->returnValue(new ListQueryScaffolder('test', 'test'))); - - $observer->expects($this->once()) - ->method('mutation') - ->will($this->returnValue(new MutationScaffolder('test', 'test'))); - - $observer->expects($this->once()) - ->method('type') - ->willReturn( - new DataObjectScaffolder(DataObjectFake::class) - ); - - Injector::inst()->registerService($observer, SchemaScaffolder::class); - - SchemaScaffolder::createFromConfig([ - 'types' => [ - DataObjectFake::class => [ - 'fields' => ['MyField'], - ], - ], - 'queries' => [ - 'testQuery' => [ - 'type' => DataObjectFake::class, - 'resolver' => FakeResolver::class, - ], - ], - 'mutations' => [ - 'testMutation' => [ - 'type' => DataObjectFake::class, - 'resolver' => FakeResolver::class, - ], - ], - ]); - } - - public function testSchemaScaffolderFixedTypes() - { - Config::modify()->merge(SchemaScaffolder::class, 'fixed_types', [FakeInt::class]); - Config::modify()->merge(FakeInt::class, 'graphql_type', [ - 'FieldOne' => 'String', - 'FieldTwo' => 'Int' - ]); - $typeName = StaticSchema::inst()->typeName(FakeInt::class); - $manager = new Manager(); - (new SchemaScaffolder())->addToManager($manager); - $this->assertTrue($manager->hasType($typeName)); - } - - public function testSchemaScaffolderFixedTypeMustBeViewableData() - { - Config::modify()->merge(SchemaScaffolder::class, 'fixed_types', ['stdclass']); - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Cannot auto register/'); - (new SchemaScaffolder())->addToManager(new Manager()); - } - - public function testSchemaScaffolderFixedTypeMustHaveTypeCreatorExtension() - { - Config::modify()->merge(SchemaScaffolder::class, 'fixed_types', [ArrayList::class]); - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Cannot auto register/'); - (new SchemaScaffolder())->addToManager(new Manager()); - } - - public function testSchemaScaffolderFixedTypeMustBeAnArray() - { - Config::modify()->merge(SchemaScaffolder::class, 'fixed_types', 'fail'); - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/must be an array/'); - (new SchemaScaffolder())->addToManager(new Manager()); - } - - public function testUnionInheritanceForTypes() - { - $scaffolder = (new SchemaScaffolder()) - ->type(FakeRedirectorPage::class) - ->addFields(['Title', 'RedirectionType']) - ->operation(SchemaScaffolder::READ) - ->setName('READ') - ->setCloneable(true) - ->end() - ->operation(SchemaScaffolder::DELETE) - ->setName('DELETE') - ->setCloneable(true) - ->end() - ->end(); - $scaffolder->addToManager($manager = new Manager()); - - $inheritanceTypeName = StaticSchema::inst() - ->inheritanceTypeNameForDataObject(FakeRedirectorPage::class); - $normalTypeName = StaticSchema::inst() - ->typeNameForDataObject(FakeRedirectorPage::class); - - $this->assertFalse($manager->hasType($inheritanceTypeName)); - $this->assertTrue($manager->hasType($normalTypeName)); - - $this->assertNotNull($manager->getQuery('READ')); - $this->assertNotNull($manager->getMutation('DELETE')); - - /** @var ObjectType $type */ - $type = $manager->getType($normalTypeName); - $fields = $type->getFields(); - $this->assertArrayHasKey('Title', $fields); - $this->assertArrayHasKey('RedirectionType', $fields); - $ancestors = StaticSchema::inst()->getAncestry(FakeRedirectorPage::class); - - foreach ($ancestors as $ancestor) { - $inheritanceTypeName = StaticSchema::inst() - ->inheritanceTypeNameForDataObject($ancestor); - $normalTypeName = StaticSchema::inst() - ->typeNameForDataObject($ancestor); - - $this->assertTrue($manager->hasType($inheritanceTypeName)); - /* @var UnionType $type */ - $type = $manager->getType($inheritanceTypeName); - $numDescendants = count(StaticSchema::inst()->getDescendants($ancestor)); - $this->assertCount($numDescendants + 1, $type->getTypes()); - $this->assertTrue($manager->hasType($normalTypeName)); - /* @var ObjectType $type */ - $type = $manager->getType($normalTypeName); - $this->assertArrayHasKey('Title', $type->getFields()); - - $read = new Read($ancestor); - $delete = new Delete($ancestor); - - $this->assertNotNull($manager->getQuery($read->getName())); - $this->assertNotNull($manager->getMutation($delete->getName())); - } - } - - public function testUnionInheritanceForFields() - { - $scaffolder = (new SchemaScaffolder()) - ->type(DataObjectFake::class) - ->addFields(['MyField', 'Author']) - ->nestedQuery('Files') - ->end() - ->end(); - $scaffolder->addToManager($manager = new Manager()); - $inheritanceTypeName = StaticSchema::inst() - ->inheritanceTypeNameForDataObject(Member::class); - $normalTypeName = StaticSchema::inst() - ->typeNameForDataObject(Member::class); - - $this->assertTrue($manager->hasType($inheritanceTypeName)); - $this->assertTrue($manager->hasType($normalTypeName)); - - /* @var UnionType $union */ - $union = $manager->getType($inheritanceTypeName); - $descendants = StaticSchema::inst()->getDescendants(Member::class); - $this->assertCount(count($descendants) + 1, $union->getTypes()); - $inheritanceTypeName = StaticSchema::inst() - ->inheritanceTypeNameForDataObject(File::class); - $normalTypeName = StaticSchema::inst() - ->typeNameForDataObject(File::class); - - $this->assertTrue($manager->hasType($inheritanceTypeName)); - $this->assertTrue($manager->hasType($normalTypeName)); - - $union = $manager->getType($inheritanceTypeName); - $descendants = StaticSchema::inst()->getDescendants(File::class); - $this->assertCount(count($descendants) + 1, $union->getTypes()); - } -} diff --git a/tests/Scaffolding/Scaffolders/UnionScaffolderTest.php b/tests/Scaffolding/Scaffolders/UnionScaffolderTest.php deleted file mode 100644 index 42b15dc92..000000000 --- a/tests/Scaffolding/Scaffolders/UnionScaffolderTest.php +++ /dev/null @@ -1,71 +0,0 @@ -addFields(['RedirectionType']); - $scaffolder1->addToManager($manager); - - $scaffolder2 = new DataObjectScaffolder(FakeSiteTree::class); - $scaffolder2->addFields(['Title']); - $scaffolder2->addToManager($manager); - - $scaffolder = new UnionScaffolder('test', [ - $scaffolder1->getTypeName(), - $scaffolder2->getTypeName() - ]); - - $unionType = $scaffolder->scaffold($manager); - $types = $unionType->getTypes(); - - $this->assertEquals($scaffolder1->getTypeName(), $types[0]->config['name']); - $this->assertEquals($scaffolder2->getTypeName(), $types[1]->config['name']); - - $fakeRedirector = new FakeRedirectorPage(); - $result = $unionType->resolveType($fakeRedirector, [], new ResolveInfo([])); - //$result = $typeResolver(new FakeRedirectorPage()); - - $this->assertEquals($scaffolder1->getTypeName(), $result->config['name']); - - $result = $unionType->resolveType(new FakeSiteTree(), [], new ResolveInfo([])); - $this->assertEquals($scaffolder2->getTypeName(), $result->config['name']); - - // FakePage was never added. Should fall back on the parent type (FakeSiteTree) - $result = $unionType->resolveType(new FakePage(), [], new ResolveInfo([])); - $this->assertEquals($scaffolder2->getTypeName(), $result->config['name']); - - $ex = null; - try { - $unionType->resolveType(new Manager(), [], new ResolveInfo([])); - } catch (Exception $e) { - $ex = $e->getMessage(); - } - - $this->assertRegExp('/not a DataObject/', $ex); - - $ex = null; - try { - $unionType->resolveType(new RestrictedDataObjectFake(), [], new ResolveInfo([])); - } catch (Exception $e) { - $ex = $e->getMessage(); - } - - $this->assertRegExp('/no type defined/', $ex); - } -} diff --git a/tests/Scaffolding/StaticSchemaTest.php b/tests/Scaffolding/StaticSchemaTest.php deleted file mode 100644 index 8ef33d862..000000000 --- a/tests/Scaffolding/StaticSchemaTest.php +++ /dev/null @@ -1,217 +0,0 @@ - 'testType', - ]; - $schema = new StaticSchema(); - $schema->setTypeNames($typeNames); - $typename = $schema->typeNameForDataObject(DataObjectFake::class); - $this->assertEquals('testType', $typename); - $typename = $schema->typeNameForDataObject(FakePage::class); - $this->assertEquals('SilverStripeFakePage', $typename); - - $typename = $schema->typeNameForDataObject('UnNamespacedClass'); - $this->assertEquals('UnNamespacedClass', $typename); - } - - public function testEnsureDataObject() - { - $this->expectException(InvalidArgumentException::class); - $schema = new StaticSchema(); - $schema->setTypeNames(['fail' => 'fail']); - } - - public function testEnsureUnique() - { - $this->expectException(InvalidArgumentException::class); - $typeNames = [ - DataObjectFake::class => 'test1', - FakePage::class => 'test2', - FakeRedirectorPage::class => 'test1', - ]; - $schema = new StaticSchema(); - $schema->setTypeNames($typeNames); - } - - public function testEnsureAssoc() - { - $this->expectException(InvalidArgumentException::class); - $typeNames = [ - 'test1', - 'test2', - ]; - $schema = new StaticSchema(); - $schema->setTypeNames($typeNames); - } - - public function testTypeName() - { - $schema = new StaticSchema(); - $this->assertEquals('NicelyFormatted_Type', $schema->typeName('Nicely Formatted/Type')); - } - - public function testIsValidFieldName() - { - $schema = new StaticSchema(); - $fake = new DataObjectFake(); - $this->assertTrue($schema->isValidFieldName($fake, 'MyField')); - $this->assertTrue($schema->isValidFieldName($fake, 'CustomGetter')); - $this->assertFalse($schema->isValidFieldName($fake, 'fail')); - } - - public function testLoadsFromConfig() - { - $config = [ - 'typeNames' => [ - DataObjectFake::class => 'testType', - ] - ]; - Manager::create()->applyConfig($config); - $this->assertEquals('testType', StaticSchema::inst()->typeNameForDataObject(DataObjectFake::class)); - StaticSchema::inst()->setTypeNames([ - DataObjectFake::class => 'otherTestType' - ]); - $this->assertEquals('otherTestType', StaticSchema::inst()->typeNameForDataObject(DataObjectFake::class)); - } - - public function testLoadSchemaName() - { - $config = [ - 'schema1' => [ - 'typeNames' => [ - DataObjectFake::class => 'testType1', - ], - ], - 'schema2' => [], - 'schema3' => [ - 'typeNames' => [ - DataObjectFake::class => 'testType3', - ] - ] - ]; - $inst = StaticSchema::inst(); - Config::modify()->set(Manager::class, 'schemas', $config); - $this->assertEquals( - 'SilverStripeDataObjectFake', - $inst->typeNameForDataObject(DataObjectFake::class) - ); - $inst->load('schema1'); - $this->assertEquals( - 'testType1', - $inst->typeNameForDataObject(DataObjectFake::class) - ); - $inst->load('schema2'); - $this->assertEquals( - 'SilverStripeDataObjectFake', - $inst->typeNameForDataObject(DataObjectFake::class) - ); - $inst->load('schema3'); - $this->assertEquals( - 'testType3', - $inst->typeNameForDataObject(DataObjectFake::class) - ); - $inst->load('notASchema'); - $this->assertEquals( - 'SilverStripeDataObjectFake', - $inst->typeNameForDataObject(DataObjectFake::class) - ); - } - - public function testInheritanceTypeNames() - { - Config::modify()->set(StaticSchema::class, 'inheritanceTypeSuffix', 'MySuffix'); - $schema = new StaticSchema(); - $type = $schema->typeNameForDataObject(FakeSiteTree::class); - $union = $schema->inheritanceTypeNameForDataObject(FakeSiteTree::class); - $this->assertEquals($type . 'MySuffix', $union); - $union = $schema->inheritanceTypeNameForType($type); - $this->assertEquals($type . 'MySuffix', $union); - } - - public function testAncestry() - { - $ancestry = StaticSchema::inst()->getAncestry(FakeRedirectorPage::class); - $this->assertCount(2, $ancestry); - $this->assertContains(FakePage::class, $ancestry); - $this->assertContains(FakeSiteTree::class, $ancestry); - $ancestry = StaticSchema::inst()->getAncestry(FakeSiteTree::class); - $this->assertCount(0, $ancestry); - } - - public function testDescendants() - { - $descendants = StaticSchema::inst()->getDescendants(FakeSiteTree::class); - $this->assertCount(2, $descendants); - $this->assertContains(FakePage::class, $descendants); - $this->assertContains(FakeRedirectorPage::class, $descendants); - $descendants = StaticSchema::inst()->getDescendants(FakeRedirectorPage::class); - $this->assertCount(0, $descendants); - } - - public function testFetchFromManager() - { - $manager = new Manager(); - $typeName = StaticSchema::inst()->typeNameForDataObject(FakeSiteTree::class); - $inheritedTypeName = StaticSchema::inst()->inheritanceTypeNameForDataObject(FakeSiteTree::class); - $singleType = new ObjectType(['name' => $typeName]); - $unionType = new ObjectType(['name' => $inheritedTypeName]); - $manager->addType($singleType); - $manager->addType($unionType); - - $result = StaticSchema::inst() - ->fetchFromManager(FakeSiteTree::class, $manager, StaticSchema::PREFER_UNION); - $this->assertSame($result, $unionType); - - $result = StaticSchema::inst() - ->fetchFromManager(FakeSiteTree::class, $manager, StaticSchema::PREFER_SINGLE); - $this->assertSame($result, $singleType); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/illegal mode/'); - StaticSchema::inst() - ->fetchFromManager(FakeSiteTree::class, $manager, 'fail'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/could not be resolved/'); - StaticSchema::inst() - ->fetchFromManager('fail', $manager); - } - - public function testInstance() - { - $inst1 = StaticSchema::inst(); - $inst2 = StaticSchema::inst(); - - $this->assertSame($inst1, $inst2); - - $new = new StaticSchema(); - StaticSchema::setInstance($new); - $this->assertSame($new, StaticSchema::inst()); - - StaticSchema::reset(); - $this->assertNotSame($new, StaticSchema::inst()); - } -} diff --git a/tests/Scaffolding/Util/ArrayTypeParserTest.php b/tests/Scaffolding/Util/ArrayTypeParserTest.php deleted file mode 100644 index 71b64cb5a..000000000 --- a/tests/Scaffolding/Util/ArrayTypeParserTest.php +++ /dev/null @@ -1,54 +0,0 @@ - 'String']); - $this->assertEquals('test', $parser->getType()->name); - } - - public function testGetType() - { - $parser = new ArrayTypeParser('test', [ - 'FieldOne' => 'String', - 'FieldTwo' => 'Int' - ]); - $type = $parser->getType(); - - $this->assertInstanceOf(ObjectType::class, $type); - - $this->assertInstanceOf(FieldDefinition::class, $type->getField('FieldOne')); - $this->assertInstanceOf(FieldDefinition::class, $type->getField('FieldTwo')); - - $this->assertInstanceOf(StringType::class, $type->getField('FieldOne')->getType()); - $this->assertInstanceOf(IntType::class, $type->getField('FieldTwo')->getType()); - } - - public function testInvalidConstructorNotArray() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/second parameter must be an associative array/'); - new ArrayTypeParser('test', 'String'); - } - - public function testInvalidConstructorNotAssociative() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/second parameter must be an associative array/'); - new ArrayTypeParser('test', ['oranges', 'apples']); - } -} diff --git a/tests/Scaffolding/Util/OperationListTest.php b/tests/Scaffolding/Util/OperationListTest.php deleted file mode 100644 index c9351f33a..000000000 --- a/tests/Scaffolding/Util/OperationListTest.php +++ /dev/null @@ -1,35 +0,0 @@ -push(new MutationScaffolder('myMutation1', 'test1')); - $list->push(new MutationScaffolder('myMutation2', 'test2')); - - $this->assertInstanceOf( - MutationScaffolder::class, - $list->findByName('myMutation1') - ); - $this->assertFalse($list->findByName('myMutation3')); - - $list->removeByName('myMutation2'); - $this->assertEquals(1, $list->count()); - - $list->removeByName('nothing'); - $this->assertEquals(1, $list->count()); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/only accepts instances of/'); - $list->push(new OperationList()); - } -} diff --git a/tests/Scaffolding/Util/StringTypeParserTest.php b/tests/Scaffolding/Util/StringTypeParserTest.php deleted file mode 100644 index 1069b525f..000000000 --- a/tests/Scaffolding/Util/StringTypeParserTest.php +++ /dev/null @@ -1,75 +0,0 @@ -assertTrue($parser->isRequired()); - $this->assertEquals('String', $parser->getName()); - $this->assertEquals('Test', $parser->getDefaultValue()); - $this->assertTrue(is_string($parser->getDefaultValue())); - - $parser = new StringTypeParser('String! (Test)'); - $this->assertTrue($parser->isRequired()); - $this->assertEquals('String', $parser->getName()); - $this->assertEquals('Test', $parser->getDefaultValue()); - - $parser = new StringTypeParser('Int!'); - $this->assertTrue($parser->isRequired()); - $this->assertEquals('Int', $parser->getName()); - $this->assertNull($parser->getDefaultValue()); - - $parser = new StringTypeParser('Int!(23)'); - $this->assertTrue($parser->isRequired()); - $this->assertEquals('Int', $parser->getName()); - $this->assertEquals('23', $parser->getDefaultValue()); - $this->assertTrue(is_int($parser->getDefaultValue())); - - $parser = new StringTypeParser('Boolean'); - $this->assertFalse($parser->isRequired()); - $this->assertEquals('Boolean', $parser->getName()); - $this->assertNull($parser->getDefaultValue()); - - $parser = new StringTypeParser('Boolean(1)'); - $this->assertFalse($parser->isRequired()); - $this->assertEquals('Boolean', $parser->getName()); - $this->assertEquals('1', $parser->getDefaultValue()); - $this->assertTrue(is_bool($parser->getDefaultValue())); - - $parser = new StringTypeParser('String!(Test)'); - $this->assertInstanceOf(StringType::class, $parser->getType()); - $this->assertEquals('Test', $parser->getDefaultValue()); - } - - public function testTypeInvalid() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/Invalid argument/'); - new StringTypeParser(' ... Nothing'); - } - - public function testTypeNotAString() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/must be passed a string/'); - new StringTypeParser(['fail']); - } - - public function testNonInternalType() - { - $type = new StringTypeParser('MyType!(bob)'); - $this->assertNull($type->getType()); - $this->assertEquals('MyType', $type->getType(false)); - } -} diff --git a/tests/TypeCreatorTest.php b/tests/TypeCreatorTest.php deleted file mode 100644 index d7d0314e0..000000000 --- a/tests/TypeCreatorTest.php +++ /dev/null @@ -1,152 +0,0 @@ -getTypeCreatorMock(); - $mock->method('fields')->willReturn([ - 'ID' => [ - 'type' => Type::nonNull(Type::id()), - ], - ]); - $fields = $mock->getFields(); - $this->assertArrayHasKey('ID', $fields); - } - - public function testToArray() - { - $mock = $this->getTypeCreatorMock(); - $actual = $mock->toArray(); - $this->assertArrayHasKey('fields', $actual); - $fields = $actual['fields'](); - $this->assertArrayHasKey('ID', $fields); - } - - public function testToType() - { - $mock = $this->getTypeCreatorMock(); - $actual = $mock->toType(); - $this->assertInstanceOf(Type::class, $actual); - } - - public function testToTypeWithInputObject() - { - $mock = $this->getTypeCreatorMock(['isInputObject']); - $mock->method('isInputObject')->willReturn(true); - $actual = $mock->toType(); - $this->assertInstanceOf(InputObjectType::class, $actual); - } - - public function testGetAttributes() - { - $mock = $this->getTypeCreatorMock(); - $actual = $mock->getAttributes(); - $this->assertArrayHasKey('fields', $actual); - $fields = $actual['fields'](); - $this->assertArrayHasKey('ID', $fields); - } - - public function testGetFieldsUsesResolveConfig() - { - $mock = $this->getTypeCreatorMock(['resolveFieldAField', 'fields']); - $mock->method('fields')->willReturn([ - 'fieldA' => [ - 'type' => Type::string(), - 'resolve' => function () { - return 'config'; - }, - ], - 'fieldB' => [ - 'type' => Type::string(), - ], - ]); - $mock->method('resolveFieldAField') - ->willReturn('method'); - - $fields = $mock->getFields(); - $this->assertArrayHasKey('fieldA', $fields); - $this->assertArrayHasKey('fieldB', $fields); - $this->assertArrayHasKey('resolve', $fields['fieldA']); - $this->assertArrayNotHasKey('resolve', $fields['fieldB']); - $this->assertEquals('config', $fields['fieldA']['resolve']()); - } - - public function testGetFieldsUsesResolverMethod() - { - $mock = $this->getTypeCreatorMock(['resolveFieldAField', 'fields']); - $mock->method('fields')->willReturn([ - 'fieldA' => [ - 'type' => Type::string(), - ], - 'fieldB' => [ - 'type' => Type::string(), - ], - ]); - $mock->method('resolveFieldAField') - ->willReturn('resolved'); - - $fields = $mock->getFields(); - $this->assertArrayHasKey('fieldA', $fields); - $this->assertArrayHasKey('fieldB', $fields); - $this->assertArrayHasKey('resolve', $fields['fieldA']); - $this->assertArrayNotHasKey('resolve', $fields['fieldB']); - } - - public function testGetFieldsUsesAllFieldsResolverMethod() - { - $mock = $this->getTypeCreatorMock(['resolveField', 'fields']); - $mock->method('fields')->willReturn([ - 'fieldA' => [ - 'type' => Type::string(), - ], - 'fieldB' => [ - 'type' => Type::string(), - ], - ]); - $mock->method('resolveField') - ->willReturn('resolved'); - - $fields = $mock->getFields(); - $this->assertArrayHasKey('fieldA', $fields); - $this->assertArrayHasKey('fieldB', $fields); - $this->assertArrayHasKey('resolve', $fields['fieldA']); - $this->assertArrayHasKey('resolve', $fields['fieldB']); - $this->assertEquals('resolved', $fields['fieldA']['resolve']()); - $this->assertEquals('resolved', $fields['fieldB']['resolve']()); - } - - /** - * @param array $extraMethods - * @return PHPUnit_Framework_MockObject_MockObject|TypeCreator - */ - protected function getTypeCreatorMock($extraMethods = []) - { - $mock = $this->getMockBuilder(TypeCreator::class) - ->setMethods(array_unique(array_merge(['fields', 'attributes'], $extraMethods))) - ->getMock(); - - if (!in_array('fields', $extraMethods)) { - $mock->method('fields')->willReturn([ - 'ID' => [ - 'type' => Type::nonNull(Type::id()), - ], - ]); - } - - if (!in_array('attributes', $extraMethods)) { - $mock->method('attributes') - ->willReturn(['name' => 'myType']); - } - - return $mock; - } -} diff --git a/tests/Util/CaseInsensitiveFieldAccessorTest.php b/tests/Util/CaseInsensitiveFieldAccessorTest.php deleted file mode 100644 index b180d1fb8..000000000 --- a/tests/Util/CaseInsensitiveFieldAccessorTest.php +++ /dev/null @@ -1,107 +0,0 @@ - 'myValue' - ]); - $mapper = new CaseInsensitiveFieldAccessor(); - $this->assertEquals('myValue', $mapper->getValue($fake, 'MyField')); - } - - public function testGetValueWithDifferentCasing() - { - $fake = new DataObjectFake([ - 'MyField' => 'myValue' - ]); - $mapper = new CaseInsensitiveFieldAccessor(); - $this->assertEquals('myValue', $mapper->getValue($fake, 'myfield')); - } - - public function testGetValueWithCustomGetter() - { - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $this->assertEquals('customGetterValue', $mapper->getValue($fake, 'customGetter')); - } - - public function testGetValueWithMethod() - { - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $this->assertEquals('customMethodValue', $mapper->getValue($fake, 'customMethod')); - } - - public function testGetValueWithUnknownFieldThrowsException() - { - $this->expectException(\InvalidArgumentException::class); - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->getValue($fake, 'unknownField'); - } - - public function testGetValueWithCustomOpts() - { - $this->expectException(\InvalidArgumentException::class); - $fake = new DataObjectFake(['MyField' => 'myValue']); - $mapper = new CaseInsensitiveFieldAccessor(); - $opts = [ - // only check for methods - CaseInsensitiveFieldAccessor::HAS_FIELD => false, - CaseInsensitiveFieldAccessor::DATAOBJECT => false, - ]; - $mapper->getValue($fake, 'MyField', $opts); - } - - public function testSetValueWithOriginalCasing() - { - $fake = new DataObjectFake([ - 'MyField' => 'myValue' - ]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->setValue($fake, 'MyField', 'myNewValue'); - $this->assertEquals('myNewValue', $fake->MyField); - } - - public function testSetValueWithDifferentCasing() - { - $fake = new DataObjectFake([ - 'MyField' => 'myValue' - ]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->setValue($fake, 'myfield', 'myNewValue'); - $this->assertEquals('myNewValue', $fake->MyField); - } - - public function testSetValueWithCustomGetter() - { - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->setValue($fake, 'customsetterfield', 'myNewValue'); - $this->assertEquals('myNewValue', $fake->customSetterFieldResult); - } - - public function testSetValueWithMethod() - { - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->setValue($fake, 'customsettermethod', 'myNewValue'); - $this->assertEquals('myNewValue', $fake->customSetterMethodResult); - } - - public function testSetValueWithUnknownFieldThrowsException() - { - $this->expectException(\InvalidArgumentException::class); - $fake = new DataObjectFake([]); - $mapper = new CaseInsensitiveFieldAccessor(); - $mapper->setValue($fake, 'unknownField', true); - } -}