From 8b362e0a680a54e4aea0fc6e755fa3fb2843c394 Mon Sep 17 00:00:00 2001 From: Philipp Melab Date: Mon, 19 Mar 2018 17:04:14 +0100 Subject: [PATCH] Contextual language handling. (#550) * Intermediate commit, not functional. * Don't abort context retrieval on falsy path elements. * Removed all implicit language contexts. * Contextual language negotiation. * Enforcing graphql language negotiation through config overrides. * Fixing language negotiator. * Fixed negotiation. * Ignore language cache contexts. * Added upgrade instructions for languages. * Docs fix. * Fix to also work without language module enabled. * Fixed wrong return, has to be yield. * Added core 8.5 tests. * Docs fix. * Moved contextual language to service. * Travis debug. * Removed travis debug. * Renamed test class according to file. * More tests and resulting fixes. * Simplified language context. * Explicit callable execution. * More fine grained tests. * Test negotiator initialization result. * Test cleanup. * Test disable negotiator fix. * Debugging ... * Debugging ... * Debugging ... * Debugging ... * Check negotiator instance. * Changed module initialization order. * Depending on language module. * Revert "Depending on language module." This reverts commit f8df099 * Revert "Changed module initialization order." This reverts commit bbaa77f * Proper service provider classname. * Properly injecting the language context. * Annotation style fixes. * Revert "Properly injecting the language context." This reverts commit c358b53 * Real setter injection. * Moved property to top. * Use language cache contexts to conditionally set the graphql language context. * Enabled multilingual features for all tests to catch potential context leaks. * Ignore language contexts when creating the cache key. * Reproducing leaking translation context. * Add language_content cache context to RouteEntity since the access handler emits it for some reason. * Fixed exception expectation. --- doc/upgrade/beta6.md | 27 ++ graphql.services.yml | 19 +- .../Deriver/Interfaces/EntityTypeDeriver.php | 4 - .../Deriver/Types/EntityBundleDeriver.php | 4 - .../Deriver/Types/EntityTypeDeriver.php | 4 - .../GraphQL/Fields/Entity/EntityById.php | 6 +- .../Fields/Entity/EntityRevisionById.php | 6 +- .../Fields/Entity/EntityTranslation.php | 2 +- .../Fields/Entity/EntityTranslations.php | 2 +- .../Plugin/GraphQL/Fields/EntityFieldBase.php | 7 + .../EntityQuery/EntityQueryEntities.php | 5 +- .../LanguageSwitch/LanguageSwitchLinks.php | 43 ++-- .../Plugin/GraphQL/Fields/Menu/MenuByName.php | 4 +- .../Plugin/GraphQL/Fields/Routing/Route.php | 34 ++- .../GraphQL/Fields/Routing/RouteEntity.php | 36 +-- .../GraphQL/Fields/Routing/Translate.php | 4 +- .../tests/src/Kernel/Blocks/BlockTest.php | 8 +- .../Kernel/Entity/EntityBasicFieldsTest.php | 26 +- .../src/Kernel/Entity/EntityByIdTest.php | 12 - .../Kernel/Entity/EntityFieldValueTest.php | 21 +- .../src/Kernel/GraphQLContentTestBase.php | 10 +- .../src/Kernel/Images/ImageFieldTest.php | 6 +- .../src/Kernel/Languages/LanguageTest.php | 11 +- .../tests/src/Kernel/Menu/MenuTest.php | 4 + .../src/Kernel/Routing/RouteEntityTest.php | 9 +- .../tests/src/Kernel/Routing/RouteTest.php | 1 - src/Annotation/GraphQLAnnotationBase.php | 2 +- src/Annotation/GraphQLField.php | 12 +- src/Config/GraphQLConfigOverrides.php | 70 ++++++ src/FixedLanguageNegotiator.php | 25 ++ src/GraphQL/Execution/QueryProcessor.php | 5 +- src/GraphQL/Execution/ResolveContext.php | 5 +- .../Visitors/CacheContextsCollector.php | 6 +- src/GraphQLLanguageContext.php | 88 +++++++ src/GraphqlServiceProvider.php | 26 ++ src/Plugin/Deriver/PluggableSchemaDeriver.php | 6 +- src/Plugin/GraphQL/Fields/FieldPluginBase.php | 59 ++++- .../LanguageNegotiationGraphQL.php | 64 +++++ .../Kernel/Extension/ResolveContextTest.php | 117 +++++++++ .../Kernel/Framework/LanguageContextTest.php | 236 ++++++++++++++++++ tests/src/Kernel/GraphQLTestBase.php | 12 + tests/src/Traits/MockGraphQLPluginTrait.php | 2 +- 42 files changed, 895 insertions(+), 155 deletions(-) create mode 100644 src/Config/GraphQLConfigOverrides.php create mode 100644 src/FixedLanguageNegotiator.php create mode 100644 src/GraphQLLanguageContext.php create mode 100644 src/GraphqlServiceProvider.php create mode 100644 src/Plugin/LanguageNegotiation/LanguageNegotiationGraphQL.php create mode 100644 tests/src/Kernel/Extension/ResolveContextTest.php create mode 100644 tests/src/Kernel/Framework/LanguageContextTest.php diff --git a/doc/upgrade/beta6.md b/doc/upgrade/beta6.md index edc1aaaa6..b187cff9d 100644 --- a/doc/upgrade/beta6.md +++ b/doc/upgrade/beta6.md @@ -2,6 +2,33 @@ ## Schema changes These changes affect you if you are using the schema automatically generated by the `graphql_core` module. + +### Language handling + +Multilingual queries changed drastically. The endpoints language negotiation is ignored entirely, instead all language handling is left to the query and its arguments. A couple of fields accept a "language" argument, and whenever this argument is filled explicitly, it's value will be inherited to subsequent occurrences. The `route` field will set this context implicitly from the paths language prefix. + +```graphql +query { + route(path: "/node/1") { + ... on EntityCanonicalUrl { + entity { + # Will emit the default language. + entityLabel + } + } + } + route(path: "/fr/node/1") { + ... on EntityCanonicalUrl { + entity { + # Will emit the french translation. + entityLabel + } + } + } +} + +``` + ### Url Interfaces The type structure of the `Url` object changed. While before there have been just the `InternalUrl` and `ExternalUrl` types, the `InternalUrl` has become an interface that can resolve to different Url types, depending on the underlying route. The `DefaultInternalUrl` has the fields for context resolving and other generic rout information. The `EntityCanonicalUrl` has access to the underlying entity. diff --git a/graphql.services.yml b/graphql.services.yml index ac6ff3355..ddf8608a0 100644 --- a/graphql.services.yml +++ b/graphql.services.yml @@ -244,8 +244,19 @@ services: # Buffers. graphql.buffer.entity: - class: Drupal\graphql\GraphQL\Buffers\EntityBuffer - arguments: ['@entity_type.manager'] + class: Drupal\graphql\GraphQL\Buffers\EntityBuffer + arguments: ['@entity_type.manager'] graphql.buffer.subrequest: - class: Drupal\graphql\GraphQL\Buffers\SubRequestBuffer - arguments: ['@http_kernel', '@request_stack'] + class: Drupal\graphql\GraphQL\Buffers\SubRequestBuffer + arguments: ['@http_kernel', '@request_stack'] + + graphql.language_context: + class: Drupal\graphql\GraphQLLanguageContext + arguments: ['@language_manager'] + + graphql.config_factory_override: + class: Drupal\graphql\Config\GraphQLConfigOverrides + arguments: ['@config.storage'] + tags: + - { name: config.factory.override, priority: -253 } + diff --git a/modules/graphql_core/src/Plugin/Deriver/Interfaces/EntityTypeDeriver.php b/modules/graphql_core/src/Plugin/Deriver/Interfaces/EntityTypeDeriver.php index dd2ff5e1e..d33c86c8d 100644 --- a/modules/graphql_core/src/Plugin/Deriver/Interfaces/EntityTypeDeriver.php +++ b/modules/graphql_core/src/Plugin/Deriver/Interfaces/EntityTypeDeriver.php @@ -39,10 +39,6 @@ public function getDerivativeDefinitions($basePluginDefinition) { $derivative['response_cache_contexts'][] = 'user.node_grants:view'; } - if ($type->isTranslatable()) { - $derivative['response_cache_contexts'][] = 'languages:language_content'; - } - $this->derivatives[$typeId] = $derivative; } diff --git a/modules/graphql_core/src/Plugin/Deriver/Types/EntityBundleDeriver.php b/modules/graphql_core/src/Plugin/Deriver/Types/EntityBundleDeriver.php index a711cc7f4..f79f718e7 100644 --- a/modules/graphql_core/src/Plugin/Deriver/Types/EntityBundleDeriver.php +++ b/modules/graphql_core/src/Plugin/Deriver/Types/EntityBundleDeriver.php @@ -90,10 +90,6 @@ public function getDerivativeDefinitions($basePluginDefinition) { $derivative['response_cache_contexts'][] = 'user.node_grants:view'; } - if ($type->isTranslatable()) { - $derivative['response_cache_contexts'][] = 'languages:language_content'; - } - $this->derivatives[$typeId . '-' . $bundle] = $derivative; } } diff --git a/modules/graphql_core/src/Plugin/Deriver/Types/EntityTypeDeriver.php b/modules/graphql_core/src/Plugin/Deriver/Types/EntityTypeDeriver.php index a947cc081..a2f5eddec 100644 --- a/modules/graphql_core/src/Plugin/Deriver/Types/EntityTypeDeriver.php +++ b/modules/graphql_core/src/Plugin/Deriver/Types/EntityTypeDeriver.php @@ -40,10 +40,6 @@ public function getDerivativeDefinitions($basePluginDefinition) { $derivative['response_cache_contexts'][] = 'user.node_grants:view'; } - if ($type->isTranslatable()) { - $derivative['response_cache_contexts'][] = 'languages:language_content'; - } - $this->derivatives[$typeId] = $derivative; } diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityById.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityById.php index e9f235728..2cc4b1cb0 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityById.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityById.php @@ -6,6 +6,7 @@ use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\TypedData\TranslatableInterface; use Drupal\graphql\GraphQL\Buffers\EntityBuffer; use Drupal\graphql\GraphQL\Cache\CacheableValue; use Drupal\graphql\GraphQL\Execution\ResolveContext; @@ -20,6 +21,7 @@ * arguments = { * "id" = "String!" * }, + * contextual_arguments = {"language"}, * deriver = "Drupal\graphql_core\Plugin\Deriver\Fields\EntityByIdDeriver" * ) */ @@ -109,8 +111,8 @@ protected function resolveValues($value, array $args, ResolveContext $context, R $access = $entity->access('view', NULL, TRUE); if ($access->isAllowed()) { - if (isset($args['language']) && $args['language'] != $entity->language()->getId()) { - $entity = $this->entityRepository->getTranslationFromContext($entity, $args['language']); + if (isset($args['language']) && $args['language'] != $entity->language()->getId() && $entity instanceof TranslatableInterface) { + $entity = $entity->getTranslation($args['language']); } yield $entity->addCacheableDependency($access); diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityRevisionById.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityRevisionById.php index 1961e3b62..c9900a286 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityRevisionById.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityRevisionById.php @@ -7,6 +7,7 @@ use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\TypedData\TranslatableInterface; use Drupal\graphql\GraphQL\Cache\CacheableValue; use Drupal\graphql\GraphQL\Execution\ResolveContext; use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; @@ -20,6 +21,7 @@ * arguments = { * "id" = "String!" * }, + * contextual_arguments = {"language"}, * deriver = "Drupal\graphql_core\Plugin\Deriver\Fields\EntityRevisionByIdDeriver" * ) */ @@ -98,8 +100,8 @@ protected function resolveValues($value, array $args, ResolveContext $context, R } /** @var \Drupal\Core\Access\AccessResultInterface $access */ else if (($access = $entity->access('view', NULL, TRUE)) && $access->isAllowed()) { - if (isset($args['language']) && $args['language'] != $entity->language()->getId()) { - $entity = $this->entityRepository->getTranslationFromContext($entity, $args['language']); + if ($entity instanceof TranslatableInterface && isset($args['language']) && $args['language'] != $entity->language()->getId()) { + $entity = $entity->getTranslation($args['language']); } yield new CacheableValue($entity, [$access]); diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslation.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslation.php index eba7fe29e..8213eb26a 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslation.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslation.php @@ -68,7 +68,7 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, */ public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) { if ($value instanceof EntityInterface && $value instanceof TranslatableInterface && $value->isTranslatable()) { - yield $this->entityRepository->getTranslationFromContext($value, $args['language']); + yield $value->getTranslation($args['language']); } } diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslations.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslations.php index a4cdbf64b..3973e74dc 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslations.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Entity/EntityTranslations.php @@ -72,7 +72,7 @@ public function resolveValues($value, array $args, ResolveContext $context, Reso if ($value instanceof ContentEntityInterface && $value instanceof TranslatableInterface && $value->isTranslatable()) { $languages = $value->getTranslationLanguages(); foreach ($languages as $language) { - yield $this->entityRepository->getTranslationFromContext($value, $language->getId()); + yield $value->getTranslation($language->getId()); } } } diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityFieldBase.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityFieldBase.php index f4c0b48cf..f41083a5d 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityFieldBase.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityFieldBase.php @@ -3,6 +3,9 @@ namespace Drupal\graphql_core\Plugin\GraphQL\Fields; use Drupal\Component\Render\MarkupInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\Entity; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Field\FieldItemInterface; use Drupal\graphql\GraphQL\Execution\ResolveContext; use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; @@ -31,6 +34,10 @@ protected function resolveItem($item, array $args, ResolveContext $context, Reso $result = $type->serialize($result); } + if ($result instanceof ContentEntityInterface && $result->isTranslatable() && $language = $context->getContext('language', $info)) { + $result = $result->getTranslation($language); + } + return $result; } } diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityQuery/EntityQueryEntities.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityQuery/EntityQueryEntities.php index 084f9bec8..8b8d35c1e 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityQuery/EntityQueryEntities.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/EntityQuery/EntityQueryEntities.php @@ -28,7 +28,8 @@ * parents = {"EntityQueryResult"}, * arguments = { * "language" = "LanguageId" - * } + * }, + * contextual_arguments = {"language"} * ) */ class EntityQueryEntities extends FieldPluginBase implements ContainerFactoryPluginInterface { @@ -195,7 +196,7 @@ protected function resolveEntities(array $entities, $metadata, array $args, Reso foreach ($entities as $entity) { // Translate the entity if it is translatable and a language was given. if ($language && $entity instanceof TranslatableInterface && $entity->isTranslatable()) { - $entity = $this->entityRepository->getTranslationFromContext($entity, $language); + yield $entity->getTranslation($language); } $access = $entity->access('view', NULL, TRUE); diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/LanguageSwitch/LanguageSwitchLinks.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/LanguageSwitch/LanguageSwitchLinks.php index 3cb86caac..89bb154e2 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/LanguageSwitch/LanguageSwitchLinks.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/LanguageSwitch/LanguageSwitchLinks.php @@ -7,7 +7,6 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\Core\Url; use Drupal\graphql\GraphQL\Buffers\SubRequestBuffer; -use Drupal\graphql\GraphQL\Cache\CacheableValue; use Drupal\graphql\GraphQL\Execution\ResolveContext; use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -20,10 +19,14 @@ * name = "languageSwitchLinks", * type = "[LanguageSwitchLink]", * parents = {"InternalUrl"}, + * arguments = { + * "language" = "LanguageId" + * }, * response_cache_contexts = { * "languages:language_url", - * "languages:language_interface" - * } + * "languages:language_interface", + * }, + * contextual_arguments = {"language"} * ) */ class LanguageSwitchLinks extends FieldPluginBase implements ContainerFactoryPluginInterface { @@ -75,29 +78,23 @@ public function __construct( */ protected function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) { if ($value instanceof Url) { - $resolve = $this->subRequestBuffer->add($value, function (Url $url) { - $links = $this->languageManager->getLanguageSwitchLinks(LanguageInterface::TYPE_URL, $url); - $current = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL); - return [$current, $links]; - }); + $links = $this->languageManager->getLanguageSwitchLinks(LanguageInterface::TYPE_URL, $value); + $current = $this->languageManager->getLanguage($args['language']); + if (!$current) { + $current = $this->languageManager->getDefaultLanguage(); + } - return function () use ($resolve) { - /** @var \Drupal\graphql\GraphQL\Cache\CacheableValue $response */ - $response = $resolve(); - list($current, $links) = $response->getValue(); - - if (!empty($links->links)) { - foreach ($links->links as $link) { - // Yield the link array and the language object of the language - // context resolved from the sub-request. - yield new CacheableValue([ - 'link' => $link, - 'context' => $current, - ], [$response]); - } + if (!empty($links->links)) { + foreach ($links->links as $link) { + // Yield the link array and the language object of the language + // context resolved from the sub-request. + yield [ + 'link' => $link, + 'context' => $current, + ]; } - }; + } } } diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Menu/MenuByName.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Menu/MenuByName.php index 8f2615b44..7075b8e45 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Menu/MenuByName.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Menu/MenuByName.php @@ -3,6 +3,7 @@ namespace Drupal\graphql_core\Plugin\GraphQL\Fields\Menu; use Drupal\Core\DependencyInjection\DependencySerializationTrait; +use Drupal\Core\Entity\EntityType; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\graphql\GraphQL\Execution\ResolveContext; @@ -22,7 +23,8 @@ * type = "Menu", * arguments = { * "name" = "String!" - * } + * }, + * response_cache_contexts = {"languages:language_interface"} * ) */ class MenuByName extends FieldPluginBase implements ContainerFactoryPluginInterface { diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Route.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Route.php index e971adc86..cae3be5a3 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Route.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Route.php @@ -2,15 +2,15 @@ namespace Drupal\graphql_core\Plugin\GraphQL\Fields\Routing; -use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Path\PathValidatorInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Url; use Drupal\graphql\GraphQL\Cache\CacheableValue; use Drupal\graphql\GraphQL\Execution\ResolveContext; use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; +use Drupal\language\LanguageNegotiator; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; /** * Retrieve a route object based on a path. @@ -35,6 +35,13 @@ class Route extends FieldPluginBase implements ContainerFactoryPluginInterface { */ protected $pathValidator; + /** + * The language negotiator service. + * + * @var \Drupal\language\LanguageNegotiator + */ + protected $languageNegotiator; + /** * {@inheritdoc} */ @@ -43,7 +50,8 @@ public static function create(ContainerInterface $container, array $configuratio $configuration, $plugin_id, $plugin_definition, - $container->get('path.validator') + $container->get('path.validator'), + $container->has('language_negotiator') ? $container->get('language_negotiator') : NULL ); } @@ -58,10 +66,19 @@ public static function create(ContainerInterface $container, array $configuratio * The plugin definition. * @param \Drupal\Core\Path\PathValidatorInterface $pathValidator * The path validator service. + * @param \Drupal\language\LanguageNegotiator|null $languageNegotiator + * The language negotiator. */ - public function __construct(array $configuration, $pluginId, $pluginDefinition, PathValidatorInterface $pathValidator) { + public function __construct( + array $configuration, + $pluginId, + $pluginDefinition, + PathValidatorInterface $pathValidator, + $languageNegotiator + ) { parent::__construct($configuration, $pluginId, $pluginDefinition); $this->pathValidator = $pathValidator; + $this->languageNegotiator = $languageNegotiator; } /** @@ -69,7 +86,16 @@ public function __construct(array $configuration, $pluginId, $pluginDefinition, */ public function resolveValues($value, array $args, ResolveContext $context, ResolveInfo $info) { if (($url = $this->pathValidator->getUrlIfValidWithoutAccessCheck($args['path'])) && $url->access()) { + + // For now we just take the "url" negotiator into account. + if ($this->languageNegotiator) { + if ($negotiator = $this->languageNegotiator->getNegotiationMethodInstance('language-url')) { + $context->setContext('language', $negotiator->getLangcode(Request::create($args['path'])), $info); + } + } + yield $url; + } else { yield (new CacheableValue(NULL))->addCacheTags(['4xx-response']); diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/RouteEntity.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/RouteEntity.php index 3eb46ebce..7e9f73c61 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/RouteEntity.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/RouteEntity.php @@ -15,6 +15,7 @@ use Drupal\graphql\GraphQL\Cache\CacheableValue; use Drupal\graphql\GraphQL\Execution\ResolveContext; use Drupal\graphql\Plugin\GraphQL\Fields\FieldPluginBase; +use Symfony\Component\CssSelector\Parser\Tokenizer\TokenizerEscaping; use Symfony\Component\DependencyInjection\ContainerInterface; use GraphQL\Type\Definition\ResolveInfo; @@ -26,7 +27,9 @@ * secure = true, * name = "entity", * description = @Translation("The entity belonging to the current url."), + * response_cache_contexts={"languages:language_content"}, * parents = {"EntityCanonicalUrl"}, + * contextual_arguments={"language"}, * type = "Entity" * ) */ @@ -47,13 +50,6 @@ class RouteEntity extends FieldPluginBase implements ContainerFactoryPluginInter */ protected $languageManager; - /** - * The sub-request buffer service. - * - * @var \Drupal\graphql\GraphQL\Buffers\SubRequestBuffer - */ - protected $subrequestBuffer; - /** * The entity repository service. * @@ -71,8 +67,7 @@ public static function create(ContainerInterface $container, array $configuratio $pluginDefinition, $container->get('entity_type.manager'), $container->get('entity.repository'), - $container->get('language_manager'), - $container->get('graphql.buffer.subrequest') + $container->get('language_manager') ); } @@ -91,7 +86,6 @@ public static function create(ContainerInterface $container, array $configuratio * The entity repository service. * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager * The language manager service. - * @param \Drupal\graphql\GraphQL\Buffers\SubRequestBuffer $subrequestBuffer */ public function __construct( array $configuration, @@ -99,13 +93,11 @@ public function __construct( $pluginDefinition, EntityTypeManagerInterface $entityTypeManager, EntityRepositoryInterface $entityRepository, - LanguageManagerInterface $languageManager, - SubRequestBuffer $subrequestBuffer + LanguageManagerInterface $languageManager ) { parent::__construct($configuration, $pluginId, $pluginDefinition); $this->entityTypeManager = $entityTypeManager; $this->languageManager = $languageManager; - $this->subrequestBuffer = $subrequestBuffer; $this->entityRepository = $entityRepository; } @@ -164,21 +156,13 @@ protected function resolveEntity(EntityInterface $entity, Url $url, array $args, * @param \GraphQL\Type\Definition\ResolveInfo $info * The resolve info object. * - * @return \Closure + * @return \Iterator */ protected function resolveEntityTranslation(EntityInterface $entity, Url $url, array $args, ResolveInfo $info) { - $resolve = $this->subrequestBuffer->add($url, function () { - return $this->languageManager->getCurrentLanguage(Language::TYPE_CONTENT)->getId(); - }); - - return function ($value, array $args, ResolveContext $context, ResolveInfo $info) use ($resolve, $entity) { - /** @var \Drupal\graphql\GraphQL\Cache\CacheableValue $response */ - $response = $resolve(); - $entity = $this->entityRepository->getTranslationFromContext($entity, $response->getValue()); - $entity->addCacheableDependency($response); - - return $this->resolveEntity($entity, $value, $args, $info); - }; + if ($entity instanceof TranslatableInterface && isset($args['language'])) { + $entity = $entity->getTranslation($args['language']); + } + return $this->resolveEntity($entity, $url, $args, $info); } /** diff --git a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Translate.php b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Translate.php index bdd6c2c2a..16b55f00e 100644 --- a/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Translate.php +++ b/modules/graphql_core/src/Plugin/GraphQL/Fields/Routing/Translate.php @@ -19,10 +19,10 @@ * description = @Translation("The translated url object."), * type = "Url", * parents = {"Url"}, - * response_cache_contexts={"languages:language_url"}, * arguments = { * "language" = "LanguageId!" - * } + * }, + * contextual_arguments = {"language"} * ) */ class Translate extends FieldPluginBase implements ContainerFactoryPluginInterface { diff --git a/modules/graphql_core/tests/src/Kernel/Blocks/BlockTest.php b/modules/graphql_core/tests/src/Kernel/Blocks/BlockTest.php index 208c4b530..431d48add 100644 --- a/modules/graphql_core/tests/src/Kernel/Blocks/BlockTest.php +++ b/modules/graphql_core/tests/src/Kernel/Blocks/BlockTest.php @@ -67,10 +67,6 @@ public function testStaticBlocks() { $query = $this->getQueryFromFile('Blocks/blocks.gql'); $metadata = $this->defaultCacheMetaData(); - $metadata->addCacheContexts([ - 'languages:language_content', - ]); - // TODO: Check cache metadata. $metadata->addCacheTags([ 'config:block_list', @@ -80,6 +76,10 @@ public function testStaticBlocks() { 'entity_field_info', ]); + $metadata->addCacheContexts([ + 'languages:language_interface', + ]); + $this->assertResults($query, [], [ 'route' => [ 'content' => [ diff --git a/modules/graphql_core/tests/src/Kernel/Entity/EntityBasicFieldsTest.php b/modules/graphql_core/tests/src/Kernel/Entity/EntityBasicFieldsTest.php index 71b08be51..9cc24c9e1 100644 --- a/modules/graphql_core/tests/src/Kernel/Entity/EntityBasicFieldsTest.php +++ b/modules/graphql_core/tests/src/Kernel/Entity/EntityBasicFieldsTest.php @@ -12,24 +12,6 @@ */ class EntityBasicFieldsTest extends GraphQLContentTestBase { - public static $modules = [ - 'language', - 'content_translation', - ]; - - /** - * {@inheritdoc} - */ - protected function setUp() { - parent::setUp(); - - $language = $this->container->get('entity.manager')->getStorage('configurable_language')->create([ - 'id' => 'fr', - ]); - - $language->save(); - } - /** * Set the prophesized permissions. * @@ -79,10 +61,9 @@ public function testBasicFields() { 'entityLabel' => $user->label(), ], // TODO: Fix this. - 'entityTranslation' => NULL, -// 'entityTranslation' => [ -// 'entityLabel' => $translation->label(), -// ], + 'entityTranslation' => [ + 'entityLabel' => $translation->label(), + ], 'entityPublished' => TRUE, 'entityCreated' => $created, 'entityChanged' => $changed, @@ -96,7 +77,6 @@ public function testBasicFields() { // TODO: Check cache metadata. $metadata = $this->defaultCacheMetaData(); $metadata->addCacheContexts([ -// 'languages:language_content', 'user.node_grants:view', ]); diff --git a/modules/graphql_core/tests/src/Kernel/Entity/EntityByIdTest.php b/modules/graphql_core/tests/src/Kernel/Entity/EntityByIdTest.php index dfb4daced..3ba7bbcf9 100644 --- a/modules/graphql_core/tests/src/Kernel/Entity/EntityByIdTest.php +++ b/modules/graphql_core/tests/src/Kernel/Entity/EntityByIdTest.php @@ -11,14 +11,6 @@ */ class EntityByIdTest extends GraphQLContentTestBase { - /** - * {@inheritdoc} - */ - public static $modules = [ - 'language', - 'content_translation', - ]; - /** * The added French language. * @@ -41,10 +33,6 @@ protected function setUp() { /** @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface $languageStorage */ $languageStorage = $this->container->get('entity.manager')->getStorage('configurable_language'); - $language = $languageStorage->create([ - 'id' => $this->frenchLangcode, - ]); - $language->save(); $language = $languageStorage->create([ 'id' => $this->chineseSimplifiedLangcode, diff --git a/modules/graphql_core/tests/src/Kernel/Entity/EntityFieldValueTest.php b/modules/graphql_core/tests/src/Kernel/Entity/EntityFieldValueTest.php index 5e6ae7c14..6903b1335 100644 --- a/modules/graphql_core/tests/src/Kernel/Entity/EntityFieldValueTest.php +++ b/modules/graphql_core/tests/src/Kernel/Entity/EntityFieldValueTest.php @@ -65,12 +65,17 @@ public function testBoolean() { GQL; $metadata = $this->defaultCacheMetaData(); + + $metadata->addCacheTags([ 'config:field.storage.node.field_boolean', 'entity_field_info', ]); - $metadata->addCacheContexts(['user.node_grants:view']); + $metadata->addCacheContexts([ + 'user.node_grants:view', + 'languages:language_interface', + ]); $this->assertResults($query, [], [ 'node' => [ @@ -115,7 +120,10 @@ public function testText() { 'entity_field_info', ]); - $metadata->addCacheContexts(['user.node_grants:view']); + $metadata->addCacheContexts([ + 'user.node_grants:view', + 'languages:language_interface', + ]); $this->assertResults($query, [], [ 'node' => [ @@ -167,6 +175,9 @@ public function testFilteredText() { 'config:field.storage.node.body', 'entity_field_info', ]); + $metadata->addCacheContexts([ + 'languages:language_interface', + ]); $metadata->addCacheContexts(['user.node_grants:view']); @@ -255,7 +266,11 @@ public function testRawValues($actualFieldValues, $expectedFieldValues) { 'file:2', ]); - $metadata->addCacheContexts(['user.node_grants:view']); + $metadata->addCacheContexts([ + 'languages:language_interface', + 'languages:language_content', + 'user.node_grants:view', + ]); $this->assertResults($this->getQueryFromFile('raw_field_values.gql'), [ 'path' => '/node/' . $node->id(), diff --git a/modules/graphql_core/tests/src/Kernel/GraphQLContentTestBase.php b/modules/graphql_core/tests/src/Kernel/GraphQLContentTestBase.php index 1ea143737..1b9533cf5 100644 --- a/modules/graphql_core/tests/src/Kernel/GraphQLContentTestBase.php +++ b/modules/graphql_core/tests/src/Kernel/GraphQLContentTestBase.php @@ -23,18 +23,13 @@ class GraphQLContentTestBase extends GraphQLCoreTestBase { * {@inheritdoc} */ public static $modules = [ + 'content_translation', 'node', 'field', 'filter', 'text', ]; - protected function defaultCacheContexts() { - return array_merge([ - 'languages:language_content', - ], parent::defaultCacheContexts()); - } - /** * {@inheritdoc} */ @@ -71,6 +66,9 @@ protected function setUp() { $this->createContentType([ 'type' => 'test', ]); + + $this->container->get('content_translation.manager') + ->setEnabled('node', 'test', TRUE); } /** diff --git a/modules/graphql_core/tests/src/Kernel/Images/ImageFieldTest.php b/modules/graphql_core/tests/src/Kernel/Images/ImageFieldTest.php index 88e23747d..2d4286cea 100644 --- a/modules/graphql_core/tests/src/Kernel/Images/ImageFieldTest.php +++ b/modules/graphql_core/tests/src/Kernel/Images/ImageFieldTest.php @@ -64,7 +64,11 @@ public function testImageField() { 'node:1', ]); - $metadata->addCacheContexts(['user.node_grants:view']); + $metadata->addCacheContexts([ + 'user.node_grants:view', + 'languages:language_interface', + 'languages:language_content', + ]); $this->assertResults($this->getQueryFromFile('image.gql'), [ 'path' => '/node/' . $a->id(), diff --git a/modules/graphql_core/tests/src/Kernel/Languages/LanguageTest.php b/modules/graphql_core/tests/src/Kernel/Languages/LanguageTest.php index a0a9e25d1..158b8d5ea 100644 --- a/modules/graphql_core/tests/src/Kernel/Languages/LanguageTest.php +++ b/modules/graphql_core/tests/src/Kernel/Languages/LanguageTest.php @@ -29,11 +29,6 @@ protected function setUp() { $this->installEntitySchema('configurable_language'); $this->container->get('router.builder')->rebuild(); - ConfigurableLanguage::create([ - 'id' => 'fr', - 'weight' => 1, - ])->save(); - ConfigurableLanguage::create([ 'id' => 'es', 'weight' => 2, @@ -106,7 +101,6 @@ public function testLanguageId() { public function testLanguageSwitchLinks() { // TODO: Check cache metadata. $metadata = $this->defaultCacheMetaData(); - $metadata->addCacheContexts(['languages:language_url', 'languages:language_interface']); $metadata->addCacheTags([ 'config:language.entity.en', 'config:language.entity.es', @@ -114,6 +108,11 @@ public function testLanguageSwitchLinks() { 'config:language.entity.pt-br', ]); + $metadata->addCacheContexts([ + 'languages:language_interface', + 'languages:language_url', + ]); + $this->assertResults($this->getQueryFromFile('language_switch_links.gql'), [], [ 'route' => [ 'links' => [ diff --git a/modules/graphql_core/tests/src/Kernel/Menu/MenuTest.php b/modules/graphql_core/tests/src/Kernel/Menu/MenuTest.php index 592de66cb..930d9a85a 100644 --- a/modules/graphql_core/tests/src/Kernel/Menu/MenuTest.php +++ b/modules/graphql_core/tests/src/Kernel/Menu/MenuTest.php @@ -78,6 +78,10 @@ public function testMenuTree() { 'config:system.menu.test', ]); + $metadata->addCacheContexts([ + "languages:language_interface", + ]); + $this->assertResults( $this->getQueryFromFile('menu.gql'), [], diff --git a/modules/graphql_core/tests/src/Kernel/Routing/RouteEntityTest.php b/modules/graphql_core/tests/src/Kernel/Routing/RouteEntityTest.php index e83ea276b..898ee54d4 100644 --- a/modules/graphql_core/tests/src/Kernel/Routing/RouteEntityTest.php +++ b/modules/graphql_core/tests/src/Kernel/Routing/RouteEntityTest.php @@ -19,6 +19,10 @@ public function testRouteEntity() { $node->save(); + $node->addTranslation('fr', [ + 'title' => 'Node A french', + ])->save(); + $query = $this->getQueryFromFile('route_entity.gql'); $vars = ['path' => '/node/' . $node->id()]; @@ -29,7 +33,10 @@ public function testRouteEntity() { 'node:1', ]); - $metadata->addCacheContexts(['user.node_grants:view']); + $metadata->addCacheContexts([ + 'user.node_grants:view', + 'languages:language_content', + ]); $this->assertResults($query, $vars, [ 'route' => [ diff --git a/modules/graphql_core/tests/src/Kernel/Routing/RouteTest.php b/modules/graphql_core/tests/src/Kernel/Routing/RouteTest.php index 067de48cc..0ba3c85e7 100644 --- a/modules/graphql_core/tests/src/Kernel/Routing/RouteTest.php +++ b/modules/graphql_core/tests/src/Kernel/Routing/RouteTest.php @@ -2,7 +2,6 @@ namespace Drupal\Tests\graphql_core\Kernel\Routing; -use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\GeneratedUrl; use Drupal\Core\Path\AliasManagerInterface; use Drupal\Core\Routing\UrlGeneratorInterface; diff --git a/src/Annotation/GraphQLAnnotationBase.php b/src/Annotation/GraphQLAnnotationBase.php index 89efeaf5e..63f482e83 100644 --- a/src/Annotation/GraphQLAnnotationBase.php +++ b/src/Annotation/GraphQLAnnotationBase.php @@ -53,7 +53,7 @@ abstract class GraphQLAnnotationBase extends Plugin { * * @var array */ - public $schema_cache_contexts = ['languages:language_interface']; + public $schema_cache_contexts = []; /** * The cache tags for caching the type system definition in the schema. diff --git a/src/Annotation/GraphQLField.php b/src/Annotation/GraphQLField.php index 9e987bf5e..dd3736d26 100644 --- a/src/Annotation/GraphQLField.php +++ b/src/Annotation/GraphQLField.php @@ -2,8 +2,6 @@ namespace Drupal\graphql\Annotation; -use Drupal\Core\Cache\CacheBackendInterface; - /** * Annotation for GraphQL field plugins. * @@ -54,6 +52,16 @@ class GraphQLField extends GraphQLAnnotationBase { */ public $arguments = []; + /** + * Contextual arguments. + * + * List of argument identifiers that will be merged with the current query + * context. + * + * @var string[] + */ + public $contextual_arguments = []; + /** * The deprecation reason or FALSE if the field is not deprecated. * diff --git a/src/Config/GraphQLConfigOverrides.php b/src/Config/GraphQLConfigOverrides.php new file mode 100644 index 000000000..504b145ce --- /dev/null +++ b/src/Config/GraphQLConfigOverrides.php @@ -0,0 +1,70 @@ +baseStorage = $storage; + } + + /** + * {@inheritdoc} + */ + public function loadOverrides($names) { + if (in_array('language.types', $names)) { + if ($config = $this->baseStorage->read('language.types')) { + foreach (array_keys($config['negotiation']) as $type) { + $config['negotiation'][$type]['enabled']['language-graphql'] = -999; + asort($config['negotiation'][$type]['enabled']); + } + return ['language.types' => $config]; + } + } + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheSuffix() { + return 'graphql'; + } + + /** + * {@inheritdoc} + */ + public function createConfigObject($name, $collection = StorageInterface::DEFAULT_COLLECTION) { + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getCacheableMetadata($name) { + return new CacheableMetadata(); + } + +} diff --git a/src/FixedLanguageNegotiator.php b/src/FixedLanguageNegotiator.php new file mode 100644 index 000000000..0d270c1c5 --- /dev/null +++ b/src/FixedLanguageNegotiator.php @@ -0,0 +1,25 @@ +getCacheContexts(); + // Ignore language contexts since they are handled by graphql internally. + $contexts = array_filter($metadata->getCacheContexts(), function ($context) { + return strpos($context, 'languages:') !== 0; + }); $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys(); // Sorting the variables will cause fewer cache vectors. diff --git a/src/GraphQL/Execution/ResolveContext.php b/src/GraphQL/Execution/ResolveContext.php index 75356a99a..884593fff 100644 --- a/src/GraphQL/Execution/ResolveContext.php +++ b/src/GraphQL/Execution/ResolveContext.php @@ -58,7 +58,8 @@ public function getContext($name, ResolveInfo $info, $default = NULL) { if (isset($this->contexts[$operation][$key][$name])) { return $this->contexts[$operation][$key][$name]; } - } while (array_pop($path) && !empty($path)); + array_pop($path); + } while (count($path)); return $default; } @@ -106,4 +107,4 @@ public function getGlobal($name, $default = NULL) { return $default; } -} \ No newline at end of file +} diff --git a/src/GraphQL/Visitors/CacheContextsCollector.php b/src/GraphQL/Visitors/CacheContextsCollector.php index e1c0edc43..d0e594bad 100644 --- a/src/GraphQL/Visitors/CacheContextsCollector.php +++ b/src/GraphQL/Visitors/CacheContextsCollector.php @@ -48,9 +48,9 @@ public function getVisitor(TypeInfo $info, array &$contexts) { */ protected function collectCacheContexts($contexts) { if (is_callable($contexts)) { - return $contexts(); + $contexts = $contexts(); } - return $contexts; } -} \ No newline at end of file + +} diff --git a/src/GraphQLLanguageContext.php b/src/GraphQLLanguageContext.php new file mode 100644 index 000000000..a99ce2673 --- /dev/null +++ b/src/GraphQLLanguageContext.php @@ -0,0 +1,88 @@ +languageManager = $languageManager; + } + + /** + * Retrieve the current language. + * + * @return string|null + * The current language code, or null if the context is not active. + */ + public function getCurrentLanguage() { + return $this->isActive + ? ($this->currentLanguage ?: $this->languageManager->getDefaultLanguage()->getId()) + : NULL; + } + + /** + * Executes a callable in a defined language context. + * + * @param callable $callable + * The callable to be executed. + * @param string $language + * The langcode to be set. + * + * @return mixed + * The callables result. + * + * @throws \Exception + * Any exception caught while executing the callable. + */ + public function executeInLanguageContext(callable $callable, $language) { + $this->currentLanguage = $language; + $this->isActive = TRUE; + $this->languageManager->reset(); + // Extract the result array. + try { + return call_user_func($callable); + } + catch (\Exception $exc) { + throw $exc; + } + finally { + // In any case, set the language context back to null. + $this->currentLanguage = NULL; + $this->isActive = FALSE; + $this->languageManager->reset(); + } + } + +} diff --git a/src/GraphqlServiceProvider.php b/src/GraphqlServiceProvider.php new file mode 100644 index 000000000..c1b85f01b --- /dev/null +++ b/src/GraphqlServiceProvider.php @@ -0,0 +1,26 @@ +hasDefinition('language_negotiator')) { + $container->getDefinition('language_negotiator') + ->setClass(FixedLanguageNegotiator::class); + } + } + +} diff --git a/src/Plugin/Deriver/PluggableSchemaDeriver.php b/src/Plugin/Deriver/PluggableSchemaDeriver.php index 7810b596a..11ff275e6 100644 --- a/src/Plugin/Deriver/PluggableSchemaDeriver.php +++ b/src/Plugin/Deriver/PluggableSchemaDeriver.php @@ -98,11 +98,7 @@ public function getDerivativeDefinitions($basePluginDefinition) { $cacheContexts = array_reduce($managers, function ($carry, CacheableDependencyInterface $current) { return Cache::mergeContexts($carry, $current->getCacheContexts()); - }, [ - // As long as the endpoint url language might have effect, we have to - // keep it. - 'languages:language_url', - ]); + }, []); $cacheMaxAge = array_reduce($managers, function ($carry, CacheableDependencyInterface $current) { return Cache::mergeMaxAges($carry, $current->getCacheMaxAge()); diff --git a/src/Plugin/GraphQL/Fields/FieldPluginBase.php b/src/Plugin/GraphQL/Fields/FieldPluginBase.php index 7ba2475e2..c84b183f0 100644 --- a/src/Plugin/GraphQL/Fields/FieldPluginBase.php +++ b/src/Plugin/GraphQL/Fields/FieldPluginBase.php @@ -15,6 +15,7 @@ use Drupal\graphql\Plugin\GraphQL\Traits\DeprecatablePluginTrait; use Drupal\graphql\Plugin\GraphQL\Traits\DescribablePluginTrait; use Drupal\graphql\Plugin\GraphQL\Traits\TypedPluginTrait; +use Drupal\graphql\Plugin\LanguageNegotiation\LanguageNegotiationGraphQL; use Drupal\graphql\Plugin\SchemaBuilderInterface; use GraphQL\Deferred; use GraphQL\Type\Definition\ListOfType; @@ -28,6 +29,13 @@ abstract class FieldPluginBase extends PluginBase implements FieldPluginInterfac use ArgumentAwarePluginTrait; use DeprecatablePluginTrait; + /** + * The language context service. + * + * @var \Drupal\graphql\GraphQLLanguageContext + */ + protected $languageContext; + /** * {@inheritdoc} */ @@ -45,6 +53,19 @@ public static function createInstance(SchemaBuilderInterface $builder, FieldPlug ]; } + /** + * Get the language context instance. + * + * @return \Drupal\graphql\GraphQLLanguageContext + * The language context service. + */ + protected function getLanguageContext() { + if (!isset($this->languageContext)) { + $this->languageContext = \Drupal::service('graphql.language_context'); + } + return $this->languageContext; + } + /** * {@inheritdoc} */ @@ -64,15 +85,47 @@ public function getDefinition() { * {@inheritdoc} */ public function resolve($value, array $args, ResolveContext $context, ResolveInfo $info) { + $definition = $this->getPluginDefinition(); + // If not resolving in a trusted environment, check if the field is secure. if (!$context->getGlobal('development', FALSE) && !$context->getGlobal('bypass field security', FALSE)) { - $definition = $this->getPluginDefinition(); if (empty($definition['secure'])) { throw new \Exception(sprintf("Unable to resolve insecure field '%s'.", $info->fieldName)); } } - return $this->resolveDeferred([$this, 'resolveValues'], $value, $args, $context, $info); + foreach ($definition['contextual_arguments'] as $argument) { + if (array_key_exists($argument, $args) && !is_null($args[$argument])) { + $context->setContext($argument, $args[$argument], $info); + } + else { + $args[$argument] = $context->getContext($argument, $info); + } + } + + if ($this->isLanguageAwareField()) { + return $this->getLanguageContext() + ->executeInLanguageContext(function () use ($value, $args, $context, $info) { + return $this->resolveDeferred([$this, 'resolveValues'], $value, $args, $context, $info); + }, $context->getContext('language', $info)); + } + else { + return $this->resolveDeferred([$this, 'resolveValues'], $value, $args, $context, $info); + } + } + + /** + * Indicator if the field is language aware. + * + * Checks for 'languages:*' cache contexts on the fields definition. + * + * @return bool + * The fields language awareness status. + */ + protected function isLanguageAwareField() { + return (boolean) count(array_filter($this->getPluginDefinition()['response_cache_contexts'], function ($context) { + return strpos($context, 'languages:') === 0; + })); } /** @@ -80,13 +133,13 @@ public function resolve($value, array $args, ResolveContext $context, ResolveInf */ protected function resolveDeferred(callable $callback, $value, array $args, ResolveContext $context, ResolveInfo $info) { $result = $callback($value, $args, $context, $info); + if (is_callable($result)) { return new Deferred(function () use ($result, $value, $args, $context, $info) { return $this->resolveDeferred($result, $value, $args, $context, $info); }); } - // Extract the result array. $result = iterator_to_array($result); // Only collect cache metadata if this is a query. All other operation types diff --git a/src/Plugin/LanguageNegotiation/LanguageNegotiationGraphQL.php b/src/Plugin/LanguageNegotiation/LanguageNegotiationGraphQL.php new file mode 100644 index 000000000..f9d0071a0 --- /dev/null +++ b/src/Plugin/LanguageNegotiation/LanguageNegotiationGraphQL.php @@ -0,0 +1,64 @@ +get('graphql.language_context')); + } + + /** + * LanguageNegotiationGraphQL constructor. + * + * @param \Drupal\graphql\GraphQLLanguageContext $languageContext + * Instance of the GraphQL language context. + */ + public function __construct(GraphQLLanguageContext $languageContext) { + $this->languageContext = $languageContext; + } + + /** + * The language negotiation method id. + */ + const METHOD_ID = 'language-graphql'; + + /** + * {@inheritdoc} + */ + public function getLangcode(Request $request = NULL) { + return $this->languageContext->getCurrentLanguage(); + } + +} diff --git a/tests/src/Kernel/Extension/ResolveContextTest.php b/tests/src/Kernel/Extension/ResolveContextTest.php new file mode 100644 index 000000000..ffcd77770 --- /dev/null +++ b/tests/src/Kernel/Extension/ResolveContextTest.php @@ -0,0 +1,117 @@ +mockType('test', ['name' => 'Test']); + + $this->mockField('a', [ + 'name' => 'a', + 'type' => 'Test', + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + $context->setContext('context', 'test', $info); + yield 'foo'; + }); + + $this->mockField('b', [ + 'name' => 'b', + 'type' => 'String', + 'parents' => ['Test'], + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + yield $context->getContext('context', $info); + }); + + $this->mockField('c', [ + 'name' => 'c', + 'type' => 'String', + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + yield $context->getContext('context', $info); + }); + + $query = <<assertResults($query, [], [ + 'a' => [ + 'b' => 'test', + ], + 'c' => NULL, + ], $this->defaultCacheMetaData()); + + } + + + /** + * Test manual context handling. + */ + public function testContextualArguments() { + $this->mockType('test', ['name' => 'Test']); + + $this->mockField('a', [ + 'name' => 'a', + 'type' => 'Test', + 'arguments' => [ + 'context' => 'String', + ], + 'contextual_arguments' => ['context'], + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + yield 'foo'; + }); + + $this->mockField('b', [ + 'name' => 'b', + 'type' => 'String', + 'arguments' => [ + 'context' => 'String', + ], + 'contextual_arguments' => ['context'], + 'parents' => ['Test'], + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + yield $args['context']; + }); + + $this->mockField('c', [ + 'name' => 'c', + 'type' => 'String', + 'arguments' => [ + 'context' => 'String', + ], + 'contextual_arguments' => ['context'], + ], function ($value, $args, ResolveContext $context, ResolveInfo $info) { + yield $args['context']; + }); + + $query = <<assertResults($query, [], [ + 'a' => [ + 'b' => 'test', + ], + 'c' => NULL, + ], $this->defaultCacheMetaData()); + + } +} diff --git a/tests/src/Kernel/Framework/LanguageContextTest.php b/tests/src/Kernel/Framework/LanguageContextTest.php new file mode 100644 index 000000000..cc2122787 --- /dev/null +++ b/tests/src/Kernel/Framework/LanguageContextTest.php @@ -0,0 +1,236 @@ +mockType('node', ['name' => 'Node']); + + $this->mockField('edge', [ + 'name' => 'edge', + 'parents' => ['Root', 'Node'], + 'type' => 'Node', + 'arguments' => [ + 'language' => 'String', + ], + 'contextual_arguments' => ['language'], + ], 'foo'); + + $this->mockField('language', [ + 'name' => 'language', + 'parents' => ['Root', 'Node'], + 'type' => 'String', + 'response_cache_contexts' => ['languages:language_interface'], + ], function () { + yield \Drupal::languageManager()->getCurrentLanguage()->getId(); + }); + + $this->mockField('unaware', [ + 'name' => 'unaware', + 'parents' => ['Root', 'Node'], + 'type' => 'String', + ], function () { + yield \Drupal::languageManager()->getCurrentLanguage()->getId(); + }); + + $this->mockField('leaking', [ + 'name' => 'leaking', + 'parents' => ['Root', 'Node'], + 'type' => 'String', + ], function () { + yield new CacheableValue('leak', [ + (new CacheableMetadata())->addCacheContexts([ + 'languages:language_interface', + ]), + ]); + }); + + $this->container->get('router.builder')->rebuild(); + } + + /** + * Test if the language negotiator is injected properly. + */ + public function testNegotiatorInjection() { + $context = $this->container->get('graphql.language_context'); + $negotiator = $this->container->get('language_negotiator'); + + $this->assertInstanceOf(FixedLanguageNegotiator::class, $negotiator); + + // Check if the order of negotiators is correct. + $getEnabledNegotiators = new \ReflectionMethod(FixedLanguageNegotiator::class, 'getEnabledNegotiators'); + $getEnabledNegotiators->setAccessible(TRUE); + $negotiators = $getEnabledNegotiators->invokeArgs($negotiator, [LanguageInterface::TYPE_INTERFACE]); + $this->assertEquals([ + 'language-graphql' => -999, + 'language-url' => 0, + ], $negotiators); + + // Check if the GraphQL language negotiation yields the correct result. + $language = $context->executeInLanguageContext(function () use ($negotiator) { + $negotiateLanguage = new \ReflectionMethod(FixedLanguageNegotiator::class, 'negotiateLanguage'); + $negotiateLanguage->setAccessible(TRUE); + return $negotiateLanguage->invokeArgs($negotiator, [LanguageInterface::TYPE_INTERFACE, 'language-graphql']); + }, 'fr'); + + $this->assertNotNull($language); + $this->assertEquals('fr', $language->getId()); + + // Check if the language type is initialized correctly. + $result = $context->executeInLanguageContext(function () { + return \Drupal::service('language_negotiator')->initializeType(LanguageInterface::TYPE_INTERFACE); + }, 'fr'); + + $this->assertEquals('language-graphql', array_keys($result)[0]); + } + + /** + * Test the language context service. + */ + public function testLanguageContext() { + $context = $this->container->get('graphql.language_context'); + + $this->assertEquals('fr', $context->executeInLanguageContext(function () { + return \Drupal::service('graphql.language_context')->getCurrentLanguage(); + }, 'fr'), 'Unexpected language context result.'); + } + + /** + * Test the language negotiation within a context. + */ + public function testLanguageNegotiation() { + $context = $this->container->get('graphql.language_context'); + + $this->assertEquals('fr', $context->executeInLanguageContext(function () { + return \Drupal::service('language_manager')->getCurrentLanguage()->getId(); + }, 'fr'), 'Unexpected language negotiation result.'); + } + + /** + * Test root language. + */ + public function testRootLanguage() { + $query = <<assertResults($query, [], [ + 'language' => \Drupal::languageManager()->getDefaultLanguage()->getId(), + ], $this->defaultCacheMetaData()->addCacheContexts(['languages:language_interface'])); + + } + + /** + * Test inherited language. + */ + public function testInheritedLanguage() { + $query = <<assertResults($query, [], [ + 'edge' => [ + 'language' => 'fr', + ], + ], $this->defaultCacheMetaData()->addCacheContexts(['languages:language_interface'])); + } + + /** + * Test overridden language. + */ + public function testOverriddenLanguage() { + $query = <<assertResults($query, [], [ + 'edge' => [ + 'language' => 'fr', + 'edge' => [ + 'language' => 'en', + ], + ], + ], $this->defaultCacheMetaData()->addCacheContexts(['languages:language_interface'])); + } + + /** + * Test an language unaware field. + * + * If a field doesn't declare language cache contexts, the context is + * not inactive and the standard language negotiation should kick in. + */ + public function testUnawareField() { + $query = <<assertResults($query, [], [ + 'edge' => [ + 'unaware' => 'en', + ], + ], $this->defaultCacheMetaData()); + } + + /** + * Test a field that is leaking cache contexts. + * + * If the field yields a cacheable result with language cache contexts but + * it doesn't declare them, this indicates an error where the field might + * not handle languages correctly. + * + * @expectedException \LogicException + */ + public function testLeakingField() { + $query = <<assertResults($query, [], [ + 'edge' => [ + 'leaking' => 'leak', + ], + ], $this->defaultCacheMetaData()); + } + +} diff --git a/tests/src/Kernel/GraphQLTestBase.php b/tests/src/Kernel/GraphQLTestBase.php index 08a15a5be..3ae1ea90f 100644 --- a/tests/src/Kernel/GraphQLTestBase.php +++ b/tests/src/Kernel/GraphQLTestBase.php @@ -5,6 +5,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\KernelTests\KernelTestBase; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\Tests\graphql\Traits\ProphesizePermissionsTrait; use Drupal\Tests\graphql\Traits\EnableCliCacheTrait; use Drupal\Tests\graphql\Traits\HttpRequestTrait; @@ -34,6 +35,7 @@ abstract class GraphQLTestBase extends KernelTestBase { public static $modules = [ 'system', 'user', + 'language', 'graphql', ]; @@ -102,6 +104,16 @@ protected function setUp() { $this->installConfig('system'); $this->installConfig('graphql'); $this->mockSchema('default'); + + $this->installEntitySchema('configurable_language'); + $this->installConfig(['language']); + $this->container->get('language_negotiator') + ->setCurrentUser($this->accountProphecy->reveal()); + + ConfigurableLanguage::create([ + 'id' => 'fr', + 'weight' => 1, + ])->save(); } } diff --git a/tests/src/Traits/MockGraphQLPluginTrait.php b/tests/src/Traits/MockGraphQLPluginTrait.php index 9d01ecc20..42f8bb9fe 100644 --- a/tests/src/Traits/MockGraphQLPluginTrait.php +++ b/tests/src/Traits/MockGraphQLPluginTrait.php @@ -118,7 +118,7 @@ protected function injectTypeSystemPluginManagers(ContainerBuilder $container) { $decoratedProp->setAccessible(TRUE); $unwrappedDiscovery = $decoratedProp->getValue($discovery); - $this->graphQLPlugins[$id] = []; + $this->graphQLPlugins[$class] = []; $mockFactory = $this ->getMockBuilder(FactoryInterface::class)