diff --git a/_config/graphql.yml b/_config/graphql.yml deleted file mode 100644 index 73d095e8..00000000 --- a/_config/graphql.yml +++ /dev/null @@ -1,31 +0,0 @@ ---- -Name: versioned-graphql -Only: - moduleexists: 'silverstripe/graphql' ---- -SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Read: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\ReadExtension -SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Delete: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\DeleteExtension -SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\ReadOne: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\ReadExtension -SilverStripe\GraphQL\Scaffolding\Scaffolders\SchemaScaffolder: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\SchemaScaffolderExtension -SilverStripe\GraphQL\Scaffolding\Scaffolders\DataObjectScaffolder: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\DataObjectScaffolderExtension - -# Register the versioned operations -SilverStripe\GraphQL\Scaffolding\Scaffolders\OperationScaffolder: - operations: - copyToStage: SilverStripe\Versioned\GraphQL\Operations\CopyToStage - publish: SilverStripe\Versioned\GraphQL\Operations\Publish - unpublish: SilverStripe\Versioned\GraphQL\Operations\Unpublish - rollback: SilverStripe\Versioned\GraphQL\Operations\Rollback -SilverStripe\GraphQL\Manager: - extensions: - - SilverStripe\Versioned\GraphQL\Extensions\ManagerExtension diff --git a/_config/graphql_operations.yml b/_config/graphql_operations.yml new file mode 100644 index 00000000..2ae99666 --- /dev/null +++ b/_config/graphql_operations.yml @@ -0,0 +1,9 @@ +--- +Name: versioned-graphql-dataobject +--- +SilverStripe\GraphQL\Schema\DataObject\DataObjectModel: + operations: + copyToStage: 'SilverStripe\Versioned\GraphQL\Operations\CopyToStageCreator' + publish: 'SilverStripe\Versioned\GraphQL\Operations\PublishCreator' + unpublish: 'SilverStripe\Versioned\GraphQL\Operations\UnpublishCreator' + rollback: 'SilverStripe\Versioned\GraphQL\Operations\RollbackCreator' diff --git a/_config/graphql_plugins.yml b/_config/graphql_plugins.yml new file mode 100644 index 00000000..ef05e972 --- /dev/null +++ b/_config/graphql_plugins.yml @@ -0,0 +1,24 @@ +--- +Name: versioned-graphql-plugins +Only: + moduleexists: 'silverstripe/graphql' +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\GraphQL\Schema\Registry\PluginRegistry: + constructor: + versionedDataobject: '%$SilverStripe\Versioned\GraphQL\Plugins\VersionedDataObject' + unpublishOnDelete: '%$SilverStripe\Versioned\GraphQL\Plugins\UnpublishOnDelete' + readVersionedDataObject: '%$SilverStripe\Versioned\GraphQL\Plugins\VersionedRead' + +# Ensure all dataobject types get the versioned plugin +SilverStripe\GraphQL\Schema\DataObject\DataObjectModel: + default_plugins: + versionedDataObject: true + +SilverStripe\GraphQL\Schema\DataObject\ReadCreator: + default_plugins: + readVersionedDataObject: true + +SilverStripe\GraphQL\Schema\DataObject\DeleteCreator: + default_plugins: + unpublishOnDelete: true diff --git a/_config/graphql_schema.yml b/_config/graphql_schema.yml new file mode 100644 index 00000000..627a77bc --- /dev/null +++ b/_config/graphql_schema.yml @@ -0,0 +1,81 @@ +--- +Name: versioned-graphql-schema +Only: + moduleexists: 'silverstripe/graphql' +--- +SilverStripe\GraphQL\Schema\Schema: + schemas: + '*': + enums: + VersionedStage: + description: The stage to read from or write to + values: + DRAFT: + value: Stage + description: The draft stage + LIVE: + value: Live + description: The live stage + VersionedQueryMode: + description: The versioned mode to use + values: + ARCHIVE: + value: archive + description: Read from a specific date of the archive + LATEST: + value: latest_versions + description: Read the latest version + DRAFT: + value: Stage + description: Read from the draft stage + LIVE: + value: Live + description: Read from the live stage + STATUS: + value: status + description: Read only records with a specific status + VERSION: + value: version + description: Read a specific version + VersionedStatus: + description: The stage to read from or write to + values: + PUBLISHED: + value: published + description: Only published records + DRAFT: + value: draft + description: Only draft records + ARCHIVED: + value: archived + description: Only records that have been archived + MODIFIED: + value: modified + description: Only records that have unpublished changes + types: + CopyToStageInputType: + input: true + fields: + id: + type: ID! + description: The ID of the record to copy + fromVersion: + type: Int + description: The source version number to copy + fromStage: + type: VersionedStage + description: The source stage to copy + toStage: + type: VersionedStage + description: The destination state to copy to + VersionedInputType: + input: true + fields: + mode: VersionedQueryMode = Stage + archiveDate: + type: String + description: The date to use for archive + status: + type: '[VersionedStatus]' + description: If mode is STATUS, specify which versioned statuses + version: Int diff --git a/src/GraphQL/Extensions/DataObjectScaffolderExtension.php b/src/GraphQL/Extensions/DataObjectScaffolderExtension.php deleted file mode 100644 index 1434eebc..00000000 --- a/src/GraphQL/Extensions/DataObjectScaffolderExtension.php +++ /dev/null @@ -1,97 +0,0 @@ -owner; - $memberType = StaticSchema::inst()->typeNameForDataObject(Member::class); - $instance = $owner->getDataObjectInstance(); - $class = $owner->getDataObjectClass(); - if (!$instance->hasExtension(Versioned::class)) { - return; - } - /* @var ObjectType $rawType */ - $rawType = $owner->scaffold($manager); - - $versionName = $this->createVersionedTypeName($class); - $coreFieldsFn = $rawType->config['fields']; - // Create the "version" type for this dataobject. Takes the original fields - // and augments them with the Versioned_Version specific fields - $versionType = new ObjectType([ - 'name' => $versionName, - 'fields' => function () use ($coreFieldsFn, $manager, $memberType) { - $coreFields = $coreFieldsFn(); - $versionFields = [ - 'Author' => [ - 'type' => $manager->getType($memberType), - 'resolve' => function ($obj) { - return $obj->Author(); - } - ], - 'Publisher' => [ - 'type' => $manager->getType($memberType), - 'resolve' => function ($obj) { - return $obj->Publisher(); - } - ], - 'Published' => [ - 'type' => Type::boolean(), - 'resolve' => function ($obj) { - return $obj->WasPublished; - } - ], - 'LiveVersion' => [ - 'type' => Type::boolean(), - 'resolve' => function ($obj) { - return $obj->isLiveVersion(); - } - ], - 'LatestDraftVersion' => [ - 'type' => Type::boolean(), - 'resolve' => function ($obj) { - return $obj->isLatestDraftVersion(); - } - ], - ]; - // Remove this recursive madness. - unset($coreFields['Versions']); - - return array_merge($coreFields, $versionFields); - } - ]); - - $manager->addType($versionType, $versionName); - - // With the version type in the manager now, add the versioning fields to the dataobject type - $owner - ->addFields(['Version']) - ->nestedQuery('Versions', new ReadVersions($class, $versionName)); - } - - /** - * @param string $class - * @return string - */ - protected function createVersionedTypeName($class) - { - return StaticSchema::inst()->typeNameForDataObject($class).'Version'; - } -} diff --git a/src/GraphQL/Extensions/DeleteExtension.php b/src/GraphQL/Extensions/DeleteExtension.php deleted file mode 100644 index aaa62715..00000000 --- a/src/GraphQL/Extensions/DeleteExtension.php +++ /dev/null @@ -1,44 +0,0 @@ -hasExtension(Versioned::class) || !$object->isPublished()) { - continue; - } - - if (!$object->canUnpublish($context['currentUser'])) { - throw new Exception(sprintf( - 'Cannot unpublish %s with ID %s', - get_class($object), - $object->ID - )); - } - - $object->doUnpublish(); - } - } -} diff --git a/src/GraphQL/Extensions/ManagerExtension.php b/src/GraphQL/Extensions/ManagerExtension.php deleted file mode 100644 index e55d7133..00000000 --- a/src/GraphQL/Extensions/ManagerExtension.php +++ /dev/null @@ -1,31 +0,0 @@ -get(ApplyVersionFilters::class) - ->applyToList($list, $args['Versioning']); - } - - /** - * @param array $args - * @param Manager $manager - */ - public function updateArgs(&$args, Manager $manager) - { - $args['Versioning'] = [ - 'type' => $manager->getType('VersionedInputType'), - ]; - } -} diff --git a/src/GraphQL/Extensions/SchemaScaffolderExtension.php b/src/GraphQL/Extensions/SchemaScaffolderExtension.php deleted file mode 100644 index 305ac9db..00000000 --- a/src/GraphQL/Extensions/SchemaScaffolderExtension.php +++ /dev/null @@ -1,37 +0,0 @@ -typeNameForDataObject(Member::class); - if ($manager->hasType($memberType)) { - return; - } - - /* @var SchemaScaffolder $owner */ - $owner = $this->owner; - - foreach ($owner->getTypes() as $scaffold) { - if ($scaffold->getDataObjectInstance()->hasExtension(Versioned::class)) { - $owner->type(Member::class); - break; - } - } - } -} diff --git a/src/GraphQL/Operations/AbstractPublishOperationCreator.php b/src/GraphQL/Operations/AbstractPublishOperationCreator.php new file mode 100644 index 00000000..e3db6e07 --- /dev/null +++ b/src/GraphQL/Operations/AbstractPublishOperationCreator.php @@ -0,0 +1,81 @@ +getSourceClass(), Versioned::class)) { + return null; + } + + $defaultPlugins = $this->config()->get('default_plugins'); + $configPlugins = $config['plugins'] ?? []; + $plugins = array_merge($defaultPlugins, $configPlugins); + return ModelMutation::create($this->createOperationName($typeName)) + ->setPlugins($plugins) + ->setType($typeName) + ->setResolver([VersionedResolver::class, 'resolvePublishOperation']) + ->addResolverContext('action', $this->getAction()) + ->addResolverContext('dataClass', $model->getSourceClass()) + ->addArg('id', 'ID!'); + + } + + abstract protected function createOperationName(string $typeName): string; + + abstract protected function getAction(): string; +} diff --git a/src/GraphQL/Operations/CopyToStage.php b/src/GraphQL/Operations/CopyToStage.php deleted file mode 100644 index 0373239a..00000000 --- a/src/GraphQL/Operations/CopyToStage.php +++ /dev/null @@ -1,96 +0,0 @@ -getTypeName(); - - return 'copy'.ucfirst($typeName).'ToStage'; - } - - protected function createDefaultArgs(Manager $manager) - { - return [ - 'Input' => Type::nonNull($manager->getType('CopyToStageInputType')), - ]; - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $input = $args['Input']; - $id = $input['ID']; - $to = $input['ToStage']; - /** @var Versioned|DataObject $record */ - $record = null; - if (isset($input['FromVersion'])) { - $from = $input['FromVersion']; - $record = Versioned::get_version($this->getDataObjectClass(), $id, $from); - } elseif (isset($input['FromStage'])) { - $from = $input['FromStage']; - $record = Versioned::get_by_stage($this->getDataObjectClass(), $from)->byID($id); - } else { - throw new InvalidArgumentException('You must provide either a FromStage or FromVersion argument'); - } - if (!$record) { - throw new InvalidArgumentException("Record {$id} not found"); - } - - // Permission check object - $can = $to === Versioned::LIVE - ? $record->canPublish($context['currentUser']) - : $record->canEdit($context['currentUser']); - if (!$can) { - throw new InvalidArgumentException(sprintf( - 'Copying %s from %s to %s is not allowed', - $this->getTypeName(), - $from, - $to - )); - } - - /** @var DataObject|Versioned $record */ - $record->copyVersionToStage($from, $to); - return $record; - } -} diff --git a/src/GraphQL/Operations/CopyToStageCreator.php b/src/GraphQL/Operations/CopyToStageCreator.php new file mode 100644 index 00000000..438b9b3e --- /dev/null +++ b/src/GraphQL/Operations/CopyToStageCreator.php @@ -0,0 +1,68 @@ +getSourceClass(), Versioned::class)) { + return null; + } + + $defaultPlugins = $this->config()->get('default_plugins'); + $configPlugins = $config['plugins'] ?? []; + $plugins = array_merge($defaultPlugins, $configPlugins); + $mutationName = 'copy' . ucfirst($typeName) . 'ToStage'; + + return ModelMutation::create($model, $mutationName) + ->setType($typeName) + ->setPlugins($plugins) + ->setDefaultResolver([VersionedResolver::class, 'resolveCopyToStage']) + ->addResolverContext('dataClass', $model->getSourceClass()) + ->addArg('input', 'CopyToStageInputType!'); + } +} diff --git a/src/GraphQL/Operations/Publish.php b/src/GraphQL/Operations/Publish.php deleted file mode 100644 index 24537104..00000000 --- a/src/GraphQL/Operations/Publish.php +++ /dev/null @@ -1,54 +0,0 @@ -getTypeName()); - } - - /** - * @param DataObjectInterface $obj - */ - protected function doMutation(DataObjectInterface $obj) - { - /** @var RecursivePublishable $obj */ - $obj->publishRecursive(); - } - - /** - * @param DataObjectInterface $obj - * @param Member $member - * @return boolean - */ - protected function checkPermission(DataObjectInterface $obj, Member $member = null) - { - /** @var Versioned $obj */ - return $obj->canPublish($member); - } - - /** - * Set the stage for the read query - */ - protected function getReadingStage() - { - return Versioned::DRAFT; - } -} diff --git a/src/GraphQL/Operations/PublishCreator.php b/src/GraphQL/Operations/PublishCreator.php new file mode 100644 index 00000000..27788209 --- /dev/null +++ b/src/GraphQL/Operations/PublishCreator.php @@ -0,0 +1,34 @@ +createOperationName(); - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - $obj = Versioned::get_by_stage($this->getDataObjectClass(), $this->getReadingStage()) - ->byID($args['ID']); - if (!$obj) { - throw new Exception(sprintf( - '%s with ID %s not found', - $this->getDataObjectClass(), - $args['ID'] - )); - } - - if (!$this->checkPermission($obj, $context['currentUser'])) { - throw new Exception(sprintf( - 'Not allowed to change published state of 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; - } - - try { - DB::get_conn()->withTransaction(function () use ($obj) { - $this->doMutation($obj); - }); - } catch (ValidationException $e) { - throw new Exception( - 'Could not changed published state of %s. Got error: %s', - $this->getDataObjectClass(), - $e->getMessage() - ); - } - return $obj; - } - - /** - * Use a generated Input type, and require an ID. - * - * @param Manager $manager - * @return array - */ - protected function createDefaultArgs(Manager $manager) - { - return [ - 'ID' => [ - 'type' => Type::nonNull(Type::id()) - ], - ]; - } - - abstract protected function checkPermission(DataObjectInterface $obj, Member $member = null); - - abstract protected function doMutation(DataObjectInterface $obj); - - abstract protected function createOperationName(); - - abstract protected function getReadingStage(); -} diff --git a/src/GraphQL/Operations/ReadVersions.php b/src/GraphQL/Operations/ReadVersions.php deleted file mode 100644 index eb6957e4..00000000 --- a/src/GraphQL/Operations/ReadVersions.php +++ /dev/null @@ -1,62 +0,0 @@ -setDataObjectClass($dataObjectClass); - $operationName = 'read' . ucfirst($versionTypeName); - - // Allow clients to sort the versions list by Version ID - $this->addSortableFields(['Version']); - - parent::__construct($operationName, $versionTypeName, $this); - } - - public function resolve($object, array $args, $context, ResolveInfo $info) - { - /** @var DataObject|Versioned $object */ - if (!$object->hasExtension(Versioned::class)) { - throw new Exception(sprintf( - 'Types using the %s query scaffolder must have the Versioned extension applied. (See %s)', - __CLASS__, - $this->getDataObjectClass() - )); - } - if (!$object->canViewStage(Versioned::DRAFT, $context['currentUser'])) { - throw new Exception(sprintf( - 'Cannot view versions on %s', - $this->getDataObjectClass() - )); - } - - // Get all versions - $list = $object->VersionsList(); - - $this->extend('updateList', $list, $object, $args, $context, $info); - - return $list; - } -} diff --git a/src/GraphQL/Operations/Rollback.php b/src/GraphQL/Operations/Rollback.php deleted file mode 100644 index 21a3af06..00000000 --- a/src/GraphQL/Operations/Rollback.php +++ /dev/null @@ -1,99 +0,0 @@ -getTypeName(); - - return 'rollback' . ucfirst($typeName); - } - - /** - * @param Manager $manager - * @return array - */ - public function createDefaultArgs(Manager $manager) - { - return [ - 'ID' => [ - 'type' => Type::nonNull(Type::id()), - 'description' => 'The object ID that needs to be rolled back' - ], - 'ToVersion' => [ - 'type' => Type::nonNull(Type::int()), - 'description' => 'The version of the object that should be rolled back to' - ], - ]; - } - - /** - * Invoked by the Executor class to resolve this mutation / query - * @see Executor - * - * @param mixed $object - * @param array $args - * @param mixed $context - * @param ResolveInfo $info - * @return mixed - */ - public function resolve($object, array $args, $context, ResolveInfo $info) - { - // Get the args - $id = $args['ID']; - $rollbackVersion = $args['ToVersion']; - - // Pull the latest version of the record - /** @var Versioned|DataObject $record */ - $record = Versioned::get_latest_version($this->getDataObjectClass(), $id); - - // Assert permission - $user = $context['currentUser']; - if (!$record->canEdit($user)) { - throw new InvalidArgumentException('Current user does not have permission to roll back this resource'); - } - - // Perform the rollback - $record = $record->rollbackRecursive($rollbackVersion); - - return $record; - } -} diff --git a/src/GraphQL/Operations/RollbackCreator.php b/src/GraphQL/Operations/RollbackCreator.php new file mode 100644 index 00000000..b1de2eca --- /dev/null +++ b/src/GraphQL/Operations/RollbackCreator.php @@ -0,0 +1,77 @@ +getSourceClass(), Versioned::class)) { + return null; + } + + $defaultPlugins = $this->config()->get('default_plugins'); + $configPlugins = $config['plugins'] ?? []; + $plugins = array_merge($defaultPlugins, $configPlugins); + $mutationName = 'rollback' . ucfirst($typeName); + return ModelMutation::create($model, $mutationName) + ->setPlugins($plugins) + ->setType($typeName) + ->setResolver([VersionedResolver::class, 'resolveRollback']) + ->addResolverContext('dataClass', $model->getSourceClass()) + ->addArg('id', [ + 'type' => 'ID!', + 'description' => 'The object ID that needs to be rolled back' + ]) + ->addArg('toVersion', [ + 'type' => 'Int!', + 'description' => 'The version of the object that should be rolled back to', + ]); + } +} diff --git a/src/GraphQL/Operations/Unpublish.php b/src/GraphQL/Operations/Unpublish.php deleted file mode 100644 index 5a85fb77..00000000 --- a/src/GraphQL/Operations/Unpublish.php +++ /dev/null @@ -1,54 +0,0 @@ -getTypeName()); - } - - /** - * @param DataObjectInterface $obj - */ - protected function doMutation(DataObjectInterface $obj) - { - /** @var Versioned|DataObject $obj */ - $obj->doUnpublish(); - } - - /** - * @param DataObjectInterface $obj - * @param Member $member - * @return boolean - */ - protected function checkPermission(DataObjectInterface $obj, Member $member = null) - { - /** @var Versioned|DataObject $obj */ - return $obj->canUnpublish($member); - } - - /** - * Set the stage for the read query - */ - protected function getReadingStage() - { - return Versioned::LIVE; - } -} diff --git a/src/GraphQL/Operations/UnpublishCreator.php b/src/GraphQL/Operations/UnpublishCreator.php new file mode 100644 index 00000000..16c8ed4c --- /dev/null +++ b/src/GraphQL/Operations/UnpublishCreator.php @@ -0,0 +1,33 @@ +addResolverMiddleware( + [static::class, 'unpublishOnDelete'], + ['dataClass' => $mutation->getModel()->getSourceClass()] + ); + } + + /** + * @param array $context + * @return Closure + */ + public static function unpublishOnDelete(array $context) + { + $dataClass = $context['dataClass'] ?? null; + return function ($objects, array $args, array $context) use ($dataClass) { + if (!$dataClass) { + return; + } + if (!Extensible::has_extension($dataClass, Versioned::class)) { + return; + } + DB::get_conn()->withTransaction(function () use ($args, $context, $dataClass) { + // Build list to filter + $objects = DataList::create($dataClass) + ->byIDs($args['ids']); + + foreach ($objects as $object) { + /** @var DataObject&Versioned $object */ + if (!$object->hasExtension(Versioned::class) || !$object->isPublished()) { + continue; + } + + if (!$object->canUnpublish($context[QueryHandler::CURRENT_USER])) { + throw new Exception(sprintf( + 'Cannot unpublish %s with ID %s', + get_class($object), + $object->ID + )); + } + + $object->doUnpublish(); + } + }); + }; + } + +} diff --git a/src/GraphQL/Plugins/VersionedDataObject.php b/src/GraphQL/Plugins/VersionedDataObject.php new file mode 100644 index 00000000..312520e9 --- /dev/null +++ b/src/GraphQL/Plugins/VersionedDataObject.php @@ -0,0 +1,88 @@ +addModel(ModelType::create(Member::class)); + } + + /** + * @param ModelType $type + * @param Schema $schema + * @param array $config + * @throws SchemaBuilderException + */ + public function apply(ModelType $type, Schema $schema, array $config = []): void + { + $class = $type->getModel()->getSourceClass(); + Schema::invariant( + is_subclass_of($class, DataObject::class), + 'The %s plugin can only be applied to types generated by %s models', + __CLASS__, + DataObject::class + ); + + if (!Extensible::has_extension($class, Versioned::class)) { + return; + } + + $versionName = $type->getModel()->getTypeName() . 'Version'; + $memberTypeName = DataObjectModel::create(Member::class)->getTypeName(); + $resolver = ['resolver' => [VersionedResolver::class, 'resolveVersionFields']]; + + $versionType = Type::create($versionName) + ->mergeWith($type) + ->addField('author', ['type' => $memberTypeName] + $resolver) + ->addField('publisher', ['type' => $memberTypeName] + $resolver) + ->addField('published', ['type' => 'Boolean'] + $resolver) + ->addField('liveVersion', ['type' => 'Boolean'] + $resolver) + ->addField('latestDraftVersion', ['type' => 'Boolean'] + $resolver); + + $schema->addType($versionType); + $type->addField('version', 'Int') + ->addField('versions', '[' . $versionName . ']', function (Field $field) use ($type) { + $field->setResolver([VersionedResolver::class, 'resolveVersionList']) + ->addResolverContext('sourceClass', $type->getModel()->getSourceClass()) + ->addPlugin(QuerySort::IDENTIFIER, [ + 'fields' => ['Version'], + ]); + }); + } +} diff --git a/src/GraphQL/Plugins/VersionedRead.php b/src/GraphQL/Plugins/VersionedRead.php new file mode 100644 index 00000000..f1e0f730 --- /dev/null +++ b/src/GraphQL/Plugins/VersionedRead.php @@ -0,0 +1,42 @@ +getModel()->getSourceClass(); + if (!Extensible::has_extension($class, Versioned::class)) { + return; + } + + $query->addResolverAfterware([VersionedResolver::class, 'resolveVersionedRead']); + $query->addArg('versioning', 'VersionedInputType'); + } +} diff --git a/src/GraphQL/Resolvers/ApplyVersionFilters.php b/src/GraphQL/Resolvers/ApplyVersionFilters.php index d2025336..cff7e0f6 100644 --- a/src/GraphQL/Resolvers/ApplyVersionFilters.php +++ b/src/GraphQL/Resolvers/ApplyVersionFilters.php @@ -17,15 +17,15 @@ class ApplyVersionFilters * * @param $versioningArgs */ - public function applyToReadingState($versioningArgs) + public function applyToReadingState(array $versioningArgs) { - if (!isset($versioningArgs['Mode'])) { + if (!isset($versioningArgs['mode'])) { return; } $this->validateArgs($versioningArgs); - $mode = $versioningArgs['Mode']; + $mode = $versioningArgs['mode']; switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: @@ -33,7 +33,7 @@ public function applyToReadingState($versioningArgs) Versioned::set_stage($mode); break; case 'archive': - $date = $versioningArgs['ArchiveDate']; + $date = $versioningArgs['archiveDate']; Versioned::set_reading_mode($mode); Versioned::reading_archived_date($date); break; @@ -55,16 +55,18 @@ public function applyToReadingState($versioningArgs) /** * @param DataList $list * @param array $versioningArgs + * @throws InvalidArgumentException + * @return DataList */ - public function applyToList(&$list, $versioningArgs) + public function applyToList(DataList $list, array $versioningArgs): DataList { - if (!isset($versioningArgs['Mode'])) { + if (!isset($versioningArgs['mode'])) { return; } $this->validateArgs($versioningArgs); - $mode = $versioningArgs['Mode']; + $mode = $versioningArgs['mode']; switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: @@ -73,7 +75,7 @@ public function applyToList(&$list, $versioningArgs) ->setDataQueryParam('Versioned.stage', $mode); break; case 'archive': - $date = $versioningArgs['ArchiveDate']; + $date = $versioningArgs['archiveDate']; $list = $list ->setDataQueryParam('Versioned.mode', 'archive') ->setDataQueryParam('Versioned.date', $date); @@ -85,7 +87,7 @@ public function applyToList(&$list, $versioningArgs) // When querying by Status we need to ensure both stage / live tables are present $baseTable = singleton($list->dataClass())->baseTable(); $liveTable = $baseTable . '_Live'; - $statuses = $versioningArgs['Status']; + $statuses = $versioningArgs['status']; // If we need to search archived records, we need to manually join draft table if (in_array('archived', $statuses)) { @@ -148,34 +150,36 @@ public function applyToList(&$list, $versioningArgs) // Note: Only valid for ReadOne $list = $list->setDataQueryParam([ "Versioned.mode" => 'version', - "Versioned.version" => $versioningArgs['Version'], + "Versioned.version" => $versioningArgs['version'], ]); break; default: throw new InvalidArgumentException("Unsupported read mode {$mode}"); } + + return $list; } /** * @throws InvalidArgumentException * @param $versioningArgs */ - public function validateArgs($versioningArgs) + public function validateArgs(array $versioningArgs) { - $mode = $versioningArgs['Mode']; + $mode = $versioningArgs['mode']; switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: break; case 'archive': - if (empty($versioningArgs['ArchiveDate'])) { + if (empty($versioningArgs['archiveDate'])) { throw new InvalidArgumentException(sprintf( 'You must provide an ArchiveDate parameter when using the "%s" mode', $mode )); } - $date = $versioningArgs['ArchiveDate']; + $date = $versioningArgs['archiveDate']; if (!$this->isValidDate($date)) { throw new InvalidArgumentException(sprintf( 'Invalid date: "%s". Must be YYYY-MM-DD format', @@ -186,7 +190,7 @@ public function validateArgs($versioningArgs) case 'latest_versions': break; case 'status': - if (empty($versioningArgs['Status'])) { + if (empty($versioningArgs['status'])) { throw new InvalidArgumentException(sprintf( 'You must provide a Status parameter when using the "%s" mode', $mode @@ -195,7 +199,7 @@ public function validateArgs($versioningArgs) break; case 'version': // Note: Only valid for ReadOne - if (!isset($versioningArgs['Version'])) { + if (!isset($versioningArgs['version'])) { throw new InvalidArgumentException( 'When using the "version" mode, you must specify a Version parameter' ); diff --git a/src/GraphQL/Resolvers/VersionedResolver.php b/src/GraphQL/Resolvers/VersionedResolver.php new file mode 100644 index 00000000..8c909eb1 --- /dev/null +++ b/src/GraphQL/Resolvers/VersionedResolver.php @@ -0,0 +1,249 @@ +fieldName) { + case 'author': + return $obj->Author(); + case 'publisher': + return $obj->Publisher(); + case 'published': + return $obj->isPublished(); + case 'liveVersion': + return $obj->isLiveVersion(); + case 'latestDraftVersion': + return $obj->isLatestDraftVersion(); + } + + return null; + } + + /** + * @param array $resolverContext + * @return Closure + * @see VersionedDataObject + */ + public static function resolveVersionList(array $resolverContext): Closure + { + $sourceClass = $resolverContext['sourceClass']; + return function ($object, array $args, array $context, ResolveInfo $info) use ($sourceClass) { + /** @var DataObject|Versioned $object */ + if (!$object->hasExtension(Versioned::class)) { + throw new Exception(sprintf( + 'Types using the %s plugin must have the Versioned extension applied. (See %s)', + VersionedDataObject::class, + $sourceClass + )); + } + if (!$object->canViewStage(Versioned::DRAFT, $context[QueryHandler::CURRENT_USER])) { + throw new Exception(sprintf( + 'Cannot view versions on %s', + $this->getDataObjectClass() + )); + } + + // Get all versions + return $object->VersionsList(); + }; + } + + /** + * @param DataList $list + * @param array $args + * @param array $context + * @param ResolveInfo $info + * @return DataList + * @see VersionedRead + */ + public static function resolveVersionedRead(DataList $list, array $args, array $context, ResolveInfo $info) + { + if (!isset($args['versioning'])) { + return $list; + } + + return Injector::inst()->get(ApplyVersionFilters::class) + ->applyToList($list, $args['versioning']); + + } + + /** + * @param array $context + * @return Closure + * @see CopyToStageCreator + */ + public static function resolveCopyToStage(array $context): Closure + { + $dataClass = $context['dataClass'] ?? null; + return function ($object, array $args, $context, ResolveInfo $info) use ($dataClass) { + if (!$dataClass) { + return; + } + + $input = $args['input']; + $id = $input['id']; + $to = $input['toStage']; + /** @var Versioned|DataObject $record */ + $record = null; + if (isset($input['fromVersion'])) { + $from = $input['fromVersion']; + $record = Versioned::get_version($dataClass, $id, $from); + } elseif (isset($input['fromStage'])) { + $from = $input['fromStage']; + $record = Versioned::get_by_stage($dataClass, $from)->byID($id); + } else { + throw new InvalidArgumentException('You must provide either a FromStage or FromVersion argument'); + } + if (!$record) { + throw new InvalidArgumentException("Record {$id} not found"); + } + + // Permission check object + $can = $to === Versioned::LIVE + ? $record->canPublish($context[QueryHandler::CURRENT_USER]) + : $record->canEdit($context[QueryHandler::CURRENT_USER]); + if (!$can) { + throw new InvalidArgumentException(sprintf( + 'Copying %s from %s to %s is not allowed', + $this->getTypeName(), + $from, + $to + )); + } + + /** @var DataObject|Versioned $record */ + $record->copyVersionToStage($from, $to); + return $record; + }; + } + + /** + * @param array $context + * @return Closure + */ + public static function resolvePublishOperation(array $context) + { + $action = $context['action'] ?? null; + $dataClass = $context['dataClass'] ?? null; + $allowedActions = [ + AbstractPublishOperationCreator::ACTION_PUBLISH, + AbstractPublishOperationCreator::ACTION_UNPUBLISH, + ]; + if (!in_array($action, $allowedActions)) { + throw new InvalidArgumentException(sprintf( + 'Invalid publish action: %s', + $action + )); + } + + $isPublish = $action === AbstractPublishOperationCreator::ACTION_PUBLISH; + + return function ($obj, array $args, array $context, ResolveInfo $info) use ($isPublish, $dataClass) { + if (!$dataClass) { + return; + } + $stage = $isPublish ? Versioned::DRAFT : Versioned::LIVE; + $obj = Versioned::get_by_stage($dataClass, $stage) + ->byID($args['id']); + if (!$obj) { + throw new Exception(sprintf( + '%s with ID %s not found', + $dataClass, + $args['id'] + )); + } + $permissionMethod = $isPublish ? 'canPublish' : 'canUnpublish'; + if (!$this->$permissionMethod($obj, $context[QueryHandler::CURRENT_USER])) { + throw new Exception(sprintf( + 'Not allowed to change published state of this %s', + $dataClass + )); + } + + try { + DB::get_conn()->withTransaction(function () use ($obj, $isPublish) { + if ($isPublish) { + $obj->publishRecursive(); + } else { + $obj->doUnpublish(); + } + }); + } catch (ValidationException $e) { + throw new Exception( + 'Could not changed published state of %s. Got error: %s', + $dataClass, + $e->getMessage() + ); + } + return $obj; + }; + } + + /** + * @param array $context + * @return Closure + * @see RollbackCreator + */ + public static function resolveRollback(array $context) + { + $dataClass = $context['dataClass'] ?? null; + return function ($obj, array $args, array $context, ResolveInfo $info) use ($dataClass) { + if (!$dataClass) { + return; + } + // Get the args + $id = $args['id']; + $rollbackVersion = $args['toVersion']; + + // Pull the latest version of the record + /** @var Versioned|DataObject $record */ + $record = Versioned::get_latest_version($dataClass, $id); + + // Assert permission + $user = $context[QueryHandler::CURRENT_USER]; + if (!$record->canEdit($user)) { + throw new InvalidArgumentException('Current user does not have permission to roll back this resource'); + } + + // Perform the rollback + $record = $record->rollbackRecursive($rollbackVersion); + + return $record; + }; + } +} diff --git a/src/GraphQL/Types/CopyToStageInputType.php b/src/GraphQL/Types/CopyToStageInputType.php deleted file mode 100644 index ce760d22..00000000 --- a/src/GraphQL/Types/CopyToStageInputType.php +++ /dev/null @@ -1,53 +0,0 @@ - 'CopyToStageInputType' - ]; - } - - /** - * @return array - */ - public function fields() - { - return [ - 'ID' => [ - 'type' => Type::nonNull(Type::id()), - 'description' => 'The ID of the record to copy', - ], - 'FromVersion' => [ - 'type' => Type::int(), - 'description' => 'The source version number to copy.' - ], - 'FromStage' => [ - 'type' => $this->manager->getType('VersionedStage'), - 'description' => 'The source stage to copy', - ], - 'ToStage' => [ - 'type' => Type::nonNull($this->manager->getType('VersionedStage')), - 'description' => 'The destination stage to copy to', - ], - ]; - } -} diff --git a/src/GraphQL/Types/VersionedInputType.php b/src/GraphQL/Types/VersionedInputType.php deleted file mode 100644 index b32b2959..00000000 --- a/src/GraphQL/Types/VersionedInputType.php +++ /dev/null @@ -1,53 +0,0 @@ - 'VersionedInputType' - ]; - } - - /** - * @return array - */ - public function fields() - { - return [ - 'Mode' => [ - 'type' => $this->manager->getType('VersionedQueryMode'), - 'defaultValue' => Versioned::DRAFT, - ], - 'ArchiveDate' => [ - 'type' => Type::string(), - 'description' => 'The date to use for archive ' - ], - 'Status' => [ - 'type' => Type::listOf($this->manager->getType('VersionedStatus')), - 'description' => 'If mode is STATUS, specify which versioned statuses' - ], - 'Version' => [ - 'type' => Type::int(), - ], - ]; - } -} diff --git a/src/GraphQL/Types/VersionedQueryMode.php b/src/GraphQL/Types/VersionedQueryMode.php deleted file mode 100644 index ad347190..00000000 --- a/src/GraphQL/Types/VersionedQueryMode.php +++ /dev/null @@ -1,59 +0,0 @@ - 'VersionedQueryMode', - 'description' => 'The versioned mode to use', - 'values' => $this->getValues() - ]); - } - - /** - * @return array - */ - protected function getValues() - { - return [ - 'ARCHIVE' => [ - 'value' => 'archive', - 'description' => 'Read from a specific date of the archive', - ], - 'LATEST' => [ - 'value' => 'latest_versions', - 'description' => 'Read the latest version', - ], - 'DRAFT' => [ - 'value' => Versioned::DRAFT, - 'description' => 'Read from the draft stage', - ], - 'LIVE' => [ - 'value' => Versioned::LIVE, - 'description' => 'Read from the live stage', - ], - 'STATUS' => [ - 'value' => 'status', - 'description' => 'Read only records with a specific status', - ], - 'VERSION' => [ - 'value' => 'version', - 'description' => 'Read a specific version', - ], - ]; - } -} diff --git a/src/GraphQL/Types/VersionedStage.php b/src/GraphQL/Types/VersionedStage.php deleted file mode 100644 index 74306076..00000000 --- a/src/GraphQL/Types/VersionedStage.php +++ /dev/null @@ -1,35 +0,0 @@ - 'VersionedStage', - 'description' => 'The stage to read from or write to', - 'values' => [ - 'DRAFT' => [ - 'value' => Versioned::DRAFT, - 'description' => 'The draft stage', - ], - 'LIVE' => [ - 'value' => Versioned::LIVE, - 'description' => 'The live stage', - ], - ] - ]); - } -} diff --git a/src/GraphQL/Types/VersionedStatus.php b/src/GraphQL/Types/VersionedStatus.php deleted file mode 100644 index 1cef2dab..00000000 --- a/src/GraphQL/Types/VersionedStatus.php +++ /dev/null @@ -1,42 +0,0 @@ - 'VersionedStatus', - 'description' => 'The stage to read from or write to', - 'values' => [ - 'PUBLISHED' => [ - 'value' => 'published', - 'description' => 'Only published records', - ], - 'DRAFT' => [ - 'value' => 'draft', - 'description' => 'Only draft records', - ], - 'ARCHIVED' => [ - 'value' => 'archived', - 'description' => 'Only records that have been archived', - ], - 'MODIFIED' => [ - 'value' => 'modified', - 'description' => 'Only records that have unpublished changes', - ], - ], - ]); - } -} diff --git a/tests/php/GraphQL/Operations/RollbackTest.php b/tests/php/GraphQL/Operations/RollbackTest.php index 377634a2..3723adf1 100644 --- a/tests/php/GraphQL/Operations/RollbackTest.php +++ b/tests/php/GraphQL/Operations/RollbackTest.php @@ -10,7 +10,7 @@ use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataObject; use SilverStripe\Security\Security; -use SilverStripe\Versioned\GraphQL\Operations\Rollback; +use SilverStripe\Versioned\GraphQL\Operations\RollbackCreator; use SilverStripe\Versioned\Tests\GraphQL\Operations\Rollback\FakeDataObjectStub; class RollbackTest extends SapphireTest @@ -59,7 +59,7 @@ protected function doMutation(DataObject $stub, $toVersion = 1, $member = null) $manager = new Manager(); $manager->addType(new ObjectType(['name' => $typeName])); - $mutation = new Rollback($stubClass); + $mutation = new RollbackCreator($stubClass); $scaffold = $mutation->scaffold($manager); $this->assertInternalType('callable', $scaffold['resolve'], 'Resolve function is scaffolded correctly');