graphql_core_schema-1.0.x-dev/src/CoreComposableResolver.php
src/CoreComposableResolver.php
<?php
namespace Drupal\graphql_core_schema;
use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
use Drupal\Core\Access\AccessibleInterface;
use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\TranslatableInterface;
use Drupal\Core\Field\EntityReferenceFieldItemListInterface;
use Drupal\Core\Field\FieldItemBase;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\Plugin\Field\FieldType\BooleanItem;
use Drupal\Core\Field\Plugin\Field\FieldType\EmailItem;
use Drupal\Core\Field\Plugin\Field\FieldType\LanguageItem;
use Drupal\Core\Field\Plugin\Field\FieldType\NumericItemBase;
use Drupal\Core\Field\Plugin\Field\FieldType\StringItem;
use Drupal\Core\Field\Plugin\Field\FieldType\StringItemBase;
use Drupal\Core\Field\Plugin\Field\FieldType\TimestampItem;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\TypedData\Plugin\DataType\BooleanData;
use Drupal\Core\TypedData\Plugin\DataType\IntegerData;
use Drupal\Core\TypedData\Plugin\DataType\StringData;
use Drupal\Core\TypedData\Plugin\DataType\Timestamp;
use Drupal\Core\TypedData\Plugin\DataType\Uri;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\Core\Url;
use Drupal\file\FileInterface;
use Drupal\file\Plugin\Field\FieldType\FileFieldItemList;
use Drupal\graphql\GraphQL\Execution\ResolveContext;
use Drupal\graphql\GraphQL\ResolverBuilder;
use Drupal\graphql\GraphQL\ResolverRegistry;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerProxy;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\options\Plugin\Field\FieldType\ListStringItem;
use Drupal\text\Plugin\Field\FieldType\TextItemBase;
use Drupal\text\TextProcessed;
use GraphQL\Executor\Executor;
use GraphQL\Type\Definition\ListOfType;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Type\Definition\WrappingType;
/**
* The core composable resolver class.
*/
class CoreComposableResolver {
/**
* Resolves a default value for a field.
*
* @param mixed $value
* The value.
* @param mixed $args
* The arguments.
* @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
* The context.
* @param \GraphQL\Type\Definition\ResolveInfo $info
* The graphql resolver info.
* @param \Drupal\graphql\GraphQL\Execution\FieldContext $field
* The field context.
*
* @return mixed|null
* The result.
*/
public static function resolveFieldDefault($value, $args, ResolveContext $context, ResolveInfo $info, RefinableCacheableDependencyInterface $field) {
$returnType = $info->returnType;
$isArrayType = $returnType instanceof ListOfType;
$renderContext = new RenderContext();
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$result = $renderer->executeInRenderContext(
$renderContext,
fn () => self::resolveField($value, $args, $context, $info, $field)
);
if (!$renderContext->isEmpty()) {
$context->addCacheableDependency($renderContext->pop());
}
// Set cache dependencies.
if ($result instanceof CacheableDependencyInterface) {
$context->addCacheableDependency($result);
}
elseif (is_array($result)) {
foreach ($result as $resultItem) {
if ($resultItem instanceof CacheableDependencyInterface) {
$context->addCacheableDependency($resultItem);
}
}
}
// Get the current language from the context.
// If no language is set, set it from the current language.
$language = $field->getContextValue('language');
if (!$language) {
$language = \Drupal::languageManager()->getCurrentLanguage()->getId();
$field->setContextValue('language', $language);
}
$translated = self::translateResolvedValue($result, $language, $isArrayType);
// Access check for the resolved result.
// The resolveFieldDefault resolver does not perform any access checks
// while for example resolving references. This is all done here.
// It's important to note that this is NOT called when using custom
// field resolvers (e.g. in schema extensions) return an entity.
return self::filterAccessible($translated, $context);
}
/**
* Resolves a default value for a field.
*
* @param mixed $value
* The value.
* @param mixed $args
* The arguments.
* @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
* The context.
* @param \GraphQL\Type\Definition\ResolveInfo $info
* The graphql resolver info.
* @param \Drupal\graphql\GraphQL\Execution\FieldContext $field
* The field context.
*
* @return mixed|null
* The result.
*/
private static function resolveField($value, $args, ResolveContext $context, ResolveInfo $info, RefinableCacheableDependencyInterface $field) {
// Find out if this is a value field.
$fieldDescription = $info->fieldDefinition->description ?? '';
$isValueField = str_starts_with($fieldDescription, '{value}');
// The GraphQL field name.
$fieldName = $field->getFieldName();
$returnType = $info->returnType;
$isList = $returnType instanceof ListOfType;
// The Drupal field name.
$drupalFieldName = self::getDrupalFieldName($fieldName, $fieldDescription);
// Handle value fields.
if ($isValueField) {
$results = self::resolveFieldValue($value, $drupalFieldName, $args, $context);
return $isList ? $results : $results[0] ?? NULL;
}
// Handle all other fields.
if ($value instanceof EntityInterface) {
if ($value instanceof FieldableEntityInterface || $value instanceof ConfigEntityInterface) {
return $value->get($drupalFieldName) ?? $value->get($fieldName);
}
}
elseif ($value instanceof FieldItemListInterface) {
return iterator_to_array($value);
}
elseif ($value instanceof FieldItemInterface) {
return self::resolveItem($value, $info, $drupalFieldName);
}
elseif (is_array($value) && isset($value[$drupalFieldName])) {
return $value[$drupalFieldName];
}
// This default resolver will try to resolve the value based on the
// GraphQL field name.
return Executor::defaultFieldResolver($value, $args, $context, $info);
}
/**
* Translate the resolved values.
*
* @param mixed $resolvedValue
* The resolved value.
* @param string|null $language
* The target language.
* @param bool $isArray
* If the resolved value is an array (in GraphQL terms).
*
* @return mixed
* The resolved value, translated.
*/
private static function translateResolvedValue($resolvedValue, string $language, bool $isArray) {
if ($isArray && is_array($resolvedValue)) {
$translated = [];
foreach ($resolvedValue as $item) {
$translated[] = self::translateResolvedValue($item, $language, FALSE);
}
return $translated;
}
if ($resolvedValue instanceof TranslatableInterface) {
if ($resolvedValue->hasTranslation($language)) {
return $resolvedValue->getTranslation($language);
}
}
return $resolvedValue;
}
/**
* Filter the input to only return accessible values.
*
* If the input is an AccessibleInterface object the method returns
* either the object or NULL.
* If the input is an array, it will iterate over the values and perform the
* check if the values are AccessibleInterface objects. Those that fail the
* check will be replaced with NULL.
* Any other inputs are returned as is.
*
* @param mixed $value
* The input value, either object or an array.
* @param ResolveContext $context
* The resolve context.
*
* @return mixed
* The filtered result.
*/
private static function filterAccessible($value, ResolveContext $context) {
if ($value instanceof AccessibleInterface) {
$result = $value->access('view', NULL, TRUE);
$context->addCacheableDependency($result);
if ($result->isAllowed()) {
return $value;
}
return NULL;
}
// Check arrays of "Accessible" objects.
if (is_array($value)) {
$checked = [];
foreach ($value as $key => $item) {
// Prevent infinite recursion.
if ($item instanceof AccessibleInterface) {
$checked[$key] = self::filterAccessible($item, $context);
}
else {
$checked[$key] = $item;
}
}
return $checked;
}
return $value;
}
/**
* Convert the GraphQL field name to the Drupal field name.
*
* @param string $fieldName
* The GraphQL field name.
* @param string|null $description
* The GraphQL field description.
*
* @return string
* The Drupal field name.
*/
private static function getDrupalFieldName(string $fieldName, string|null $description = NULL): string {
if ($description) {
// If the description contains e.g. {field: field_foobar_1_a}, use this
// as the field name.
$matches = [];
preg_match('/\{field: (.+)\}/', $description, $matches);
$match = $matches[1] ?? NULL;
if ($match) {
return $match;
}
}
// Convert the field name to snake case.
return EntitySchemaHelper::toSnakeCase($fieldName);
}
/**
* Resolve a value field.
*
* Unlike the FieldItemList fields, these directly resolve to a scalar or
* other "sane" object type.
*/
private static function resolveFieldValue($parent, string $fieldName, array $args, ResolveContext $context): array {
$result = [];
if ($parent instanceof FieldableEntityInterface) {
$field = $parent->get($fieldName);
// Perform access check here because we directly resolve a field value.
if (!$field->access('view')) {
return [];
}
// Special handling for file fields, which inherit from EntityReferenceItem.
// Their value fields are not the referenced entity (file), but the field item.
// This is because some files like images have additional properties like
// alt and title, which would otherwise not be available on the File
// type.
if ($field instanceof FileFieldItemList) {
return iterator_to_array($field);
}
// Entity reference fields, directly get the entities via the
// referencedEntities helper method.
if ($field instanceof EntityReferenceFieldItemListInterface) {
return $field->referencedEntities();
}
foreach ($field as $item) {
$result[] = self::extractFieldValue($item, $args, $context);
}
}
return $result;
}
/**
* Extract the value for a value field.
*
* This logic corresponds to the logic in
* EntitySchemaBuilder::buildGraphqlValueField, where the GraphQL scalar type
* is determined.
*/
private static function extractFieldValue(FieldItemInterface $item, array $args, ResolveContext $context) {
if (
$item instanceof StringItem ||
$item instanceof StringItemBase ||
$item instanceof EmailItem ||
$item instanceof BooleanItem ||
$item instanceof ListStringItem ||
$item instanceof NumericItemBase
) {
return $item->value;
}
elseif ($item instanceof TextItemBase) {
if (isset($args['summary'])) {
return $item->summary_processed;
}
$processed = $item->processed;
$context->addCacheableDependency($item->get('processed'));
return $processed;
}
elseif ($item instanceof TimestampItem) {
$value = $item->value;
if ($value) {
return date(\DateTime::ATOM, $value);
}
}
elseif ($item instanceof LanguageItem) {
return $item->language;
}
elseif ($item instanceof FieldItemBase) {
$pluginId = $item->getPluginId();
if ($pluginId === 'field_item:telephone') {
return $item->value;
}
}
return $item;
}
/**
* Resolve item.
*
* @param \Drupal\Core\Field\FieldItemInterface $item
* The field item.
* @param \GraphQL\Type\Definition\ResolveInfo $info
* The graphql context.
* @param string $property
* The property name.
*
* @return \Drupal\Component\Render\MarkupInterface|\Drupal\Core\Entity\ContentEntityInterface|mixed|string|null
* The resolved item.
*
* @throws \Drupal\Core\TypedData\Exception\MissingDataException
* @throws \GraphQL\Error\Error
*/
private static function resolveItem(FieldItemInterface $item, ResolveInfo $info, string $property) {
$result = $item->get($property);
if ($result instanceof Uri) {
return !is_null($result->getValue()) ? Url::fromUri($result->getValue()) : NULL;
}
elseif ($result instanceof StringData) {
return $result->getValue() ?? '';
}
elseif ($result instanceof BooleanData) {
return $result->getValue() ?? FALSE;
}
elseif ($result instanceof Timestamp) {
return $result->getValue();
}
elseif ($result instanceof IntegerData) {
return $result->getValue();
}
elseif ($result instanceof TextProcessed) {
return $result->getValue();
}
elseif ($result instanceof TypedDataInterface) {
return $result->getValue();
}
$type = $info->returnType;
$type = $type instanceof WrappingType ? $type->getWrappedType(TRUE) : $type;
if ($type instanceof ScalarType) {
$result = is_null($result) ? NULL : $type->serialize($result);
}
return $result;
}
/**
* Returns NULL as default type.
*
* @param mixed $value
* The value.
* @param \Drupal\graphql\GraphQL\Execution\ResolveContext $context
* The context.
* @param \GraphQL\Type\Definition\ResolveInfo $info
* The resolver info.
*
* @return null
* The default type.
*/
public static function resolveTypeDefault($value, ResolveContext $context, ResolveInfo $info) {
$coreComposableConfig = CoreComposableConfig::fromConfiguration($context->getServer()->get('schema_configuration')['core_composable'] ?? []);
if ($value instanceof FileInterface) {
// Special handling here because if we return "File" as a string,
// the execution resolver will call "is_callable('File')", which evaluates
// to true. This then ends up calling the PHP file() method without
// any arguments, which will throw an error.
return $info->schema->getType('File');
}
elseif ($value instanceof EntityInterface) {
return $coreComposableConfig->getTypeNameForEntity($value);
}
elseif ($value instanceof FieldItemInterface) {
return EntitySchemaHelper::getTypeForFieldItem($value);
}
elseif ($value instanceof PluginDefinitionInterface) {
return EntitySchemaHelper::toPascalCase([$value->id(), '_plugin']);
}
elseif ($value instanceof Url) {
return 'DefaultUrl';
}
return NULL;
}
/**
* Register field item and field item list resolvers.
*
* @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
* The resolver registry.
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
*/
public static function registerFieldListResolvers(ResolverRegistry $registry, ResolverBuilder $builder) {
$registry->addFieldResolver(
'FieldItemList',
'first',
self::resolveCallMethod($builder, 'first')
);
$registry->addFieldResolver(
'FieldItemList',
'isEmpty',
self::resolveCallMethod($builder, 'isEmpty')
);
$registry->addFieldResolver(
'FieldItemList',
'count',
self::resolveCallMethod($builder, 'count')
);
$registry->addFieldResolver(
'FieldItemList',
'getString',
self::resolveCallMethod($builder, 'getString')
);
$registry->addFieldResolver(
'FieldItemType',
'isEmpty',
self::resolveCallMethod($builder, 'isEmpty')
);
$registry->addFieldResolver(
'FieldItemList',
'entity',
self::resolveCallMethod($builder, 'getEntity')
);
$registry->addFieldResolver(
'FieldItemList',
'list',
$builder->fromParent()
);
}
/**
* Register entity resolvers.
*
* @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
* The resolver registry.
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
*/
public static function registerEntityResolvers(ResolverRegistry $registry, ResolverBuilder $builder) {
$resolveEnumArgument = function ($name) {
return function ($value, $args) use ($name) {
return str_replace('_', '-', strtolower($args[$name]));
};
};
$registry->addFieldResolver(
'Entity',
'id',
$builder->produce('entity_id')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'label',
$builder->produce('entity_label')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'uuid',
$builder->produce('entity_uuid')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'entityTypeId',
$builder->produce('entity_type_id')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'language',
$builder->produce('entity_language')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'langcode',
$builder->callback(function (EntityInterface $entity) {
return $entity->language()->getId();
})
);
$registry->addFieldResolver(
'Entity',
'isNew',
self::resolveCallMethod($builder, 'isNew')
);
$registry->addFieldResolver(
'Entity',
'toArray',
self::resolveCallMethod($builder, 'toArray')
);
$registry->addFieldResolver(
'Entity',
'uriRelationships',
self::resolveCallMethod($builder, 'uriRelationships')
);
$registry->addFieldResolver(
'Entity',
'entityBundle',
$builder->produce('entity_bundle')->map('entity', $builder->fromParent()),
);
$registry->addFieldResolver(
'Entity',
'referencedEntities',
$builder->produce('entity_referenced_entities')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'Entity',
'getConfigTarget',
self::resolveCallMethod($builder, 'getConfigTarget')
);
$registry->addFieldResolver(
'EntityLinkable',
'url',
$builder->produce('entity_url')
->map('entity', $builder->fromParent())
->map('rel', $builder->fromArgument('rel'))
->map('options',
$builder->produce('url_options')->map('options', $builder->fromArgument('options'))
)
);
$registry->addFieldResolver(
'Entity',
'accessCheck',
$builder->produce('entity_access')
->map('entity', $builder->fromParent())
->map('operation', $builder->fromArgument('operation'))
);
$registry->addFieldResolver(
'EntityTranslatable',
'translations',
$builder->produce('entity_translations')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'EntityTranslatable',
'translation',
$builder->produce('entity_translation_fallback')
->map('entity', $builder->fromParent())
->map('langcode', $builder->callback($resolveEnumArgument('langcode')))
->map('fallback', $builder->fromArgument('fallback'))
);
$registry->addFieldResolver(
'EntityDescribable',
'entityDescription',
$builder->produce('entity_description')->map('entity', $builder->fromParent())
);
$registry->addFieldResolver(
'EntityRevisionable',
'entityRevisionId',
self::resolveCallMethod($builder, 'getRevisionId')
);
$registry->addFieldResolver(
'EntityRevisionable',
'wasDefaultRevision',
self::resolveCallMethod($builder, 'wasDefaultRevision')
);
$registry->addFieldResolver(
'EntityRevisionable',
'isLatestRevision',
self::resolveCallMethod($builder, 'isLatestRevision')
);
}
/**
* Register fields for the base Url fields.
*
* @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
* The resolver registry.
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
*/
public static function registerUrlResolvers(ResolverRegistry $registry, ResolverBuilder $builder) {
$registry->addFieldResolver(
'Url',
'path',
$builder->produce('url_path')->map('url', $builder->fromParent())
);
}
/**
* Register language resolvers.
*
* @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
* The resolver registry.
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
*/
public static function registerLanguageResolvers(ResolverRegistry $registry, ResolverBuilder $builder) {
$registry->addFieldResolver(
'LanguageInterface',
'name',
self::resolveCallMethod($builder, 'getName')
);
$registry->addFieldResolver(
'LanguageInterface',
'id',
self::resolveCallMethod($builder, 'getId')
);
$registry->addFieldResolver(
'LanguageInterface',
'direction',
self::resolveCallMethod($builder, 'getDirection')
);
$registry->addFieldResolver(
'LanguageInterface',
'weight',
self::resolveCallMethod($builder, 'getWeight')
);
$registry->addFieldResolver(
'LanguageInterface',
'isLocked',
self::resolveCallMethod($builder, 'isLocked')
);
$registry->addTypeResolver('LanguageInterface', function ($value) {
if ($value instanceof ConfigurableLanguage) {
return 'ConfigurableLanguage';
}
return 'Language';
});
}
/**
* Register ping resolvers.
*
* These are needed because it's possible to not have a single query or
* mutation when all extensions are disabled. This way we can make sure that
* the schema can be generated without an exception.
*
* @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
* The resolver registry.
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
*/
public static function registerPingResolvers(ResolverRegistry $registry, ResolverBuilder $builder) {
$registry->addFieldResolver('Query', 'ping', $builder->fromValue('pong'));
$registry->addFieldResolver('Mutation', 'ping', $builder->fromValue('pong'));
}
/**
* Return a resolver that calls the method on the parent object.
*
* @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
* The resolver builder.
* @param string $method
* The method to call.
*
* @return DataProducerProxy
* The resolver.
*/
public static function resolveCallMethod(ResolverBuilder $builder, string $method): DataProducerProxy {
return $builder
->produce('call_method')
->map('object', $builder->fromParent())
->map('method', $builder->fromValue($method));
}
}
