From 4a42ca707e37e7fd5beae8ae2894c65c65d5fe81 Mon Sep 17 00:00:00 2001 From: Aaron Carlino Date: Fri, 13 Nov 2020 13:51:26 +1300 Subject: [PATCH] NEW: GraphQL 4 Compatibility (#308) * First pass at graphql 4 compat * It works kinda * Remove conflict composer * Replace graphql3 legacy * Fix ApplyVersionFilters conflict * Fix paginate plugin bfore * Compatability with flushless schema * Throw if versioned used on nested query * Versioned readone * NEW schema defaults * Compliance with new modelConfig * Fix exlcusion rule * Remove old classes * Proper sort for versions field * Compatability with field formatting API * New graphql 3 compat * compliance with new modeltype constructor * Operations tests for v4' * new composer constraint for graphql * Add shims for graphql branches * Fix git branch * Update admin requirement * Clean up travis * Remove admin depdendency * Add graphql dependency * Clean up tests * Tests should work now * Fix linting * BC tests fixed * Add missing versioned fields * Linting * use stageTable * API Mark GraphQL v3 usage as deprecated * Fixed draft table alias regression Co-authored-by: Ingo Schommer --- .travis.yml | 12 +- _config/{graphql.yml => graphql-legacy.yml} | 2 + _config/graphql_operations.yml | 12 + _config/graphql_plugins.yml | 12 + _config/graphql_schema.yml | 11 + _graphql/enums.yml | 50 +++ _graphql/modelConfig.yml | 15 + _graphql/types.yml | 27 ++ composer.json | 8 +- .../AbstractPublishOperationCreator.php | 75 +++++ src/GraphQL/Operations/CopyToStageCreator.php | 66 ++++ src/GraphQL/Operations/PublishCreator.php | 33 ++ src/GraphQL/Operations/RollbackCreator.php | 70 +++++ src/GraphQL/Operations/UnpublishCreator.php | 32 ++ src/GraphQL/Plugins/UnpublishOnDelete.php | 80 +++++ src/GraphQL/Plugins/VersionedDataObject.php | 124 ++++++++ src/GraphQL/Plugins/VersionedRead.php | 44 +++ src/GraphQL/Resolvers/VersionFilters.php | 241 +++++++++++++++ src/GraphQL/Resolvers/VersionedResolver.php | 255 ++++++++++++++++ .../DataObjectScaffolderExtension.php | 17 +- .../Extensions/DeleteExtension.php | 2 + .../Extensions/ManagerExtension.php | 5 + .../Extensions/ReadExtension.php | 16 +- .../Extensions/SchemaScaffolderExtension.php | 3 + .../{ => _legacy}/Operations/CopyToStage.php | 30 +- .../{ => _legacy}/Operations/Publish.php | 2 + .../Operations/PublishOperation.php | 19 +- .../{ => _legacy}/Operations/ReadVersions.php | 13 +- .../{ => _legacy}/Operations/Rollback.php | 15 +- .../{ => _legacy}/Operations/Unpublish.php | 2 + .../Resolvers/ApplyVersionFilters.php | 37 ++- .../Types/CopyToStageInputType.php | 8 +- src/GraphQL/_legacy/Types/VersionSortType.php | 62 ++++ .../Types/VersionedInputType.php | 8 +- .../Types/VersionedQueryMode.php | 3 + .../{ => _legacy}/Types/VersionedStage.php | 3 + .../{ => _legacy}/Types/VersionedStatus.php | 3 + .../Rollback => Fake}/FakeDataObjectStub.php | 2 +- tests/php/GraphQL/Fake/FakeResolveInfo.php | 13 + .../DataObjectScaffolderExtensionTest.php | 13 +- .../Extensions/ReadExtensionTest.php | 11 +- .../Extensions/ReadOneExtensionTest.php | 11 +- .../SchemaScaffolderExtensionTest.php | 13 +- .../Operations/CopyToStageTest.php | 12 +- .../{ => Legacy}/Operations/PublishTest.php | 11 +- .../Operations/ReadVersionsTest.php | 20 +- .../{ => Legacy}/Operations/RollbackTest.php | 13 +- .../{ => Legacy}/Operations/UnpublishTest.php | 11 +- .../Resolvers/ApplyVersionFiltersTest.php | 11 +- .../Plugins/VersionedDataObjectPluginTest.php | 97 ++++++ .../php/GraphQL/Plugins/VersionedReadTest.php | 52 ++++ .../Resolvers/VersionedFiltersTest.php | 285 ++++++++++++++++++ .../Resolvers/VersionedResolverTest.php | 237 +++++++++++++++ 53 files changed, 2167 insertions(+), 62 deletions(-) rename _config/{graphql.yml => graphql-legacy.yml} (95%) create mode 100644 _config/graphql_operations.yml create mode 100644 _config/graphql_plugins.yml create mode 100644 _config/graphql_schema.yml create mode 100644 _graphql/enums.yml create mode 100644 _graphql/modelConfig.yml create mode 100644 _graphql/types.yml create mode 100644 src/GraphQL/Operations/AbstractPublishOperationCreator.php create mode 100644 src/GraphQL/Operations/CopyToStageCreator.php create mode 100644 src/GraphQL/Operations/PublishCreator.php create mode 100644 src/GraphQL/Operations/RollbackCreator.php create mode 100644 src/GraphQL/Operations/UnpublishCreator.php create mode 100644 src/GraphQL/Plugins/UnpublishOnDelete.php create mode 100644 src/GraphQL/Plugins/VersionedDataObject.php create mode 100644 src/GraphQL/Plugins/VersionedRead.php create mode 100644 src/GraphQL/Resolvers/VersionFilters.php create mode 100644 src/GraphQL/Resolvers/VersionedResolver.php rename src/GraphQL/{ => _legacy}/Extensions/DataObjectScaffolderExtension.php (88%) rename src/GraphQL/{ => _legacy}/Extensions/DeleteExtension.php (94%) rename src/GraphQL/{ => _legacy}/Extensions/ManagerExtension.php (83%) rename src/GraphQL/{ => _legacy}/Extensions/ReadExtension.php (68%) rename src/GraphQL/{ => _legacy}/Extensions/SchemaScaffolderExtension.php (93%) rename src/GraphQL/{ => _legacy}/Operations/CopyToStage.php (76%) rename src/GraphQL/{ => _legacy}/Operations/Publish.php (94%) rename src/GraphQL/{ => _legacy}/Operations/PublishOperation.php (88%) rename src/GraphQL/{ => _legacy}/Operations/ReadVersions.php (87%) rename src/GraphQL/{ => _legacy}/Operations/Rollback.php (88%) rename src/GraphQL/{ => _legacy}/Operations/Unpublish.php (94%) rename src/GraphQL/{ => _legacy}/Resolvers/ApplyVersionFilters.php (89%) rename src/GraphQL/{ => _legacy}/Types/CopyToStageInputType.php (86%) create mode 100644 src/GraphQL/_legacy/Types/VersionSortType.php rename src/GraphQL/{ => _legacy}/Types/VersionedInputType.php (86%) rename src/GraphQL/{ => _legacy}/Types/VersionedQueryMode.php (95%) rename src/GraphQL/{ => _legacy}/Types/VersionedStage.php (91%) rename src/GraphQL/{ => _legacy}/Types/VersionedStatus.php (93%) rename tests/php/GraphQL/{Operations/Rollback => Fake}/FakeDataObjectStub.php (91%) create mode 100644 tests/php/GraphQL/Fake/FakeResolveInfo.php rename tests/php/GraphQL/{ => Legacy}/Extensions/DataObjectScaffolderExtensionTest.php (82%) rename tests/php/GraphQL/{ => Legacy}/Extensions/ReadExtensionTest.php (83%) rename tests/php/GraphQL/{ => Legacy}/Extensions/ReadOneExtensionTest.php (90%) rename tests/php/GraphQL/{ => Legacy}/Extensions/SchemaScaffolderExtensionTest.php (78%) rename tests/php/GraphQL/{ => Legacy}/Operations/CopyToStageTest.php (91%) rename tests/php/GraphQL/{ => Legacy}/Operations/PublishTest.php (87%) rename tests/php/GraphQL/{ => Legacy}/Operations/ReadVersionsTest.php (85%) rename tests/php/GraphQL/{ => Legacy}/Operations/RollbackTest.php (85%) rename tests/php/GraphQL/{ => Legacy}/Operations/UnpublishTest.php (88%) rename tests/php/GraphQL/{ => Legacy}/Resolvers/ApplyVersionFiltersTest.php (96%) create mode 100644 tests/php/GraphQL/Plugins/VersionedDataObjectPluginTest.php create mode 100644 tests/php/GraphQL/Plugins/VersionedReadTest.php create mode 100644 tests/php/GraphQL/Resolvers/VersionedFiltersTest.php create mode 100644 tests/php/GraphQL/Resolvers/VersionedResolverTest.php diff --git a/.travis.yml b/.travis.yml index a7b3cb47..1c2d5155 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,6 +25,8 @@ jobs: env: DB=MYSQL RECIPE_VERSION=4.x-dev PHPUNIT_TEST=1 - php: 7.4 env: DB=MYSQL RECIPE_VERSION=4.x-dev PHPUNIT_TEST=1 + - php: 7.4 + env: DB=MYSQL RECIPE_VERSION=4.x-dev PHPUNIT_TEST=1 BACKWARD_COMPAT=1 - php: nightly env: DB=MYSQL RECIPE_VERSION=4.x-dev PHPUNIT_TEST=1 COMPOSER_ARG=--ignore-platform-reqs @@ -37,12 +39,20 @@ before_script: # Install composer - composer validate - - composer require silverstripe/recipe-cms:$RECIPE_VERSION --no-update + - composer require silverstripe/recipe-cms:$RECIPE_VERSION --no-update --prefer-dist # Fix for running phpunit 5 on php 7.4+ - composer require --no-update sminnee/phpunit-mock-objects:^3 + + ######## Remove once GraphQL 4 is merged ######### + - composer require silverstripe/admin:"dev-pulls/1/schemageddon as 1.x-dev" silverstripe/asset-admin:"dev-pulls/1/schemageddon as 1.x-dev" silverstripe/versioned-admin:"dev-pulls/1/schemageddon as 1.x-dev" silverstripe/cms:"dev-pulls/4/schemageddon as 4.x-dev" silverstripe/graphql:"4.x-dev as 3.x-dev" --no-update + ################################################## + - 'if [[ $BACKWARD_COMPAT ]]; then composer require silverstripe/graphql:"dev-pulls/3/schemageddon-compat as 3.x-dev" --no-update; fi' + + - if [[ $DB == PGSQL ]]; then composer require silverstripe/postgresql:^2 --no-update; fi - composer update --prefer-dist --no-interaction --no-progress --no-suggest --optimize-autoloader --verbose --profile $COMPOSER_ARG + script: - if [[ $PHPUNIT_TEST ]]; then vendor/bin/phpunit tests/php; fi - if [[ $PHPCS_TEST ]]; then composer run-script lint; fi diff --git a/_config/graphql.yml b/_config/graphql-legacy.yml similarity index 95% rename from _config/graphql.yml rename to _config/graphql-legacy.yml index 73d095e8..48be804d 100644 --- a/_config/graphql.yml +++ b/_config/graphql-legacy.yml @@ -2,6 +2,8 @@ Name: versioned-graphql Only: moduleexists: 'silverstripe/graphql' +Except: + classexists: 'SilverStripe\GraphQL\Schema\Schema' --- SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Read: extensions: diff --git a/_config/graphql_operations.yml b/_config/graphql_operations.yml new file mode 100644 index 00000000..2eab0d9a --- /dev/null +++ b/_config/graphql_operations.yml @@ -0,0 +1,12 @@ +--- +Name: versioned-graphql-dataobject +Only: + moduleexists: 'silverstripe/graphql' + classexists: 'SilverStripe\GraphQL\Schema\Schema' +--- +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..c31ac515 --- /dev/null +++ b/_config/graphql_plugins.yml @@ -0,0 +1,12 @@ +--- +Name: versioned-graphql-plugins +Only: + moduleexists: 'silverstripe/graphql' + classexists: 'SilverStripe\GraphQL\Schema\Schema' +--- +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' diff --git a/_config/graphql_schema.yml b/_config/graphql_schema.yml new file mode 100644 index 00000000..f313bdb5 --- /dev/null +++ b/_config/graphql_schema.yml @@ -0,0 +1,11 @@ +--- +Name: versioned-graphql-schema +Only: + moduleexists: 'silverstripe/graphql' + classexists: 'SilverStripe\GraphQL\Schema\Schema' +--- +SilverStripe\GraphQL\Schema\Schema: + schemas: + '*': + src: + versionedSrc: 'silverstripe/versioned: _graphql' diff --git a/_graphql/enums.yml b/_graphql/enums.yml new file mode 100644 index 00000000..8ebcb72c --- /dev/null +++ b/_graphql/enums.yml @@ -0,0 +1,50 @@ +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 + ALL_VERSIONS: + value: all_versions + description: Reads all versionse + 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 diff --git a/_graphql/modelConfig.yml b/_graphql/modelConfig.yml new file mode 100644 index 00000000..62c33d9b --- /dev/null +++ b/_graphql/modelConfig.yml @@ -0,0 +1,15 @@ +DataObject: + plugins: + versioning: true + operations: + read: + plugins: + readVersion: + before: paginateList + readOne: + plugins: + readVersion: + before: firstResult + delete: + plugins: + unpublishOnDelete: true diff --git a/_graphql/types.yml b/_graphql/types.yml new file mode 100644 index 00000000..dff0e8ae --- /dev/null +++ b/_graphql/types.yml @@ -0,0 +1,27 @@ +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/composer.json b/composer.json index a89a97fa..a1aa3d7c 100755 --- a/composer.json +++ b/composer.json @@ -25,15 +25,15 @@ }, "require-dev": { "sminnee/phpunit": "^5.7", - "squizlabs/php_codesniffer": "^3", - "silverstripe/graphql": "^3" + "silverstripe/graphql": "3.x-dev || 4.x-dev", + "squizlabs/php_codesniffer": "^3" }, - "extra": [], "autoload": { "psr-4": { "SilverStripe\\Versioned\\": "src/", "SilverStripe\\Versioned\\Tests\\": "tests/php/" - } + }, + "classmap": ["src/GraphQL/_legacy"] }, "scripts": { "lint": "vendor/bin/phpcs src/ tests/php/", diff --git a/src/GraphQL/Operations/AbstractPublishOperationCreator.php b/src/GraphQL/Operations/AbstractPublishOperationCreator.php new file mode 100644 index 00000000..2606c9a8 --- /dev/null +++ b/src/GraphQL/Operations/AbstractPublishOperationCreator.php @@ -0,0 +1,75 @@ +getSourceClass(), Versioned::class)) { + return null; + } + + $plugins = $config['plugins'] ?? []; + $name = $config['name'] ?? null; + if (!$name) { + $name = $this->createOperationName($typeName); + } + return ModelMutation::create($model, $name) + ->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/CopyToStageCreator.php b/src/GraphQL/Operations/CopyToStageCreator.php new file mode 100644 index 00000000..0148d706 --- /dev/null +++ b/src/GraphQL/Operations/CopyToStageCreator.php @@ -0,0 +1,66 @@ +getSourceClass(), Versioned::class)) { + return null; + } + + $plugins = $config['plugins'] ?? []; + $mutationName = $config['name'] ?? null; + if (!$mutationName) { + $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/PublishCreator.php b/src/GraphQL/Operations/PublishCreator.php new file mode 100644 index 00000000..82b04c7f --- /dev/null +++ b/src/GraphQL/Operations/PublishCreator.php @@ -0,0 +1,33 @@ +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/UnpublishCreator.php b/src/GraphQL/Operations/UnpublishCreator.php new file mode 100644 index 00000000..fab30a28 --- /dev/null +++ b/src/GraphQL/Operations/UnpublishCreator.php @@ -0,0 +1,32 @@ +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..3841e381 --- /dev/null +++ b/src/GraphQL/Plugins/VersionedDataObject.php @@ -0,0 +1,124 @@ +addModelbyClassName(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'; + $memberType = $schema->getModelByClassName(Member::class); + Schema::invariant( + $memberType, + 'The %s class was not added as a model. Should have been done in %s::%s?', + Member::class, + __CLASS__, + 'updateSchema' + ); + $memberTypeName = $memberType->getModel()->getTypeName(); + $resolver = ['resolver' => [VersionedResolver::class, 'resolveVersionFields']]; + + $type->addField('version', 'Int'); + + $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('deleted', ['type' => 'Boolean'] + $resolver) + ->addField('draft', ['type' => 'Boolean'] + $resolver) + ->addField('latestDraftVersion', ['type' => 'Boolean'] + $resolver); + + $schema->addType($versionType); + $type->addField('versions', '[' . $versionName . ']', function (Field $field) use ($type) { + $field->setResolver([VersionedResolver::class, 'resolveVersionList']) + ->addResolverContext('sourceClass', $type->getModel()->getSourceClass()) + ->addPlugin(SortPlugin::IDENTIFIER, [ + 'fields' => [ + 'version' => true + ], + 'input' => $type->getName() . 'VersionSort', + 'resolver' => [static::class, 'sortVersions'], + ]) + ->addPlugin(Paginator::IDENTIFIER, [ + 'connection' => $type->getName() . 'Versions', + ]); + }); + } + + /** + * @param array $config + * @return Closure + */ + public static function sortVersions(array $config): Closure + { + $fieldName = $config['fieldName']; + return function (Sortable $list, array $args) use ($fieldName) { + $versionSort = $args[$fieldName]['version'] ?? null; + if ($versionSort) { + $list = $list->sort('Version', $versionSort); + } + + return $list; + }; + } +} diff --git a/src/GraphQL/Plugins/VersionedRead.php b/src/GraphQL/Plugins/VersionedRead.php new file mode 100644 index 00000000..4e711681 --- /dev/null +++ b/src/GraphQL/Plugins/VersionedRead.php @@ -0,0 +1,44 @@ +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/VersionFilters.php b/src/GraphQL/Resolvers/VersionFilters.php new file mode 100644 index 00000000..9e134e04 --- /dev/null +++ b/src/GraphQL/Resolvers/VersionFilters.php @@ -0,0 +1,241 @@ +validateArgs($versioningArgs); + + $mode = $versioningArgs['mode']; + switch ($mode) { + case Versioned::LIVE: + case Versioned::DRAFT: + case 'latest_versions': + case 'all_versions': + Versioned::set_stage($mode); + break; + case 'archive': + $date = $versioningArgs['archiveDate']; + Versioned::set_reading_mode($mode); + Versioned::reading_archived_date($date); + break; + case 'status': + throw new InvalidArgumentException( + 'The "status" mode is not supported for setting versioned reading stages' + ); + break; + case 'version': + throw new InvalidArgumentException( + 'The "version" mode is not supported for setting versioned reading stages' + ); + break; + default: + throw new InvalidArgumentException("Unsupported read mode {$mode}"); + } + } + + /** + * @param DataList $list + * @param array $versioningArgs + * @throws InvalidArgumentException + * @return DataList + */ + public function applyToList(DataList $list, array $versioningArgs): DataList + { + if ($list instanceof RelationList) { + throw new InvalidArgumentException(sprintf( + 'Version filtering cannot be applied to instances of %s. Are you using the plugin on a nested query?', + get_class($list) + )); + } + if (!isset($versioningArgs['mode'])) { + return $list; + } + + $this->validateArgs($versioningArgs); + + $mode = $versioningArgs['mode']; + switch ($mode) { + case Versioned::LIVE: + case Versioned::DRAFT: + $list = $list + ->setDataQueryParam('Versioned.mode', 'stage') + ->setDataQueryParam('Versioned.stage', $mode); + break; + case 'archive': + $date = $versioningArgs['archiveDate']; + $list = $list + ->setDataQueryParam('Versioned.mode', 'archive') + ->setDataQueryParam('Versioned.date', $date); + break; + case 'all_versions': + $list = $list->setDataQueryParam('Versioned.mode', 'all_versions'); + break; + case 'latest_versions': + $list = $list->setDataQueryParam('Versioned.mode', 'latest_versions'); + break; + case 'status': + // When querying by Status we need to ensure both stage / live tables are present + /* @var DataObject&Versioned $sng */ + $sng = singleton($list->dataClass()); + $baseTable = $sng->baseTable(); + $liveTable = $sng->stageTable($baseTable, Versioned::LIVE); + $statuses = $versioningArgs['status']; + + // If we need to search archived records, we need to manually join draft table + if (in_array('archived', $statuses)) { + $list = $list + ->setDataQueryParam('Versioned.mode', 'latest_versions'); + // Join a temporary alias BaseTable_Draft, renaming this on execution to BaseTable + // See Versioned::augmentSQL() For reference on this alias + $draftTable = $sng->stageTable($baseTable, Versioned::DRAFT) . '_Draft'; + $list = $list + ->leftJoin( + $draftTable, + "\"{$baseTable}\".\"ID\" = \"{$draftTable}\".\"ID\"" + ); + } else { + // Use draft as base query mode (base join live) + $draftTable = $baseTable; + $list = $list + ->setDataQueryParam('Versioned.mode', 'stage') + ->setDataQueryParam('Versioned.stage', Versioned::DRAFT); + } + + // Always include live table + $list = $list->leftJoin( + $liveTable, + "\"{$baseTable}\".\"ID\" = \"{$liveTable}\".\"ID\"" + ); + + // Add all conditions + $conditions = []; + + // Modified exist on both stages, but differ + if (in_array('modified', $statuses)) { + $conditions[] = "\"{$liveTable}\".\"ID\" IS NOT NULL AND \"{$draftTable}\".\"ID\" IS NOT NULL" + . " AND \"{$draftTable}\".\"Version\" <> \"{$liveTable}\".\"Version\""; + } + + // Is deleted and sent to archive + if (in_array('archived', $statuses)) { + // Note: Include items staged for deletion for the time being, as these are effectively archived + // we could split this out into "staged for deletion" in the future + $conditions[] = "\"{$draftTable}\".\"ID\" IS NULL"; + } + + // Is on draft only + if (in_array('draft', $statuses)) { + $conditions[] = "\"{$liveTable}\".\"ID\" IS NULL AND \"{$draftTable}\".\"ID\" IS NOT NULL"; + } + + if (in_array('published', $statuses)) { + $conditions[] = "\"{$liveTable}\".\"ID\" IS NOT NULL"; + } + + // Validate that all statuses have been handled + if (empty($conditions) || count($statuses) !== count($conditions)) { + throw new InvalidArgumentException("Invalid statuses provided"); + } + $list = $list->whereAny(array_filter($conditions)); + break; + case 'version': + // Note: Only valid for ReadOne + $list = $list->setDataQueryParam([ + "Versioned.mode" => 'version', + "Versioned.version" => $versioningArgs['version'], + ]); + break; + default: + throw new InvalidArgumentException("Unsupported read mode {$mode}"); + } + + return $list; + } + + /** + * @throws InvalidArgumentException + * @param $versioningArgs + */ + public function validateArgs(array $versioningArgs) + { + $mode = $versioningArgs['mode']; + + switch ($mode) { + case Versioned::LIVE: + case Versioned::DRAFT: + break; + case 'archive': + if (empty($versioningArgs['archiveDate'])) { + throw new InvalidArgumentException(sprintf( + 'You must provide an ArchiveDate parameter when using the "%s" mode', + $mode + )); + } + $date = $versioningArgs['archiveDate']; + if (!$this->isValidDate($date)) { + throw new InvalidArgumentException(sprintf( + 'Invalid date: "%s". Must be YYYY-MM-DD format', + $date + )); + } + break; + case 'all_versions': + break; + case 'latest_versions': + break; + case 'status': + if (empty($versioningArgs['status'])) { + throw new InvalidArgumentException(sprintf( + 'You must provide a Status parameter when using the "%s" mode', + $mode + )); + } + break; + case 'version': + // Note: Only valid for ReadOne + if (!isset($versioningArgs['version'])) { + throw new InvalidArgumentException( + 'When using the "version" mode, you must specify a Version parameter' + ); + } + break; + default: + throw new InvalidArgumentException("Unsupported read mode {$mode}"); + } + } + + /** + * Returns true if date is in proper YYYY-MM-DD format + * @param string $date + * @return bool + */ + protected function isValidDate($date) + { + $dt = DateTime::createFromFormat('Y-m-d', $date); + + return ($dt !== false && !array_sum($dt->getLastErrors())); + } +} diff --git a/src/GraphQL/Resolvers/VersionedResolver.php b/src/GraphQL/Resolvers/VersionedResolver.php new file mode 100644 index 00000000..b8bc9f1d --- /dev/null +++ b/src/GraphQL/Resolvers/VersionedResolver.php @@ -0,0 +1,255 @@ +fieldName) { + case 'author': + return $obj->Author(); + case 'publisher': + return $obj->Publisher(); + case 'published': + return $obj->isPublished(); + case 'draft': + return $obj->WasDraft; + case 'deleted': + return $obj->WasDeleted; + 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(VersionFilters::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 (!$obj->$permissionMethod($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/Extensions/DataObjectScaffolderExtension.php b/src/GraphQL/_legacy/Extensions/DataObjectScaffolderExtension.php similarity index 88% rename from src/GraphQL/Extensions/DataObjectScaffolderExtension.php rename to src/GraphQL/_legacy/Extensions/DataObjectScaffolderExtension.php index 131361a9..e0e0ed20 100644 --- a/src/GraphQL/Extensions/DataObjectScaffolderExtension.php +++ b/src/GraphQL/_legacy/Extensions/DataObjectScaffolderExtension.php @@ -12,6 +12,13 @@ use SilverStripe\Versioned\GraphQL\Operations\ReadVersions; use SilverStripe\Versioned\Versioned; +if (!class_exists(Manager::class)) { + return; +} + +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class DataObjectScaffolderExtension extends Extension { /** @@ -35,6 +42,7 @@ public function onBeforeAddToManager(Manager $manager) $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) { @@ -84,7 +92,9 @@ public function onBeforeAddToManager(Manager $manager) ], ]; // Remove this recursive madness. - unset($coreFields['Versions']); + unset($coreFields[StaticSchema::inst()->formatField('Versions')]); + + $versionFields = StaticSchema::inst()->formatKeys($versionFields); return array_merge($coreFields, $versionFields); } @@ -92,10 +102,11 @@ public function onBeforeAddToManager(Manager $manager) $manager->addType($versionType, $versionName); + list ($version, $versions) = StaticSchema::inst()->formatFields(['Version', 'Versions']); // 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)); + ->addFields([$version]) + ->nestedQuery($versions, new ReadVersions($class, $versionName)); } /** diff --git a/src/GraphQL/Extensions/DeleteExtension.php b/src/GraphQL/_legacy/Extensions/DeleteExtension.php similarity index 94% rename from src/GraphQL/Extensions/DeleteExtension.php rename to src/GraphQL/_legacy/Extensions/DeleteExtension.php index aaa62715..c552b0aa 100644 --- a/src/GraphQL/Extensions/DeleteExtension.php +++ b/src/GraphQL/_legacy/Extensions/DeleteExtension.php @@ -11,6 +11,8 @@ /** * Extends the @see Delete CRUD scaffolder to unpublish any items first + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class DeleteExtension extends Extension { diff --git a/src/GraphQL/Extensions/ManagerExtension.php b/src/GraphQL/_legacy/Extensions/ManagerExtension.php similarity index 83% rename from src/GraphQL/Extensions/ManagerExtension.php rename to src/GraphQL/_legacy/Extensions/ManagerExtension.php index e55d7133..90b2b00e 100644 --- a/src/GraphQL/Extensions/ManagerExtension.php +++ b/src/GraphQL/_legacy/Extensions/ManagerExtension.php @@ -8,7 +8,11 @@ use SilverStripe\Versioned\GraphQL\Types\VersionedQueryMode; use SilverStripe\Versioned\GraphQL\Types\VersionedStage; use SilverStripe\Versioned\GraphQL\Types\VersionedStatus; +use SilverStripe\Versioned\GraphQL\Types\VersionSortType; +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class ManagerExtension extends Extension { /** @@ -27,5 +31,6 @@ public function updateConfig(&$config) $config['types']['VersionedQueryMode'] = VersionedQueryMode::class; $config['types']['VersionedInputType'] = VersionedInputType::class; $config['types']['CopyToStageInputType'] = CopyToStageInputType::class; + $config['types']['VersionSortType'] = VersionSortType::class; } } diff --git a/src/GraphQL/Extensions/ReadExtension.php b/src/GraphQL/_legacy/Extensions/ReadExtension.php similarity index 68% rename from src/GraphQL/Extensions/ReadExtension.php rename to src/GraphQL/_legacy/Extensions/ReadExtension.php index 2f4192c4..0683b308 100644 --- a/src/GraphQL/Extensions/ReadExtension.php +++ b/src/GraphQL/_legacy/Extensions/ReadExtension.php @@ -7,6 +7,7 @@ use SilverStripe\GraphQL\Resolvers\ApplyVersionFilters; use SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\Read; use SilverStripe\GraphQL\Scaffolding\Scaffolders\CRUD\ReadOne; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataList; use SilverStripe\GraphQL\Manager; @@ -14,17 +15,18 @@ * Decorator for either a Read or ReadOne query scaffolder * * @property Read|ReadOne $owner + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class ReadExtension extends Extension { public function updateList(DataList &$list, $args) { - if (!isset($args['Versioning'])) { + if (!isset($args[$this->argName()])) { return; } Injector::inst()->get(ApplyVersionFilters::class) - ->applyToList($list, $args['Versioning']); + ->applyToList($list, $args[$this->argName()]); } /** @@ -33,8 +35,16 @@ public function updateList(DataList &$list, $args) */ public function updateArgs(&$args, Manager $manager) { - $args['Versioning'] = [ + $args[$this->argName()] = [ 'type' => $manager->getType('VersionedInputType'), ]; } + + /** + * @return string + */ + private function argName() + { + return StaticSchema::inst()->formatField('Versioning'); + } } diff --git a/src/GraphQL/Extensions/SchemaScaffolderExtension.php b/src/GraphQL/_legacy/Extensions/SchemaScaffolderExtension.php similarity index 93% rename from src/GraphQL/Extensions/SchemaScaffolderExtension.php rename to src/GraphQL/_legacy/Extensions/SchemaScaffolderExtension.php index 305ac9db..ce4c2ac3 100644 --- a/src/GraphQL/Extensions/SchemaScaffolderExtension.php +++ b/src/GraphQL/_legacy/Extensions/SchemaScaffolderExtension.php @@ -9,6 +9,9 @@ use SilverStripe\Security\Member; use SilverStripe\Versioned\Versioned; +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class SchemaScaffolderExtension extends Extension { /** diff --git a/src/GraphQL/Operations/CopyToStage.php b/src/GraphQL/_legacy/Operations/CopyToStage.php similarity index 76% rename from src/GraphQL/Operations/CopyToStage.php rename to src/GraphQL/_legacy/Operations/CopyToStage.php index 0373239a..9d96ed5f 100644 --- a/src/GraphQL/Operations/CopyToStage.php +++ b/src/GraphQL/_legacy/Operations/CopyToStage.php @@ -8,6 +8,7 @@ use SilverStripe\GraphQL\Manager; use SilverStripe\GraphQL\OperationResolver; use SilverStripe\GraphQL\Scaffolding\Scaffolders\MutationScaffolder; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataObject; use SilverStripe\Versioned\Versioned; @@ -21,6 +22,7 @@ * copy[TypeName]ToStage(ID!, FromVersion!, FromStage!, ToStage!) * * @internal This is a low level API that might be removed in the future. Consider using the "rollback" mutation instead + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class CopyToStage extends MutationScaffolder implements OperationResolver { @@ -51,23 +53,27 @@ public function getName() protected function createDefaultArgs(Manager $manager) { + $input = $this->argName(); return [ - 'Input' => Type::nonNull($manager->getType('CopyToStageInputType')), + $input => Type::nonNull($manager->getType('CopyToStageInputType')), ]; } public function resolve($object, array $args, $context, ResolveInfo $info) { - $input = $args['Input']; - $id = $input['ID']; - $to = $input['ToStage']; + list($input) = StaticSchema::inst()->extractKeys(['Input'], $args); + list($id, $to, $fromVersion, $fromStage) = StaticSchema::inst()->extractKeys( + ['ID', 'ToStage', 'FromVersion', 'FromStage'], + $input + ); /** @var Versioned|DataObject $record */ $record = null; - if (isset($input['FromVersion'])) { - $from = $input['FromVersion']; + $from = null; + if ($fromVersion) { + $from = $fromVersion; $record = Versioned::get_version($this->getDataObjectClass(), $id, $from); - } elseif (isset($input['FromStage'])) { - $from = $input['FromStage']; + } elseif ($fromStage) { + $from = $fromStage; $record = Versioned::get_by_stage($this->getDataObjectClass(), $from)->byID($id); } else { throw new InvalidArgumentException('You must provide either a FromStage or FromVersion argument'); @@ -93,4 +99,12 @@ public function resolve($object, array $args, $context, ResolveInfo $info) $record->copyVersionToStage($from, $to); return $record; } + + /** + * @return string + */ + private function argName() + { + return StaticSchema::inst()->formatField('Input'); + } } diff --git a/src/GraphQL/Operations/Publish.php b/src/GraphQL/_legacy/Operations/Publish.php similarity index 94% rename from src/GraphQL/Operations/Publish.php rename to src/GraphQL/_legacy/Operations/Publish.php index 24537104..6e35a72c 100644 --- a/src/GraphQL/Operations/Publish.php +++ b/src/GraphQL/_legacy/Operations/Publish.php @@ -13,6 +13,8 @@ /** * Scaffolds a generic update operation for DataObjects. + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class Publish extends PublishOperation { diff --git a/src/GraphQL/Operations/PublishOperation.php b/src/GraphQL/_legacy/Operations/PublishOperation.php similarity index 88% rename from src/GraphQL/Operations/PublishOperation.php rename to src/GraphQL/_legacy/Operations/PublishOperation.php index 31d9a3d8..31fdc278 100644 --- a/src/GraphQL/Operations/PublishOperation.php +++ b/src/GraphQL/_legacy/Operations/PublishOperation.php @@ -8,6 +8,7 @@ use SilverStripe\GraphQL\Manager; use SilverStripe\GraphQL\OperationResolver; use SilverStripe\GraphQL\Scaffolding\Scaffolders\MutationScaffolder; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataObjectInterface; use SilverStripe\ORM\DB; use SilverStripe\ORM\ValidationException; @@ -20,6 +21,8 @@ /** * Scaffolds a generic update operation for DataObjects. + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ abstract class PublishOperation extends MutationScaffolder implements OperationResolver { @@ -47,13 +50,14 @@ public function getName() public function resolve($object, array $args, $context, ResolveInfo $info) { + $id = $this->argName(); $obj = Versioned::get_by_stage($this->getDataObjectClass(), $this->getReadingStage()) - ->byID($args['ID']); + ->byID($args[$id]); if (!$obj) { throw new Exception(sprintf( '%s with ID %s not found', $this->getDataObjectClass(), - $args['ID'] + $args[$id] )); } @@ -92,8 +96,9 @@ public function resolve($object, array $args, $context, ResolveInfo $info) */ protected function createDefaultArgs(Manager $manager) { + $id = $this->argName(); return [ - 'ID' => [ + $id => [ 'type' => Type::nonNull(Type::id()) ], ]; @@ -106,4 +111,12 @@ abstract protected function doMutation(DataObjectInterface $obj); abstract protected function createOperationName(); abstract protected function getReadingStage(); + + /** + * @return string + */ + private function argName() + { + return StaticSchema::inst()->formatField('ID'); + } } diff --git a/src/GraphQL/Operations/ReadVersions.php b/src/GraphQL/_legacy/Operations/ReadVersions.php similarity index 87% rename from src/GraphQL/Operations/ReadVersions.php rename to src/GraphQL/_legacy/Operations/ReadVersions.php index eb6957e4..72f08b62 100644 --- a/src/GraphQL/Operations/ReadVersions.php +++ b/src/GraphQL/_legacy/Operations/ReadVersions.php @@ -15,6 +15,8 @@ /** * Scaffolds a generic read operation for DataObjects. + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class ReadVersions extends ListQueryScaffolder implements OperationResolver { @@ -29,10 +31,10 @@ public function __construct($dataObjectClass, $versionTypeName) $this->setDataObjectClass($dataObjectClass); $operationName = 'read' . ucfirst($versionTypeName); - // Allow clients to sort the versions list by Version ID - $this->addSortableFields(['Version']); - parent::__construct($operationName, $versionTypeName, $this); + + // Allow clients to sort the versions list by Version ID + $this->addArg('sort', 'VersionSortType'); } public function resolve($object, array $args, $context, ResolveInfo $info) @@ -55,6 +57,11 @@ public function resolve($object, array $args, $context, ResolveInfo $info) // Get all versions $list = $object->VersionsList(); + $sort = $args['sort']['version'] ?? null; + if ($sort) { + $list = $list->sort('Version', $sort); + } + $this->extend('updateList', $list, $object, $args, $context, $info); return $list; diff --git a/src/GraphQL/Operations/Rollback.php b/src/GraphQL/_legacy/Operations/Rollback.php similarity index 88% rename from src/GraphQL/Operations/Rollback.php rename to src/GraphQL/_legacy/Operations/Rollback.php index 21a3af06..50cddc3d 100644 --- a/src/GraphQL/Operations/Rollback.php +++ b/src/GraphQL/_legacy/Operations/Rollback.php @@ -8,6 +8,7 @@ use SilverStripe\GraphQL\Manager; use SilverStripe\GraphQL\OperationResolver; use SilverStripe\GraphQL\Scaffolding\Scaffolders\MutationScaffolder; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataObject; use SilverStripe\Versioned\Versioned; @@ -19,6 +20,8 @@ * Scaffolds a "rollback recursive" operation for DataObjects. * * rollback[TypeName](ID!, ToVersion!) + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class Rollback extends MutationScaffolder implements OperationResolver { @@ -53,7 +56,7 @@ public function getName() */ public function createDefaultArgs(Manager $manager) { - return [ + return StaticSchema::inst()->formatKeys([ 'ID' => [ 'type' => Type::nonNull(Type::id()), 'description' => 'The object ID that needs to be rolled back' @@ -62,7 +65,7 @@ public function createDefaultArgs(Manager $manager) 'type' => Type::nonNull(Type::int()), 'description' => 'The version of the object that should be rolled back to' ], - ]; + ]); } /** @@ -77,10 +80,10 @@ public function createDefaultArgs(Manager $manager) */ public function resolve($object, array $args, $context, ResolveInfo $info) { - // Get the args - $id = $args['ID']; - $rollbackVersion = $args['ToVersion']; - + list ($id, $rollbackVersion) = StaticSchema::inst()->extractKeys( + ['ID', 'ToVersion'], + $args + ); // Pull the latest version of the record /** @var Versioned|DataObject $record */ $record = Versioned::get_latest_version($this->getDataObjectClass(), $id); diff --git a/src/GraphQL/Operations/Unpublish.php b/src/GraphQL/_legacy/Operations/Unpublish.php similarity index 94% rename from src/GraphQL/Operations/Unpublish.php rename to src/GraphQL/_legacy/Operations/Unpublish.php index 5a85fb77..ee0647de 100644 --- a/src/GraphQL/Operations/Unpublish.php +++ b/src/GraphQL/_legacy/Operations/Unpublish.php @@ -13,6 +13,8 @@ /** * Scaffolds a generic update operation for DataObjects. + * + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. */ class Unpublish extends PublishOperation { diff --git a/src/GraphQL/Resolvers/ApplyVersionFilters.php b/src/GraphQL/_legacy/Resolvers/ApplyVersionFilters.php similarity index 89% rename from src/GraphQL/Resolvers/ApplyVersionFilters.php rename to src/GraphQL/_legacy/Resolvers/ApplyVersionFilters.php index 8b0fb50e..8f5bfc61 100644 --- a/src/GraphQL/Resolvers/ApplyVersionFilters.php +++ b/src/GraphQL/_legacy/Resolvers/ApplyVersionFilters.php @@ -2,11 +2,15 @@ namespace SilverStripe\GraphQL\Resolvers; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; use InvalidArgumentException; use DateTime; +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class ApplyVersionFilters { /** @@ -19,13 +23,16 @@ class ApplyVersionFilters */ public function applyToReadingState($versioningArgs) { - if (!isset($versioningArgs['Mode'])) { + list ($mode, $archiveDate) = StaticSchema::inst()->extractKeys( + ['Mode', 'ArchiveDate'], + $versioningArgs + ); + if (!$mode) { return; } $this->validateArgs($versioningArgs); - $mode = $versioningArgs['Mode']; switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: @@ -34,7 +41,7 @@ public function applyToReadingState($versioningArgs) Versioned::set_stage($mode); break; case 'archive': - $date = $versioningArgs['ArchiveDate']; + $date = $archiveDate; Versioned::set_reading_mode($mode); Versioned::reading_archived_date($date); break; @@ -59,13 +66,17 @@ public function applyToReadingState($versioningArgs) */ public function applyToList(&$list, $versioningArgs) { - if (!isset($versioningArgs['Mode'])) { + list ($mode, $date, $statuses, $version) = StaticSchema::inst()->extractKeys( + ['Mode', 'ArchiveDate', 'Status', 'Version'], + $versioningArgs + ); + + if (!$mode) { return; } $this->validateArgs($versioningArgs); - $mode = $versioningArgs['Mode']; switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: @@ -74,7 +85,6 @@ public function applyToList(&$list, $versioningArgs) ->setDataQueryParam('Versioned.stage', $mode); break; case 'archive': - $date = $versioningArgs['ArchiveDate']; $list = $list ->setDataQueryParam('Versioned.mode', 'archive') ->setDataQueryParam('Versioned.date', $date); @@ -89,7 +99,6 @@ 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']; // If we need to search archived records, we need to manually join draft table if (in_array('archived', $statuses)) { @@ -152,7 +161,7 @@ public function applyToList(&$list, $versioningArgs) // Note: Only valid for ReadOne $list = $list->setDataQueryParam([ "Versioned.mode" => 'version', - "Versioned.version" => $versioningArgs['Version'], + "Versioned.version" => $version, ]); break; default: @@ -166,20 +175,22 @@ public function applyToList(&$list, $versioningArgs) */ public function validateArgs($versioningArgs) { - $mode = $versioningArgs['Mode']; + list ($mode, $date, $status, $version) = StaticSchema::inst()->extractKeys( + ['Mode', 'ArchiveDate', 'Status', 'Version'], + $versioningArgs + ); switch ($mode) { case Versioned::LIVE: case Versioned::DRAFT: break; case 'archive': - if (empty($versioningArgs['ArchiveDate'])) { + if (empty($date)) { throw new InvalidArgumentException(sprintf( 'You must provide an ArchiveDate parameter when using the "%s" mode', $mode )); } - $date = $versioningArgs['ArchiveDate']; if (!$this->isValidDate($date)) { throw new InvalidArgumentException(sprintf( 'Invalid date: "%s". Must be YYYY-MM-DD format', @@ -192,7 +203,7 @@ public function validateArgs($versioningArgs) case 'latest_versions': break; case 'status': - if (empty($versioningArgs['Status'])) { + if (empty($status)) { throw new InvalidArgumentException(sprintf( 'You must provide a Status parameter when using the "%s" mode', $mode @@ -201,7 +212,7 @@ public function validateArgs($versioningArgs) break; case 'version': // Note: Only valid for ReadOne - if (!isset($versioningArgs['Version'])) { + if ($version === null) { throw new InvalidArgumentException( 'When using the "version" mode, you must specify a Version parameter' ); diff --git a/src/GraphQL/Types/CopyToStageInputType.php b/src/GraphQL/_legacy/Types/CopyToStageInputType.php similarity index 86% rename from src/GraphQL/Types/CopyToStageInputType.php rename to src/GraphQL/_legacy/Types/CopyToStageInputType.php index ce760d22..2a168328 100644 --- a/src/GraphQL/Types/CopyToStageInputType.php +++ b/src/GraphQL/_legacy/Types/CopyToStageInputType.php @@ -3,12 +3,16 @@ namespace SilverStripe\Versioned\GraphQL\Types; use GraphQL\Type\Definition\Type; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\GraphQL\TypeCreator; if (!class_exists(TypeCreator::class)) { return; } +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class CopyToStageInputType extends TypeCreator { /** @@ -31,7 +35,7 @@ public function attributes() */ public function fields() { - return [ + return StaticSchema::inst()->formatKeys([ 'ID' => [ 'type' => Type::nonNull(Type::id()), 'description' => 'The ID of the record to copy', @@ -48,6 +52,6 @@ public function fields() 'type' => Type::nonNull($this->manager->getType('VersionedStage')), 'description' => 'The destination stage to copy to', ], - ]; + ]); } } diff --git a/src/GraphQL/_legacy/Types/VersionSortType.php b/src/GraphQL/_legacy/Types/VersionSortType.php new file mode 100644 index 00000000..ba7a0eaa --- /dev/null +++ b/src/GraphQL/_legacy/Types/VersionSortType.php @@ -0,0 +1,62 @@ + 'VersionSortType', + ]; + } + + /** + * @return array + */ + public function fields() + { + return [ + 'version' => [ + 'type' => Injector::inst()->get(SortDirectionTypeCreator::class)->toType() + ] + ]; + } + + /** + * @return static + */ + public function toType() + { + if (!$this->type) { + $this->type = parent::toType(); + } + + return $this->type; + } +} diff --git a/src/GraphQL/Types/VersionedInputType.php b/src/GraphQL/_legacy/Types/VersionedInputType.php similarity index 86% rename from src/GraphQL/Types/VersionedInputType.php rename to src/GraphQL/_legacy/Types/VersionedInputType.php index b32b2959..59eb6767 100644 --- a/src/GraphQL/Types/VersionedInputType.php +++ b/src/GraphQL/_legacy/Types/VersionedInputType.php @@ -3,6 +3,7 @@ namespace SilverStripe\Versioned\GraphQL\Types; use GraphQL\Type\Definition\Type; +use SilverStripe\GraphQL\Scaffolding\StaticSchema; use SilverStripe\GraphQL\TypeCreator; use SilverStripe\Versioned\Versioned; @@ -10,6 +11,9 @@ return; } +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class VersionedInputType extends TypeCreator { /** @@ -32,7 +36,7 @@ public function attributes() */ public function fields() { - return [ + return StaticSchema::inst()->formatKeys([ 'Mode' => [ 'type' => $this->manager->getType('VersionedQueryMode'), 'defaultValue' => Versioned::DRAFT, @@ -48,6 +52,6 @@ public function fields() 'Version' => [ 'type' => Type::int(), ], - ]; + ]); } } diff --git a/src/GraphQL/Types/VersionedQueryMode.php b/src/GraphQL/_legacy/Types/VersionedQueryMode.php similarity index 95% rename from src/GraphQL/Types/VersionedQueryMode.php rename to src/GraphQL/_legacy/Types/VersionedQueryMode.php index fee10065..f184198c 100644 --- a/src/GraphQL/Types/VersionedQueryMode.php +++ b/src/GraphQL/_legacy/Types/VersionedQueryMode.php @@ -10,6 +10,9 @@ return; } +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class VersionedQueryMode extends TypeCreator { /** diff --git a/src/GraphQL/Types/VersionedStage.php b/src/GraphQL/_legacy/Types/VersionedStage.php similarity index 91% rename from src/GraphQL/Types/VersionedStage.php rename to src/GraphQL/_legacy/Types/VersionedStage.php index 74306076..041efe05 100644 --- a/src/GraphQL/Types/VersionedStage.php +++ b/src/GraphQL/_legacy/Types/VersionedStage.php @@ -10,6 +10,9 @@ return; } +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class VersionedStage extends TypeCreator { /** diff --git a/src/GraphQL/Types/VersionedStatus.php b/src/GraphQL/_legacy/Types/VersionedStatus.php similarity index 93% rename from src/GraphQL/Types/VersionedStatus.php rename to src/GraphQL/_legacy/Types/VersionedStatus.php index 1cef2dab..c7471335 100644 --- a/src/GraphQL/Types/VersionedStatus.php +++ b/src/GraphQL/_legacy/Types/VersionedStatus.php @@ -9,6 +9,9 @@ return; } +/** + * @deprecated 4.8..5.0 Use silverstripe/graphql:^4 functionality. + */ class VersionedStatus extends TypeCreator { /** diff --git a/tests/php/GraphQL/Operations/Rollback/FakeDataObjectStub.php b/tests/php/GraphQL/Fake/FakeDataObjectStub.php similarity index 91% rename from tests/php/GraphQL/Operations/Rollback/FakeDataObjectStub.php rename to tests/php/GraphQL/Fake/FakeDataObjectStub.php index 45a2ddc0..9da64613 100644 --- a/tests/php/GraphQL/Operations/Rollback/FakeDataObjectStub.php +++ b/tests/php/GraphQL/Fake/FakeDataObjectStub.php @@ -1,5 +1,5 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testDataObjectScaffolderAddsVersionedFields() { $manager = new Manager(); $manager->addType((new VersionedStage())->toType()); + $manager->addType((new VersionSortType())->toType()); $scaffolder = new DataObjectScaffolder(Fake::class); $scaffolder->addFields(['Name', 'Title']); $scaffolder->addToManager($manager); diff --git a/tests/php/GraphQL/Extensions/ReadExtensionTest.php b/tests/php/GraphQL/Legacy/Extensions/ReadExtensionTest.php similarity index 83% rename from tests/php/GraphQL/Extensions/ReadExtensionTest.php rename to tests/php/GraphQL/Legacy/Extensions/ReadExtensionTest.php index ed432f9b..947d94e1 100644 --- a/tests/php/GraphQL/Extensions/ReadExtensionTest.php +++ b/tests/php/GraphQL/Legacy/Extensions/ReadExtensionTest.php @@ -1,6 +1,6 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testReadExtensionAppliesFilters() { $mock = $this->getMockBuilder(ApplyVersionFilters::class) diff --git a/tests/php/GraphQL/Extensions/ReadOneExtensionTest.php b/tests/php/GraphQL/Legacy/Extensions/ReadOneExtensionTest.php similarity index 90% rename from tests/php/GraphQL/Extensions/ReadOneExtensionTest.php rename to tests/php/GraphQL/Legacy/Extensions/ReadOneExtensionTest.php index 68846fe7..a919220a 100644 --- a/tests/php/GraphQL/Extensions/ReadOneExtensionTest.php +++ b/tests/php/GraphQL/Legacy/Extensions/ReadOneExtensionTest.php @@ -1,6 +1,6 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testReadOneExtensionAppliesFilters() { $manager = new Manager(); diff --git a/tests/php/GraphQL/Extensions/SchemaScaffolderExtensionTest.php b/tests/php/GraphQL/Legacy/Extensions/SchemaScaffolderExtensionTest.php similarity index 78% rename from tests/php/GraphQL/Extensions/SchemaScaffolderExtensionTest.php rename to tests/php/GraphQL/Legacy/Extensions/SchemaScaffolderExtensionTest.php index 91b30777..cbae518f 100644 --- a/tests/php/GraphQL/Extensions/SchemaScaffolderExtensionTest.php +++ b/tests/php/GraphQL/Legacy/Extensions/SchemaScaffolderExtensionTest.php @@ -1,14 +1,16 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testSchemaScaffolderEnsuresMemberType() { $manager = new Manager(); $manager->addType((new VersionedStage())->toType()); + $manager->addType((new VersionSortType())->toType()); $memberType = StaticSchema::inst()->typeNameForDataObject(Member::class); $this->assertFalse($manager->hasType($memberType)); diff --git a/tests/php/GraphQL/Operations/CopyToStageTest.php b/tests/php/GraphQL/Legacy/Operations/CopyToStageTest.php similarity index 91% rename from tests/php/GraphQL/Operations/CopyToStageTest.php rename to tests/php/GraphQL/Legacy/Operations/CopyToStageTest.php index 84dc4628..7dffa3f3 100644 --- a/tests/php/GraphQL/Operations/CopyToStageTest.php +++ b/tests/php/GraphQL/Legacy/Operations/CopyToStageTest.php @@ -1,12 +1,13 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + + public function testCopyToStage() { $typeName = StaticSchema::inst()->typeNameForDataObject(Fake::class); diff --git a/tests/php/GraphQL/Operations/PublishTest.php b/tests/php/GraphQL/Legacy/Operations/PublishTest.php similarity index 87% rename from tests/php/GraphQL/Operations/PublishTest.php rename to tests/php/GraphQL/Legacy/Operations/PublishTest.php index 4ce81480..99f387db 100644 --- a/tests/php/GraphQL/Operations/PublishTest.php +++ b/tests/php/GraphQL/Legacy/Operations/PublishTest.php @@ -1,12 +1,13 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testPublish() { $typeName = StaticSchema::inst()->typeNameForDataObject(Fake::class); diff --git a/tests/php/GraphQL/Operations/ReadVersionsTest.php b/tests/php/GraphQL/Legacy/Operations/ReadVersionsTest.php similarity index 85% rename from tests/php/GraphQL/Operations/ReadVersionsTest.php rename to tests/php/GraphQL/Legacy/Operations/ReadVersionsTest.php index 259a31bd..651f89ba 100644 --- a/tests/php/GraphQL/Operations/ReadVersionsTest.php +++ b/tests/php/GraphQL/Legacy/Operations/ReadVersionsTest.php @@ -1,17 +1,19 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + + public function testItThrowsIfAppliedToAnUnversionedObject() { $manager = new Manager(); $manager->addType((new VersionedStage())->toType()); + $manager->addType((new VersionSortType())->toType()); $manager->addType(new ObjectType(['name' => 'Test'])); $readVersions = new ReadVersions(UnversionedWithField::class, 'Test'); $readVersions->setUsePagination(false); @@ -48,6 +60,7 @@ public function testItThrowsIfYouCantReadStages() { $manager = new Manager(); $manager->addType((new VersionedStage())->toType()); + $manager->addType((new VersionSortType())->toType()); $manager->addType(new ObjectType(['name' => 'Test'])); $readVersions = new ReadVersions(Fake::class, 'Test'); $readVersions->setUsePagination(false); @@ -68,6 +81,7 @@ public function testItReadsVersions() { $manager = new Manager(); $manager->addType((new VersionedStage())->toType()); + $manager->addType((new VersionSortType())->toType()); $manager->addType(new ObjectType(['name' => 'Test'])); $readVersions = new ReadVersions(Fake::class, 'Test'); $readVersions->setUsePagination(false); @@ -108,7 +122,7 @@ public function testVersionFieldIsSortable() if (!method_exists($operation, 'getSortableFields')) { $this->markTestSkipped('getSortableFields API is missing'); } - - $this->assertContains('Version', $operation->getSortableFields()); + $args = $operation->getArgs()->filter('argName', 'sort'); + $this->assertCount(1, $args); } } diff --git a/tests/php/GraphQL/Operations/RollbackTest.php b/tests/php/GraphQL/Legacy/Operations/RollbackTest.php similarity index 85% rename from tests/php/GraphQL/Operations/RollbackTest.php rename to tests/php/GraphQL/Legacy/Operations/RollbackTest.php index 377634a2..6454c179 100644 --- a/tests/php/GraphQL/Operations/RollbackTest.php +++ b/tests/php/GraphQL/Legacy/Operations/RollbackTest.php @@ -1,6 +1,6 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + /** * @expectedException InvalidArgumentException * @expectedExceptionMessage Current user does not have permission to roll back this resource diff --git a/tests/php/GraphQL/Operations/UnpublishTest.php b/tests/php/GraphQL/Legacy/Operations/UnpublishTest.php similarity index 88% rename from tests/php/GraphQL/Operations/UnpublishTest.php rename to tests/php/GraphQL/Legacy/Operations/UnpublishTest.php index 8a8436d2..f5729fac 100644 --- a/tests/php/GraphQL/Operations/UnpublishTest.php +++ b/tests/php/GraphQL/Legacy/Operations/UnpublishTest.php @@ -1,12 +1,13 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testPublish() { $typeName = StaticSchema::inst()->typeNameForDataObject(Fake::class); diff --git a/tests/php/GraphQL/Resolvers/ApplyVersionFiltersTest.php b/tests/php/GraphQL/Legacy/Resolvers/ApplyVersionFiltersTest.php similarity index 96% rename from tests/php/GraphQL/Resolvers/ApplyVersionFiltersTest.php rename to tests/php/GraphQL/Legacy/Resolvers/ApplyVersionFiltersTest.php index fdab2c0a..96f9a04a 100644 --- a/tests/php/GraphQL/Resolvers/ApplyVersionFiltersTest.php +++ b/tests/php/GraphQL/Legacy/Resolvers/ApplyVersionFiltersTest.php @@ -1,10 +1,11 @@ markTestSkipped('Skipped GraphQL 3 test ' . __CLASS__); + } + } + public function testItValidatesArchiveDate() { $filter = new ApplyVersionFilters(); diff --git a/tests/php/GraphQL/Plugins/VersionedDataObjectPluginTest.php b/tests/php/GraphQL/Plugins/VersionedDataObjectPluginTest.php new file mode 100644 index 00000000..795982ae --- /dev/null +++ b/tests/php/GraphQL/Plugins/VersionedDataObjectPluginTest.php @@ -0,0 +1,97 @@ +markTestSkipped('Skipped GraphQL 4 test ' . __CLASS__); + } + } + + public function testPluginAddsVersionedFields() + { + $model = DataObjectModel::create(Fake::class, new ModelConfiguration()); + $type = ModelType::create($model); + $type->addField('name'); + + $schema = new Schema('test'); + $schema->addModel($type); + $plugin = new VersionedDataObject(); + $plugin->updateSchema($schema); + $this->assertInstanceOf(ModelType::class, $schema->getModelByClassName(Member::class)); + + $plugin->apply($type, $schema); + $versionType = $schema->getType('FakeVersion'); + $this->assertInstanceOf(Type::class, $versionType); + + $fields = ['author', 'publisher', 'published', 'liveVersion', 'latestDraftVersion']; + foreach ($fields as $fieldName) { + $field = $versionType->getFieldByName($fieldName); + $this->assertInstanceOf(Field::class, $field, 'Field ' . $fieldName . ' not found'); + $this->assertEquals(VersionedResolver::class . '::resolveVersionFields', $field->getResolver()->toString()); + } + + $fields = ['version', 'name']; + foreach ($fields as $fieldName) { + $field = $type->getFieldByName($fieldName); + $this->assertInstanceOf(Field::class, $field, 'Field ' . $fieldName . ' not found'); + $this->assertEquals(Resolver::class . '::resolve', $field->getEncodedResolver()->getRef()->toString()); + } + + $this->assertInstanceOf(Field::class, $type->getFieldByName('versions')); + + $versions = $type->getFieldByName('versions'); + $this->assertTrue($versions->hasPlugin(SortPlugin::IDENTIFIER)); + $this->assertEquals(VersionedResolver::class . '::resolveVersionList', $versions->getEncodedResolver()->getRef()->toString()); + } + + public function testPluginDoesntAddVersionedFieldsToUnversionedObjects() + { + Fake::remove_extension(Versioned::class); + $type = ModelType::create(DataObjectModel::create(Fake::class, new ModelConfiguration())); + $type->addField('Name'); + + $schema = new Schema('test'); + $schema->addModel($type); + $plugin = new VersionedDataObject(); + $plugin->updateSchema($schema); + + $plugin->apply($type, $schema); + $type = $schema->getType('FakeVersion'); + $this->assertNull($type); + + Fake::add_extension(Versioned::class); + } +} diff --git a/tests/php/GraphQL/Plugins/VersionedReadTest.php b/tests/php/GraphQL/Plugins/VersionedReadTest.php new file mode 100644 index 00000000..918926bc --- /dev/null +++ b/tests/php/GraphQL/Plugins/VersionedReadTest.php @@ -0,0 +1,52 @@ +markTestSkipped('Skipped GraphQL 4 test ' . __CLASS__); + } + } + + public function testVersionedRead() + { + $model = DataObjectModel::create(Fake::class); + $query = ModelQuery::create($model, 'testQuery'); + $schema = new Schema('test'); + $plugin = new VersionedRead(); + $plugin->apply($query, $schema); + $this->assertCount(1, $query->getResolverAfterwares()); + $this->assertEquals( + VersionedResolver::class . '::resolveVersionedRead', + $query->getResolverAfterwares()[0]->getRef()->toString() + ); + $this->assertCount(1, $query->getArgs()); + $this->assertArrayHasKey('versioning', $query->getArgs()); + } +} diff --git a/tests/php/GraphQL/Resolvers/VersionedFiltersTest.php b/tests/php/GraphQL/Resolvers/VersionedFiltersTest.php new file mode 100644 index 00000000..16b1ccc7 --- /dev/null +++ b/tests/php/GraphQL/Resolvers/VersionedFiltersTest.php @@ -0,0 +1,285 @@ +markTestSkipped('Skipped GraphQL 4 test ' . __CLASS__); + } + } + + public function testItValidatesArchiveDate() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/ArchiveDate parameter/'); + $filter->validateArgs(['mode' => 'archive']); + } + + public function testItValidatesArchiveDateFormat() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Invalid date/'); + $filter->validateArgs(['mode' => 'archive', 'archiveDate' => '01/12/2018']); + } + + public function testItValidatesStatusParameter() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Status parameter/'); + $filter->validateArgs(['mode' => 'status']); + } + + public function testItValidatesVersionParameter() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Version parameter/'); + $filter->validateArgs(['mode' => 'version']); + } + + public function testItSetsReadingStateByMode() + { + Versioned::withVersionedMode(function () { + $filter = new VersionFilters(); + $filter->applyToReadingState(['mode' => Versioned::DRAFT]); + $this->assertEquals(Versioned::DRAFT, Versioned::get_stage()); + }); + } + + public function testItSetsReadingStateByArchiveDate() + { + Versioned::withVersionedMode(function () { + $filter = new VersionFilters(); + $filter->applyToReadingState(['mode' => 'archive', 'archiveDate' => '2018-01-01']); + $this->assertEquals('2018-01-01', Versioned::current_archived_date()); + }); + } + + public function testItFiltersByStageOnApplyToList() + { + $filter = new VersionFilters(); + $record1 = new Fake(); + $record1->Name = 'First version draft'; + $record1->write(); + + $record2 = new Fake(); + $record2->Name = 'First version live'; + $record2->write(); + $record2->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + + $list = Fake::get(); + $list = $filter->applyToList($list, ['mode' => Versioned::DRAFT]); + $this->assertCount(2, $list); + + $list = Fake::get(); + $list = $filter->applyToList($list, ['mode' => Versioned::LIVE]); + $this->assertCount(1, $list); + } + + public function testItThrowsIfArchiveAndNoDateOnApplyToList() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/ArchiveDate parameter/'); + $list = Fake::get(); + $filter->applyToList($list, ['mode' => 'archive']); + } + + public function testItThrowsIfArchiveAndInvalidDateOnApplyToList() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Invalid date/'); + $list = Fake::get(); + $filter->applyToList($list, ['mode' => 'archive', 'archiveDate' => 'foo']); + } + + + public function testItThrowsIfVersionAndNoVersionOnApplyToList() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Version parameter/'); + $list = Fake::get(); + $filter->applyToList($list, ['mode' => 'version']); + } + + public function testItSetsArchiveQueryParamsOnApplyToList() + { + $filter = new VersionFilters(); + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'archive', + 'archiveDate' => '2016-11-08', + ] + ); + + $this->assertEquals('archive', $list->dataQuery()->getQueryParam('Versioned.mode')); + $this->assertEquals('2016-11-08', $list->dataQuery()->getQueryParam('Versioned.date')); + } + + public function testItSetsVersionQueryParamsOnApplyToList() + { + $filter = new VersionFilters(); + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'version', + 'version' => '5', + ] + ); + + $this->assertEquals('version', $list->dataQuery()->getQueryParam('Versioned.mode')); + $this->assertEquals('5', $list->dataQuery()->getQueryParam('Versioned.version')); + } + + public function testItSetsLatestVersionQueryParamsOnApplyToList() + { + $filter = new VersionFilters(); + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'latest_versions', + ] + ); + + $this->assertEquals('latest_versions', $list->dataQuery()->getQueryParam('Versioned.mode')); + } + + public function testItSetsAllVersionsQueryParamsOnApplyToList() + { + $filter = new VersionFilters(); + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'all_versions', + ] + ); + + $this->assertEquals('all_versions', $list->dataQuery()->getQueryParam('Versioned.mode')); + } + + public function testItThrowsOnNoStatusOnApplyToList() + { + $filter = new VersionFilters(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageRegExp('/Status parameter/'); + $list = Fake::get(); + $filter->applyToList($list, ['mode' => 'status']); + } + + public function testStatusOnApplyToList() + { + $filter = new VersionFilters(); + $record1 = new Fake(); + $record1->Name = 'Only on draft'; + $record1->write(); + + $record2 = new Fake(); + $record2->Name = 'Published'; + $record2->write(); + $record2->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + + $record3 = new Fake(); + $record2->Name = 'Will be modified'; + $record3->write(); + $record3->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $record3->Name = 'Modified'; + $record3->write(); + + $record4 = new Fake(); + $record4->Name = 'Will be archived'; + $record4->write(); + $record4->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $oldID = $record4->ID; + $record4->delete(); + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'status', + 'status' => ['modified'] + ] + ); + $this->assertListEquals([['ID' => $record3->ID]], $list); + + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'status', + 'status' => ['archived'] + ] + ); + $this->assertCount(1, $list); + $this->assertEquals($oldID, $list->first()->ID); + + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'status', + 'status' => ['draft'] + ] + ); + + $this->assertCount(1, $list); + $ids = $list->column('ID'); + $this->assertContains($record1->ID, $ids); + + + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'status', + 'status' => ['draft', 'modified'] + ] + ); + + $this->assertCount(2, $list); + $ids = $list->column('ID'); + + $this->assertContains($record3->ID, $ids); + $this->assertContains($record1->ID, $ids); + + $list = Fake::get(); + $list = $filter->applyToList( + $list, + [ + 'mode' => 'status', + 'status' => ['archived', 'modified'] + ] + ); + + $this->assertCount(2, $list); + $ids = $list->column('ID'); + $this->assertTrue(in_array($record3->ID, $ids)); + $this->assertTrue(in_array($oldID, $ids)); + } +} diff --git a/tests/php/GraphQL/Resolvers/VersionedResolverTest.php b/tests/php/GraphQL/Resolvers/VersionedResolverTest.php new file mode 100644 index 00000000..4115244a --- /dev/null +++ b/tests/php/GraphQL/Resolvers/VersionedResolverTest.php @@ -0,0 +1,237 @@ +markTestSkipped('Skipped GraphQL 4 test ' . __CLASS__); + } + } + + public function testCopyToStage() + { + /* @var Fake|Versioned $record */ + $record = new Fake(); + $record->Name = 'First'; + $record->write(); // v1 + + $this->logInWithPermission('ADMIN'); + $member = Security::getCurrentUser(); + $resolve = VersionedResolver::resolveCopyToStage(['dataClass' => Fake::class]); + $resolve( + null, + [ + 'input' => [ + 'fromStage' => Versioned::DRAFT, + 'toStage' => Versioned::LIVE, + 'id' => $record->ID, + ], + ], + [ 'currentUser' => $member ], + new FakeResolveInfo() + ); + $recordLive = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + $this->assertNotNull($recordLive); + $this->assertEquals($record->ID, $recordLive->ID); + + $record->Name = 'Second'; + $record->write(); + $newVersion = $record->Version; + + $recordLive = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + $this->assertEquals('First', $recordLive->Title); + + // Invoke publish + $resolve( + null, + [ + 'input' => [ + 'fromVersion' => $newVersion, + 'toStage' => Versioned::LIVE, + 'id' => $record->ID, + ], + ], + [ 'currentUser' => $member ], + new FakeResolveInfo() + ); + $recordLive = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + $this->assertEquals('Second', $recordLive->Title); + + // Test error + $this->expectException(InvalidArgumentException::class); + $resolve( + null, + [ + 'input' => [ + 'toStage' => Versioned::DRAFT, + 'id' => $record->ID, + ], + ], + [ 'currentUser' => new Member() ], + new FakeResolveInfo() + ); + } + + public function testPublish() + { + $record = new Fake(); + $record->Name = 'First'; + $record->write(); + + $result = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + + $this->assertNull($result); + $this->logInWithPermission('ADMIN'); + $member = Security::getCurrentUser(); + $resolve = VersionedResolver::resolvePublishOperation([ + 'dataClass' => Fake::class, + 'action' => AbstractPublishOperationCreator::ACTION_PUBLISH + ]); + $resolve( + null, + [ + 'id' => $record->ID + ], + [ 'currentUser' => $member ], + new FakeResolveInfo() + ); + $result = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + + $this->assertNotNull($result); + $this->assertInstanceOf(Fake::class, $result); + $this->assertEquals('First', $result->Name); + + $this->expectException(Exception::class); + $this->expectExceptionMessageRegExp('/^Not allowed/'); + $resolve( + null, + [ + 'id' => $record->ID + ], + [ 'currentUser' => new Member() ], + new FakeResolveInfo() + ); + } + + public function testUnpublish() + { + $record = new Fake(); + $record->Name = 'First'; + $record->write(); + $record->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $result = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + + $this->assertNotNull($result); + $this->assertInstanceOf(Fake::class, $result); + $this->assertEquals('First', $result->Name); + + $this->logInWithPermission('ADMIN'); + $member = Security::getCurrentUser(); + $doResolve = VersionedResolver::resolvePublishOperation([ + 'dataClass' => Fake::class, + 'action' => AbstractPublishOperationCreator::ACTION_UNPUBLISH + ]); + $doResolve( + null, + [ + 'id' => $record->ID + ], + [ 'currentUser' => $member ], + new FakeResolveInfo() + ); + $result = Versioned::get_by_stage(Fake::class, Versioned::LIVE) + ->byID($record->ID); + + $this->assertNull($result); + + $record->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $this->expectException(Exception::class); + $this->expectExceptionMessageRegExp('/^Not allowed/'); + $doResolve( + null, + [ + 'id' => $record->ID + ], + [ 'currentUser' => new Member() ], + new FakeResolveInfo() + ); + } + + /** + * @expectedException InvalidArgumentException + * @expectedExceptionMessage Current user does not have permission to roll back this resource + */ + public function testRollbackCannotBePerformedWithoutEditPermission() + { + // Create a fake version of our stub + $stub = FakeDataObjectStub::create(); + $stub->Name = 'First'; + $stub->Editable = false; + $stub->write(); + + $this->doRollbackMutation($stub); + } + + public function testRollbackRecursiveIsCalled() + { + // Create a fake version of our stub + $stub = FakeDataObjectStub::create(); + $stub->Name = 'First'; + $stub->write(); + + $this->doRollbackMutation($stub); + + $this->assertTrue($stub::$rollbackCalled, 'RollbackRecursive was called'); + } + + protected function doRollbackMutation(DataObject $stub, $toVersion = 1, $member = null) + { + if (!$stub->isInDB()) { + $stub->write(); + } + + $doRollback = VersionedResolver::resolveRollback(['dataClass' => get_class($stub)]); + $args = [ + 'id' => $stub->ID, + 'toVersion' => $toVersion, + ]; + + $doRollback( + null, + $args, + [ 'currentUser' => $member ?: Security::getCurrentUser() ], + new FakeResolveInfo() + ); + } +}