Unverified Commit 4452ea6e authored by Klaus Purer's avatar Klaus Purer Committed by GitHub
Browse files

feat(dataproducers): Add entity query dataproducers (#1360)

parent 9e8f44f8
Loading
Loading
Loading
Loading
+195 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity;

use Drupal\graphql\GraphQL\Execution\FieldContext;

/**
 * Builds and executes Drupal entity query.
 *
 * Example for mapping this dataproducer to the schema:
 * @code
 *   $defaultSorting = [
 *     [
 *       'field' => 'created',
 *       'direction' => 'DESC',
 *     ],
 *   ];
 *   $registry->addFieldResolver('Query', 'jobApplicationsByUserId',
 *     $builder->compose(
 *       $builder->fromArgument('id'),
 *       $builder->callback(function ($uid) {
 *         $conditions = [
 *           [
 *             'field' => 'uid',
 *             'value' => [$uid],
 *           ],
 *         ];
 *         return $conditions;
 *       }),
 *       $builder->produce('entity_query', [
 *         'type' => $builder->fromValue('node'),
 *         'conditions' => $builder->fromParent(),
 *         'offset' => $builder->fromArgument('offset'),
 *         'limit' => $builder->fromArgument('limit'),
 *         'language' => $builder->fromArgument('language'),
 *         'allowed_filters' => $builder->fromValue(['uid']),
 *         'bundles' => $builder->fromValue(['job_application']),
 *         'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting),
 *       ]),
 *       $builder->produce('entity_load_multiple', [
 *         'type' => $builder->fromValue('node'),
 *         'ids' => $builder->fromParent(),
 *       ]),
 *     )
 *   );
 * @endcode
 *
 * @DataProducer(
 *   id = "entity_query",
 *   name = @Translation("Load entities"),
 *   description = @Translation("Returns entity IDs for a given query"),
 *   produces = @ContextDefinition("string",
 *     label = @Translation("Entity IDs"),
 *     multiple = TRUE
 *   ),
 *   consumes = {
 *     "type" = @ContextDefinition("string",
 *       label = @Translation("Entity type")
 *     ),
 *     "limit" = @ContextDefinition("integer",
 *       label = @Translation("Limit"),
 *       required = FALSE,
 *       default_value = 10
 *     ),
 *     "offset" = @ContextDefinition("integer",
 *       label = @Translation("Offset"),
 *       required = FALSE,
 *       default_value = 0
 *     ),
 *     "owned_only" = @ContextDefinition("boolean",
 *       label = @Translation("Query only owned entities"),
 *       required = FALSE,
 *       default_value = FALSE
 *     ),
 *     "conditions" = @ContextDefinition("any",
 *       label = @Translation("Conditions"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "allowed_filters" = @ContextDefinition("string",
 *       label = @Translation("Allowed filters"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "languages" = @ContextDefinition("string",
 *       label = @Translation("Entity languages"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "bundles" = @ContextDefinition("any",
 *       label = @Translation("Entity bundles"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "access" = @ContextDefinition("boolean",
 *       label = @Translation("Check access"),
 *       required = FALSE,
 *       default_value = TRUE
 *     ),
 *     "sorts" = @ContextDefinition("any",
 *       label = @Translation("Sorts"),
 *       multiple = TRUE,
 *       default_value = {},
 *       required = FALSE
 *     )
 *   }
 * )
 */
class EntityQuery extends EntityQueryBase {

  /**
   * The default maximum number of items to be capped to prevent DDOS attacks.
   */
  const MAX_ITEMS = 100;

  /**
   * Resolves the entity query.
   *
   * @param string $type
   *   Entity type.
   * @param int $limit
   *   Maximum number of queried entities.
   * @param int $offset
   *   Offset to start with.
   * @param bool $ownedOnly
   *   Query only entities owned by current user.
   * @param array $conditions
   *   List of conditions to filter the entities.
   * @param array $allowedFilters
   *   List of fields to be used in conditions to restrict access to data.
   * @param string[] $languages
   *   Languages for queried entities.
   * @param string[] $bundles
   *   List of bundles to be filtered.
   * @param bool $access
   *   Whether entity query should check access.
   * @param array $sorts
   *   List of sorts.
   * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context
   *   The caching context related to the current field.
   *
   * @return array
   *   The list of ids that match this query.
   *
   * @throws \GraphQL\Error\UserError
   *   No bundles defined for given entity type.
   */
  public function resolve(string $type, int $limit, int $offset, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, array $sorts, FieldContext $context): array {
    $query = $this->buildBaseEntityQuery(
      $type,
      $ownedOnly,
      $conditions,
      $allowedFilters,
      $languages,
      $bundles,
      $access,
      $context
    );

    // Make sure offset is zero or positive.
    $offset = max($offset, 0);

    // Make sure limit is positive and cap the max items to prevent DDOS
    // attacks.
    if ($limit <= 0) {
      $limit = 10;
    }
    $limit = min($limit, self::MAX_ITEMS);

    // Apply offset and limit.
    $query->range($offset, $limit);

    // Add sorts.
    foreach ($sorts as $sort) {
      if (!empty($sort['field'])) {
        if (!empty($sort['direction']) && strtolower($sort['direction']) == 'desc') {
          $direction = 'DESC';
        }
        else {
          $direction = 'ASC';
        }
        $query->sort($sort['field'], $direction);
      }
    }

    $ids = $query->execute();

    return $ids;
  }

}
+141 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity;

use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\graphql\GraphQL\Execution\FieldContext;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
use GraphQL\Error\UserError;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Base class to share code between entity query and entity query count.
 */
abstract class EntityQueryBase extends DataProducerPluginBase implements ContainerFactoryPluginInterface {

  /**
   * The entity type manager service.
   *
   * @var \Drupal\Core\Entity\EntityTypeManager
   */
  protected $entityTypeManager;

  /**
   * The current user proxy.
   *
   * @var \Drupal\Core\Session\AccountProxyInterface
   */
  protected $currentUser;

  /**
   * {@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('current_user')
    );
  }

  /**
   * EntityLoad constructor.
   *
   * @param array $configuration
   *   The plugin configuration array.
   * @param string $pluginId
   *   The plugin id.
   * @param array $pluginDefinition
   *   The plugin definition array.
   * @param \Drupal\Core\Entity\EntityTypeManager $entityTypeManager
   *   The entity type manager service.
   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
   *   The current user proxy.
   */
  public function __construct(
    array $configuration,
    string $pluginId,
    array $pluginDefinition,
    EntityTypeManager $entityTypeManager,
    AccountProxyInterface $current_user
  ) {
    parent::__construct($configuration, $pluginId, $pluginDefinition);
    $this->entityTypeManager = $entityTypeManager;
    $this->currentUser = $current_user;
  }

  /**
   * Build base entity query which may be reused for count query as well.
   *
   * @param string $type
   *   Entity type.
   * @param bool $ownedOnly
   *   Query only entities owned by current user.
   * @param array $conditions
   *   List of conditions to filter the entities.
   * @param array $allowedFilters
   *   List of fields to be used in conditions to restrict access to data.
   * @param string[] $languages
   *   Languages for queried entities.
   * @param string[] $bundles
   *   List of bundles to be filtered.
   * @param bool $access
   *   Whether entity query should check access.
   * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context
   *   The caching context related to the current field.
   *
   * @return \Drupal\Core\Entity\Query\QueryInterface
   *   Base entity query.
   *
   * @throws \GraphQL\Error\UserError
   *   No bundles defined for given entity type.
   */
  protected function buildBaseEntityQuery(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): QueryInterface {
    $entity_type = $this->entityTypeManager->getStorage($type);
    $query = $entity_type->getQuery();

    // Query only those entities which are owned by current user, if desired.
    if ($ownedOnly) {
      $query->condition('uid', $this->currentUser->id());
      // Add user cacheable dependencies.
      $account = $this->currentUser->getAccount();
      $context->addCacheableDependency($account);
      // Cache response per user to make sure the user related result is shown.
      $context->addCacheContexts(['user']);
    }

    // Ensure that desired access checking is performed on the query.
    $query->accessCheck($access);

    // Filter entities only of given bundles, if desired.
    if ($bundles) {
      $bundle_key = $entity_type->getEntityType()->getKey('bundle');
      if (!$bundle_key) {
        throw new UserError('No bundles defined for given entity type.');
      }
      $query->condition($bundle_key, $bundles, 'IN');
    }

    // Filter entities by given languages, if desired.
    if ($languages) {
      $query->condition('langcode', $languages, 'IN');
    }

    // Filter by given conditions.
    foreach ($conditions as $condition) {
      if (!in_array($condition['field'], $allowedFilters)) {
        throw new UserError("Field '{$condition['field']}' is not allowed as filter.");
      }
      $operation = $condition['operator'] ?? NULL;
      $query->condition($condition['field'], $condition['value'], $operation);
    }

    return $query;
  }

}
+138 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\graphql\Plugin\GraphQL\DataProducer\Entity;

use Drupal\graphql\GraphQL\Execution\FieldContext;

/**
 * Builds and executes Drupal entity query count.
 *
 * It supposed to be used along the entity query to get the total amount of
 * items. This is important for pagination when the total amount needs to be
 * known to derive the number of available pages. Otherwise the query works the
 * same way as entity query. Same filters are applied, just skips the offset and
 * limit, and turns the query into count query.
 *
 * Example for mapping this dataproducer to the schema:
 * @code
 *   $defaultSorting = [
 *     [
 *       'field' => 'created',
 *       'direction' => 'DESC',
 *     ],
 *   ];
 *   $registry->addFieldResolver('Query', 'jobApplicationsByUserIdCount',
 *     $builder->compose(
 *       $builder->fromArgument('id'),
 *       $builder->callback(function ($uid) {
 *         $conditions = [
 *           [
 *             'field' => 'uid',
 *             'value' => [$uid],
 *           ],
 *         ];
 *         return $conditions;
 *       }),
 *       $builder->produce('entity_query_count', [
 *         'type' => $builder->fromValue('node'),
 *         'conditions' => $builder->fromParent(),
 *         'offset' => $builder->fromArgument('offset'),
 *         'limit' => $builder->fromArgument('limit'),
 *         'language' => $builder->fromArgument('language'),
 *         'allowed_filters' => $builder->fromValue(['uid']),
 *         'bundles' => $builder->fromValue(['job_application']),
 *         'sorts' => $builder->fromArgumentWithDefaultValue('sorting', $defaultSorting),
 *       ])
 *     )
 *   );
 * @endcode
 *
 * @DataProducer(
 *   id = "entity_query_count",
 *   name = @Translation("Load entities"),
 *   description = @Translation("Loads entities."),
 *   produces = @ContextDefinition("integer",
 *     label = @Translation("Total count of items queried by entity query."),
 *   ),
 *   consumes = {
 *     "type" = @ContextDefinition("string",
 *       label = @Translation("Entity type")
 *     ),
 *     "owned_only" = @ContextDefinition("boolean",
 *       label = @Translation("Query only owned entities"),
 *       required = FALSE,
 *       default_value = FALSE
 *     ),
 *     "conditions" = @ContextDefinition("any",
 *       label = @Translation("Conditions"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "allowed_filters" = @ContextDefinition("string",
 *       label = @Translation("Allowed filters"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "languages" = @ContextDefinition("string",
 *       label = @Translation("Entity languages"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "bundles" = @ContextDefinition("any",
 *       label = @Translation("Entity bundles"),
 *       multiple = TRUE,
 *       required = FALSE,
 *       default_value = {}
 *     ),
 *     "access" = @ContextDefinition("boolean",
 *       label = @Translation("Check access"),
 *       required = FALSE,
 *       default_value = TRUE
 *     )
 *   }
 * )
 */
class EntityQueryCount extends EntityQueryBase {

  /**
   * Resolves the entity query count.
   *
   * @param string $type
   *   Entity type.
   * @param bool $ownedOnly
   *   Query only entities owned by current user.
   * @param array $conditions
   *   List of conditions to filter the entities.
   * @param array $allowedFilters
   *   List of fields to be used in conditions to restrict access to data.
   * @param string[] $languages
   *   Languages for queried entities.
   * @param string[] $bundles
   *   List of bundles to be filtered.
   * @param bool $access
   *   Whether entity query should check access.
   * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context
   *   The caching context related to the current field.
   *
   * @return int
   *   Total count of items queried by entity query.
   */
  public function resolve(string $type, bool $ownedOnly, array $conditions, array $allowedFilters, array $languages, array $bundles, bool $access, FieldContext $context): int {
    $query = $this->buildBaseEntityQuery(
      $type,
      $ownedOnly,
      $conditions,
      $allowedFilters,
      $languages,
      $bundles,
      $access,
      $context
    );

    return $query->count()->execute();
  }

}