diff --git a/src/Plugin/GraphQL/DataProducer/EntityLoadRevision.php b/src/Plugin/GraphQL/DataProducer/EntityLoadRevision.php new file mode 100644 index 0000000000000000000000000000000000000000..ec3c930ffa02957ac3a5cdaf3c2e634f644eb9c3 --- /dev/null +++ b/src/Plugin/GraphQL/DataProducer/EntityLoadRevision.php @@ -0,0 +1,196 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\graphql_compose\Plugin\GraphQL\DataProducer; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\TranslatableInterface; +use Drupal\Core\Entity\TranslatableRevisionableStorageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer; +use Drupal\graphql\GraphQL\Execution\FieldContext; +use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; +use GraphQL\Deferred; +use GraphQL\Error\UserError; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Loads the entity by revision. + * + * @DataProducer( + * id = "entity_load_revision", + * name = @Translation("Load entity revision"), + * description = @Translation("The entity belonging to the current url."), + * produces = @ContextDefinition("entity", + * label = @Translation("Entity"), + * ), + * consumes = { + * "entity" = @ContextDefinition("any", + * label = @Translation("The entity to load revisions from"), + * ), + * "identifier" = @ContextDefinition("any", + * label = @Translation("Revision ID"), + * required = FALSE, + * ), + * "language" = @ContextDefinition("string", + * label = @Translation("Language code"), + * required = FALSE, + * ), + * }, + * ) + */ +class EntityLoadRevision extends DataProducerPluginBase implements ContainerFactoryPluginInterface { + + /** + * The latest revision identifiers. + */ + const REVISION_LATEST = [ + 'latest', + 'newest', + 'working', + 'working-copy', + ]; + + /** + * The current revision identifiers. + */ + const REVISION_CURRENT = [ + 'active', + 'current', + 'default', + ]; + + /** + * Constructs a \Drupal\Component\Plugin\PluginBase object. + * + * @param array $configuration + * The plugin configuration array. + * @param string $pluginId + * The plugin id. + * @param mixed $pluginDefinition + * The plugin definition array. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The language manager service. + * @param \Drupal\graphql\GraphQL\Buffers\EntityRevisionBuffer $entityRevisionBuffer + * The entity revision buffer service. + * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager + * The language manager service. + */ + public function __construct( + array $configuration, + $pluginId, + $pluginDefinition, + protected EntityTypeManagerInterface $entityTypeManager, + protected EntityRevisionBuffer $entityRevisionBuffer, + protected LanguageManagerInterface $languageManager, + ) { + parent::__construct($configuration, $pluginId, $pluginDefinition); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('graphql.buffer.entity_revision'), + $container->get('language_manager'), + ); + } + + /** + * Resolve the entity revision. + * + * @param \Drupal\Core\Entity\EntityInterface|null $entity + * The entity to load revisions from. + * @param int|string|null $identifier + * The revision ID to load. + * @param string|null $language + * The language code to use. + * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context + * Cache context. + */ + public function resolve(?EntityInterface $entity, int|string|null $identifier, ?string $language, FieldContext $context): Deferred|EntityInterface|null { + + $identifier = $identifier ? strtolower((string) $identifier) : NULL; + + if (!$identifier || in_array($identifier, self::REVISION_CURRENT)) { + return $entity; + } + + if (!$entity instanceof RevisionableInterface) { + return $entity; + }; + + $entity_id = $entity->id(); + $entity_type_id = $entity->getEntityTypeId(); + + // We need a langcode for getLatestTranslationAffectedRevisionId(). + // Set the default langcode to the current context language. + // Fall back to the current language. + $langcode = $language + ?: $context->getContextLanguage() + ?: $this->languageManager->getCurrentLanguage()->getId(); + + // Quickly resolve the latest revision. + if (in_array($identifier, self::REVISION_LATEST)) { + /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($entity_type_id); + + $identifier = ($storage instanceof TranslatableRevisionableStorageInterface) + ? $storage->getLatestTranslationAffectedRevisionId($entity_id, $langcode) + : $storage->getLatestRevisionId($entity_id); + + // Did not get a valid revision identifier. + if (!$identifier) { + return NULL; + } + } + + // Add the entity to the buffer. + $resolver = $this->entityRevisionBuffer->add($entity_type_id, $identifier); + + return new Deferred(function () use ($resolver, $langcode, $entity_id, $entity_type_id, $context) { + + /** @var \Drupal\Core\Entity\RevisionableInterface|null $revision */ + if (!$revision = $resolver()) { + // Add cache list tags to invalidate the cache. + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id, FALSE); + if ($entity_type) { + $context->addCacheTags($entity_type->getListCacheTags()); + } + + $context->addCacheTags(['4xx-response']); + return NULL; + } + + // Check the revision belongs to the entity. + if ($revision->id() !== $entity_id) { + throw new UserError('The requested revision does not belong to the requested entity.'); + } + + $context->setContextValue('revision', $revision->getRevisionId()); + + // A specific language was requested. + // Ensure the revision is translated. + if ($langcode && $revision instanceof TranslatableInterface && $revision->hasTranslation($langcode) && $langcode !== $revision->language()->getId()) { + $revision = $revision->getTranslation($langcode); + $revision->addCacheContexts(["static:language:{$langcode}"]); + } + + // Check revision access. + $access = $revision->access('view', NULL, TRUE); + $context->addCacheableDependency($access); + + return $access->isAllowed() ? $revision : NULL; + }); + } + +} diff --git a/src/Plugin/GraphQLCompose/GraphQLComposeEntityTypeBase.php b/src/Plugin/GraphQLCompose/GraphQLComposeEntityTypeBase.php index 877b2d60c7dcfc632fb3b4b850028025ca3bbbf6..fc9d0b7373912786e16f36372528aa5c97d540d5 100644 --- a/src/Plugin/GraphQLCompose/GraphQLComposeEntityTypeBase.php +++ b/src/Plugin/GraphQLCompose/GraphQLComposeEntityTypeBase.php @@ -253,18 +253,26 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ * Register unions and interfaces only if there is multiple enabled bundles. */ public function registerTypes(): void { - $bundles = $this->getBundles(); if (!$bundles) { return; } - // Register a bundle types into the schema. + $this->registerEntityInterface(); + $this->registerEntityUnion(); + $this->registerEntityQuery(); + foreach ($bundles as $bundle) { $this->registerBundleTypes($bundle); + $this->registerBundleQueries($bundle); + $this->registerBundleFieldUnions($bundle); } + } - // Create generic entity wide interface. + /** + * Register a generic entity wide interface. + */ + protected function registerEntityInterface(): void { $interface_fields = $this->gqlFieldTypeManager->getInterfaceFields($this->getEntityTypeId()); if ($interface_fields) { $interface = new InterfaceType([ @@ -289,11 +297,15 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ $this->gqlSchemaTypeManager->add($interface); } + } - // Create generic entity wide union. + /** + * Register a generic entity wide union. + */ + protected function registerEntityUnion(): void { $union_types = array_map( fn(EntityTypeWrapper $bundle): string => $bundle->getTypeSdl(), - $bundles + $this->getBundles() ); $entity_union = new UnionType([ @@ -306,10 +318,14 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ ]); $this->gqlSchemaTypeManager->add($entity_union); + } - // Create generic entity wide query. + /** + * Register a generic entity wide query. + */ + protected function registerEntityQuery(): void { $enabled_query_bundles = array_filter( - $bundles, + $this->getBundles(), fn(EntityTypeWrapper $bundle) => $bundle->isQueryLoadEnabled() ); @@ -339,6 +355,10 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ 'type' => Type::string(), 'description' => (string) $this->t('Optionally set the response language. Eg en, ja, fr.'), ] : [], + 'revision' => $this->getEntityType()->isRevisionable() ? [ + 'type' => Type::id(), + 'description' => (string) $this->t('Optionally set the revision of the entity. Eg current, latest, or an ID.'), + ] : [], ]), ], ], @@ -386,8 +406,15 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ ]); $this->gqlSchemaTypeManager->add($entityType); + } - // Create bundle query. + /** + * Register individual bundle queries into the schema. + * + * @param \Drupal\graphql_compose\Wrapper\EntityTypeWrapper $bundle + * The bundle to register. + */ + protected function registerBundleQueries(EntityTypeWrapper $bundle): void { if (!$this->isQueryLoadSimple() && $bundle->isQueryLoadEnabled()) { $entityQuery = new ObjectType([ 'name' => 'Query', @@ -408,6 +435,10 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ 'type' => Type::string(), 'description' => (string) $this->t('Optionally set the response language. Eg en, ja, fr.'), ] : [], + 'revision' => $this->getEntityType()->isRevisionable() ? [ + 'type' => Type::id(), + 'description' => (string) $this->t('Optionally set the revision of the entity. Eg current, latest, or an ID.'), + ] : [], ]), ], ], @@ -415,6 +446,19 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ $this->gqlSchemaTypeManager->extend($entityQuery); } + } + + /** + * Register a bundle field union types into the schema. + * + * @param \Drupal\graphql_compose\Wrapper\EntityTypeWrapper $bundle + * The bundle to register. + */ + protected function registerBundleFieldUnions(EntityTypeWrapper $bundle): void { + $fields = $this->gqlFieldTypeManager->getBundleFields( + $this->getEntityTypeId(), + $bundle->getEntity()->id() + ); // Add per-field union types. foreach ($fields as $field_plugin) { @@ -456,23 +500,33 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ * Resolve unions only if there is multiple enabled bundles. */ public function registerResolvers(ResolverRegistryInterface $registry, ResolverBuilder $builder): void { - $bundles = $this->getBundles(); if (!$bundles) { return; } - $entity_class = $this->entityTypeManager - ->getDefinition($this->getEntityTypeId()) - ->getClass(); + $this->resolveEntityQuery($registry, $builder); + $this->resolveEntityUnion($registry, $builder); foreach ($bundles as $bundle) { - $this->registerBundleResolvers($registry, $builder, $bundle, $entity_class); + $this->resolveBundleTypes($registry, $builder, $bundle); + $this->resolveBundleQueries($registry, $builder, $bundle); + $this->resolveBundleFieldUnions($registry, $builder, $bundle); } + } + /** + * Resolve generic entity query. + * + * @param \Drupal\graphql\GraphQL\ResolverRegistryInterface $registry + * The resolver registry. + * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder + * The resolver builder. + */ + protected function resolveEntityQuery(ResolverRegistryInterface $registry, ResolverBuilder $builder): void { // Resolve generic load by id query. $enabled_query_bundles = array_filter( - $bundles, + $this->getBundles(), fn(EntityTypeWrapper $bundle) => $bundle->isQueryLoadEnabled() ); @@ -486,31 +540,51 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ $registry->addFieldResolver( 'Query', $this->getNameSdl(), - $builder->produce('entity_load_by_uuid_or_id') - ->map('type', $builder->fromValue($this->getEntityTypeId())) - ->map('bundles', $builder->fromValue($enabled_query_bundle_ids)) - ->map('identifier', $builder->fromArgument('id')) - ->map('language', $builder->fromArgument('langcode')) + $builder->compose( + $builder->produce('entity_load_by_uuid_or_id') + ->map('type', $builder->fromValue($this->getEntityTypeId())) + ->map('bundles', $builder->fromValue($enabled_query_bundle_ids)) + ->map('identifier', $builder->fromArgument('id')) + ->map('language', $builder->fromArgument('langcode')), + $builder->produce('entity_load_revision') + ->map('entity', $builder->fromParent()) + ->map('identifier', $builder->fromArgument('revision')) + ->map('language', $builder->fromArgument('langcode')) + ) ); } + } + + /** + * Resolve generic entity wide union. + * + * @param \Drupal\graphql\GraphQL\ResolverRegistryInterface $registry + * The resolver registry. + * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder + * The resolver builder. + */ + protected function resolveEntityUnion(ResolverRegistryInterface $registry, ResolverBuilder $builder): void { + // The expected class for the entity type. + $class = $this->entityTypeManager + ->getDefinition($this->getEntityTypeId()) + ->getClass(); // Resolve generic entity wide union. $registry->addTypeResolver( $this->getUnionTypeSdl(), - function ($value) use ($entity_class) { - if (!is_a($value, $entity_class, TRUE)) { - throw new UserError(sprintf('Could not resolve union entity type %s', $entity_class)); + function ($value) use ($class) { + if (!is_a($value, $class, TRUE)) { + throw new UserError(sprintf('Could not resolve union entity type %s', $class)); } $bundle = $this->getBundle($value->bundle()); if (!$bundle) { - throw new UserError(sprintf('Could not resolve union entity bundle %s::%s, is it enabled?', $entity_class, $value->bundle())); + throw new UserError(sprintf('Could not resolve union entity bundle %s::%s, is it enabled?', $class, $value->bundle())); } return $bundle->getTypeSdl(); } ); - } /** @@ -521,28 +595,31 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder * The resolver builder. * @param \Drupal\graphql_compose\Wrapper\EntityTypeWrapper $bundle - * The bundle to register. - * @param string $entity_class - * The bundle's base entity class. + * The bundle to resolve. */ - protected function registerBundleResolvers(ResolverRegistryInterface $registry, ResolverBuilder $builder, EntityTypeWrapper $bundle, string $entity_class): void { - // Add bundle type resolution. + protected function resolveBundleTypes(ResolverRegistryInterface $registry, ResolverBuilder $builder, EntityTypeWrapper $bundle): void { + + // The expected class for the entity type. + $class = $this->entityTypeManager + ->getDefinition($this->getEntityTypeId()) + ->getClass(); + $registry->addTypeResolver( $bundle->getTypeSdl(), - function ($value) use ($entity_class) { - if (!is_a($value, $entity_class, TRUE)) { - throw new UserError(sprintf('Could not resolve entity type %s', $entity_class)); + function ($value) use ($class) { + if (!is_a($value, $class, TRUE)) { + throw new UserError(sprintf('Could not resolve entity type %s', $class)); } return $this->getBundle($value->bundle())->getTypeSdl(); } ); + // Add fields to bundle type. $fields = $this->gqlFieldTypeManager->getBundleFields( $this->getEntityTypeId(), $bundle->getEntity()->id() ); - // Add fields to bundle type. foreach ($fields as $field_plugin) { $registry->addFieldResolver( $bundle->getTypeSdl(), @@ -553,19 +630,55 @@ abstract class GraphQLComposeEntityTypeBase extends PluginBase implements GraphQ ->map('value', $field_plugin->getProducers($builder)), ); } + } - // Enable loading by query with id. + /** + * Resolve bundle queries for the schema. + * + * @param \Drupal\graphql\GraphQL\ResolverRegistryInterface $registry + * The resolver registry. + * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder + * The resolver builder. + * @param \Drupal\graphql_compose\Wrapper\EntityTypeWrapper $bundle + * The bundle to resolve. + */ + protected function resolveBundleQueries(ResolverRegistryInterface $registry, ResolverBuilder $builder, EntityTypeWrapper $bundle): void { if (!$this->isQueryLoadSimple() && $bundle->isQueryLoadEnabled()) { $registry->addFieldResolver( 'Query', $bundle->getNameSdl(), - $builder->produce('entity_load_by_uuid_or_id') - ->map('type', $builder->fromValue($this->getEntityTypeId())) - ->map('bundles', $builder->fromValue([$bundle->getEntity()->id()])) - ->map('identifier', $builder->fromArgument('id')) - ->map('language', $builder->fromArgument('langcode')) + $builder->compose( + $builder->produce('entity_load_by_uuid_or_id') + ->map('type', $builder->fromValue($this->getEntityTypeId())) + ->map('bundles', $builder->fromValue([$bundle->getEntity()->id()])) + ->map('identifier', $builder->fromArgument('id')) + ->map('language', $builder->fromArgument('langcode')), + $builder->produce('entity_load_revision') + ->map('entity', $builder->fromParent()) + ->map('identifier', $builder->fromArgument('revision')) + ->map('language', $builder->fromArgument('langcode')) + ) ); } + } + + /** + * Resolve bundle field unions for the schema. + * + * @param \Drupal\graphql\GraphQL\ResolverRegistryInterface $registry + * The resolver registry. + * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder + * The resolver builder. + * @param \Drupal\graphql_compose\Wrapper\EntityTypeWrapper $bundle + * The bundle to register. + */ + protected function resolveBundleFieldUnions(ResolverRegistryInterface $registry, ResolverBuilder $builder, EntityTypeWrapper $bundle): void { + + // Get the bundle fields. + $fields = $this->gqlFieldTypeManager->getBundleFields( + $this->getEntityTypeId(), + $bundle->getEntity()->id() + ); // Add union field resolution for non-simple unions. foreach ($fields as $field_plugin) { diff --git a/tests/src/Functional/Core/EntityRevisionTest.php b/tests/src/Functional/Core/EntityRevisionTest.php new file mode 100644 index 0000000000000000000000000000000000000000..c4f4b79ebe3975b0a7595874779051863cb0aee3 --- /dev/null +++ b/tests/src/Functional/Core/EntityRevisionTest.php @@ -0,0 +1,461 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\graphql_compose\Functional\Core; + +use Drupal\Core\Language\LanguageInterface; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Entity\ContentLanguageSettings; +use Drupal\node\Entity\Node; +use Drupal\node\NodeInterface; +use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait; +use Drupal\Tests\graphql_compose\Functional\GraphQLComposeBrowserTestBase; +use Drupal\user\UserInterface; + +/** + * Test the entity version is loading as expected. + */ +class EntityRevisionTest extends GraphQLComposeBrowserTestBase { + use ContentModerationTestTrait; + + /** + * The node. + * + * @var \Drupal\node\NodeInterface + */ + protected NodeInterface $node; + + /** + * The privileged user. + * + * @var \Drupal\user\UserInterface + */ + protected UserInterface $privilegedUser; + + /** + * The revision ids. + * + * @var array + */ + protected array $revisionIds = []; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'workflows', + 'content_moderation', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->createContentType([ + 'type' => 'test', + 'name' => 'Node test type', + ]); + + $workflow = $this->createEditorialWorkflow(); + $this->addEntityTypeAndBundleToWorkflow($workflow, 'node', 'test'); + + // Create the initial revision. + $this->node = $this->createNode([ + 'type' => 'test', + 'title' => 'Test', + 'status' => 1, + 'moderation_state' => 'published', + ]); + + // Store some revisions for testing loading by id. + $this->revisionIds['A'] = $this->createNodeDraft($this->node, ['title' => 'Test A']); + $this->revisionIds['B'] = $this->createNodeDraft($this->node, ['title' => 'Test B']); + $this->revisionIds['C'] = $this->createNodeDraft($this->node, ['title' => 'Test C']); + + // Create the latest working english copy. + $this->createNodeDraft($this->node, ['title' => 'Test working-copy']); + + $this->setEntityConfig('node', 'test', [ + 'enabled' => TRUE, + 'query_load_enabled' => TRUE, + ]); + + // Create a user with the view all permissions. + $this->privilegedUser = $this->createUser([ + 'access content', + 'view any unpublished content', + 'view latest version', + ...$this->graphqlPermissions, + ]); + + node_access_rebuild(); + } + + /** + * Install the language modules and create some translations. + */ + private function setupLanguageModules(): void { + $modules = [ + 'content_translation', + 'config_translation', + 'language', + ]; + + $this->container->get('module_installer')->install($modules, TRUE); + + $this->resetAll(); + + ConfigurableLanguage::createFromLangcode('ja')->save(); + ConfigurableLanguage::createFromLangcode('de')->save(); + ConfigurableLanguage::createFromLangcode('abc')->save(); + + // Enable translations for the test node type. + ContentLanguageSettings::loadByEntityTypeBundle('node', 'test') + ->setDefaultLangcode(LanguageInterface::LANGCODE_SITE_DEFAULT) + ->setLanguageAlterable(TRUE) + ->save(); + + $this->node = Node::load($this->node->id()); + + // Enable translations for the test node type. + $this->node->addTranslation('ja', [ + 'title' => 'Test (JA)', + ])->save(); + + $this->node->addTranslation('de', [ + 'title' => 'Test (DE)', + ])->save(); + + // Create some translation drafts. + $this->createNodeDraft($this->node->getTranslation('ja'), ['title' => 'Test working-copy (JA)']); + + node_access_rebuild(); + } + + /** + * Creates a new node revision. + * + * @param \Drupal\node\NodeInterface $node + * The node. + * @param array $values + * The field values. + * + * @return int|string|null + * The revision id. + */ + private function createNodeDraft(NodeInterface $node, array $values): int|string|null { + foreach ($values as $field_name => $value) { + $node->set($field_name, $value); + } + $node->set('moderation_state', 'draft'); + $node->save(); + + return $node->getRevisionId(); + } + + /** + * Test load latest version of entity. + */ + public function testNodeLoadByLatest(): void { + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: "current") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertEquals('Test', $content['data']['node']['title']); + + // Enable languages and re-test. + $this->setupLanguageModules(); + + $content = $this->executeQuery($query); + $this->assertEquals('Test', $content['data']['node']['title']); + + // Login as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $content = $this->executeQuery($query); + $this->assertEquals('Test', $content['data']['node']['title']); + } + + /** + * Test loading revision by numeric id. + */ + public function testNodeNumericRevisions(): void { + foreach ($this->revisionIds as $key => $revisionId) { + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: {$revisionId}) { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + + // The working copy is not visible to the anonymous user. + $this->assertNull($content['data']['node']); + + // Login as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $content = $this->executeQuery($query); + $this->assertEquals('Test ' . $key, $content['data']['node']['title']); + + $this->drupalLogout(); + } + } + + /** + * Tests working copy retrieval. + */ + public function testNodeLoadByWorkingCopy(): void { + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: "latest") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + // The working copy is not visible to the anonymous user. + $content = $this->executeQuery($query); + $this->assertNull($content['data']['node']); + + // Login as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $content = $this->executeQuery($query); + $this->assertEquals('Test working-copy', $content['data']['node']['title']); + + // Enable languages and re-test. + $this->setupLanguageModules(); + + $content = $this->executeQuery($query); + $this->assertEquals('Test working-copy', $content['data']['node']['title']); + } + + /** + * Test load latest version of entity. + */ + public function testNodeLoadNoLangByLatest(): void { + + $this->setupLanguageModules(); + + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: "current", langcode: "abc") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertNull($content['data']['node']); + + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: "latest", langcode: "abc") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertNull($content['data']['node']); + } + + /** + * Test revision loading works with simple queries disabled. + */ + public function testNodeRevisionLoadNonSimpleQueries(): void { + $this->setConfig('settings.simple_queries', FALSE); + + $query_current = <<<GQL + query { + nodeTest(id: "{$this->node->uuid()}", revision: "current") { + id + title + } + } + GQL; + + $query_latest = <<<GQL + query { + nodeTest(id: "{$this->node->uuid()}", revision: "latest") { + id + title + } + } + GQL; + + $content = $this->executeQuery($query_current); + $this->assertEquals('Test', $content['data']['nodeTest']['title']); + + // Login as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $content = $this->executeQuery($query_latest); + $this->assertEquals('Test working-copy', $content['data']['nodeTest']['title']); + } + + /** + * Test incorrect revision id returns null. + */ + public function testNodeIncorrectRevisionIdReturnsNull(): void { + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: "90210") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertNull($content['data']['node']); + } + + /** + * Test incorrect revision id returns null. + */ + public function testNodeWrongRevisionIdReturnsNull(): void { + + // Login as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $new_node = $this->createNode([ + 'type' => 'test', + 'title' => 'Bonk', + 'status' => 1, + 'moderation_state' => 'published', + ]); + + $new_revision_id = $this->createNodeDraft($new_node, ['title' => 'Bunk']); + + // Use the wrong (valid) revision id on the node. + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", revision: {$new_revision_id}) { + ... on NodeInterface { + id + title + } + } + } + GQL; + + // Should be an error. + $content = $this->executeQuery($query); + $this->assertSame('The requested revision does not belong to the requested entity.', $content['errors'][0]['message']); + $this->assertNull($content['data']['node']); + + // Test the new node. + $query = <<<GQL + query { + node(id: "{$new_node->uuid()}", revision: {$new_revision_id}) { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertEquals('Bunk', $content['data']['node']['title']); + } + + /** + * Test latest version retrieval for translated content. + */ + public function testNodeLoadByLatestWithLangcode(): void { + + $this->setupLanguageModules(); + + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", langcode: "ja", revision: "current") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertEquals('Test (JA)', $content['data']['node']['title']); + } + + /** + * Test working-copy version retrieval for translated content. + */ + public function testNodeLoadByWorkingCopyWithLangcode(): void { + + $this->setupLanguageModules(); + + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", langcode: "ja", revision: "latest") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + // Now try as user with view all revision permission. + $this->drupalLogin($this->privilegedUser); + + $content = $this->executeQuery($query); + $this->assertEquals('Test working-copy (JA)', $content['data']['node']['title']); + } + + /** + * Test the latest translated revision is returned when no working-copy. + */ + public function testNodeLoadByWorkingCopyWithLangcodeFallback(): void { + + $this->setupLanguageModules(); + + $query = <<<GQL + query { + node(id: "{$this->node->uuid()}", langcode: "de", revision: "latest") { + ... on NodeInterface { + id + title + } + } + } + GQL; + + $content = $this->executeQuery($query); + $this->assertEquals('Test (DE)', $content['data']['node']['title']); + } + +}