diff --git a/src/Plugin/VersionNegotiation/VersionById.php b/src/Plugin/VersionNegotiation/VersionById.php index 7ef7046..0224bd0 100644 --- a/src/Plugin/VersionNegotiation/VersionById.php +++ b/src/Plugin/VersionNegotiation/VersionById.php @@ -3,7 +3,6 @@ namespace Drupal\jsonapi\Plugin\VersionNegotiation; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\RevisionableStorageInterface; use Drupal\jsonapi\Revisions\InvalidVersionIdentifierException; use Drupal\jsonapi\Revisions\PluginNegotiationBase; use Drupal\jsonapi\Revisions\VersionNegotiationInterface; @@ -11,10 +10,11 @@ use Drupal\jsonapi\Revisions\VersionNegotiationInterface; /** * Defines a revision ID implementation for entity revision ID values. * - * @internal * @VersionNegotiation( * id = "id", * ) + * + * @internal */ class VersionById extends PluginNegotiationBase implements VersionNegotiationInterface { diff --git a/src/Plugin/VersionNegotiation/VersionByRel.php b/src/Plugin/VersionNegotiation/VersionByRel.php index b1533f0..12dc1ad 100644 --- a/src/Plugin/VersionNegotiation/VersionByRel.php +++ b/src/Plugin/VersionNegotiation/VersionByRel.php @@ -2,19 +2,13 @@ namespace Drupal\jsonapi\Plugin\VersionNegotiation; -use Drupal\Component\Plugin\Exception\PluginException; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityStorageException; -use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\RevisionableInterface; -use Drupal\Core\Entity\RevisionableStorageInterface; -use Drupal\Core\Http\Exception\CacheableHttpException; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\jsonapi\Revisions\InvalidVersionIdentifierException; use Drupal\jsonapi\Revisions\PluginNegotiationBase; -use Drupal\jsonapi\Revisions\ResourceVersionRouteEnhancer; use Drupal\jsonapi\Revisions\VersionNegotiationInterface; -use Drupal\jsonapi\Revisions\VersionNotFoundException; +use Drupal\jsonapi\Revisions\VersionNegotiationManager; /** * Revision ID implementation for the current or latest revisions. @@ -91,47 +85,165 @@ class VersionByRel extends PluginNegotiationBase implements ContainerFactoryPlug switch ($version_argument) { case static::WORKING_COPY: /* @var \Drupal\Core\Entity\RevisionableStorageInterface $entity_storage */ - $revision_id = $entity_storage->getLatestRevisionId($entity->id()); - if (is_null($revision_id)) { - throw new VersionNotFoundException(); - } - return $revision_id; + return static::ensureVersionFound($entity_storage->getLatestRevisionId($entity->id())); case static::LATEST_VERSION: // The already loaded revision will be the latest version by default. return $entity->getLoadedRevisionId(); - // @todo: Implement these. We can copy the solution in https://www.drupal.org/project/drupal/issues/2986027. case static::PREDECESSOR_VERSION: + return static::ensureVersionFound($this->getPriorRevisionId($entity, TRUE)); + case static::WORKING_COPY_OF: + return static::ensureVersionFound($this->getPriorRevisionId($entity, TRUE)); + case static::PRIOR_WORKING_COPY: + return static::ensureVersionFound($this->getPriorRevisionId($entity, FALSE)); + case static::SUBSEQUENT_WORKING_COPY: - $this->checkVersionHistorySupport($entity_storage); - $message = sprintf('The link relation type `%s` is not implemented yet.'); - $cacheability = (new CacheableMetadata())->addCacheContexts(['url.query_args:' . ResourceVersionRouteEnhancer::RESOURCE_VERSION_QUERY_PARAMETER]); - // @todo: uncomment the next line and remove the following line after https://www.drupal.org/project/drupal/issues/3002352 lands. - /* throw new CacheableHttpException($cacheability, 501, $message); */ - throw new CacheableHttpException($cacheability, 501, $message, []); + return static::ensureVersionFound($this->getSubsequentRevisionId($entity, FALSE)); default: - throw new InvalidVersionIdentifierException(sprintf('The version identifier argument, `%s` is unrecognized.', $version_argument)); + return $this->resolveRevisionId($entity, $version_argument); } } /** - * Checks if the storage supports listing all the revisions of an entity. + * Resolves a revision ID given a relative version argument. + * + * The `predecessor-version`, `working-copy-of`, `prior-working-copy` and + * `subsequent-working-copy` version arguments are not very useful unless the + * requester can specify a relative version. If not, then these can only be + * resolved for the current default revision. + * + * For example, assume that a client would like to view the most recent + * changes. To do so, the latest revision must be compared with the preceding + * revision. The site has Content Moderation enabled and the latest revision + * is not the default revision. + * + * Remember, under RFC5829, the latest revision is known as the + * "working copy". Thus, the client needs to request the working copy using a + * `?resource_version=working-copy` query string to fetch the latest revision. + * + * Now, the client must request the second-to-last revision. How? One might + * guess `?resource_version=prior-working-copy`, but this would be ambiguous. + * Does the requester want the revision prior to the default revision or the + * latest revision? What if there are many revisions after the default + * revision? + * + * The requester must be able to indicate the relative version. This is + * accomplished by concatenating a relative version specifier with a fixed + * version specifier: + * + * `?resource_version=rel:prior-working-copy:working-copy` + * + * The two unique version specifiers are `latest-version` and `working-copy` + * since only one of each of these may exist in a version history. * - * @param \Drupal\Core\Entity\EntityStorageInterface $entity_storage - * The storage that may or may not support listing revision IDs. + * If needed, arbitrary versions can be specified using a revision ID: + * + * `?resource_version=prior-working-copy:42` + * + * This requests the working copy immediately prior to the version with + * revision ID 42. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to resolve a revision ID. + * @param $version_argument + * The argument which specifies with revision ID is requested. + * + * @return int + * A revision ID. * * @throws \Drupal\jsonapi\Revisions\InvalidVersionIdentifierException - * If the version history is not supported for this storage. + * @throws \Drupal\jsonapi\Revisions\VersionNotFoundException + * @throws \Drupal\Component\Plugin\Exception\PluginException */ - protected function checkVersionHistorySupport(EntityStorageInterface $entity_storage) { - if (!method_exists($entity_storage, 'revisionIds')) { - $message = sprintf('The storage class for the %s entity type does not support version history.', $entity_storage->getEntityTypeId()); + protected function resolveRevisionId(EntityInterface $entity, $version_argument) { + $specifiers = explode(VersionNegotiationManager::SEPARATOR, $version_argument); + if (count($specifiers) <= 1) { + throw new InvalidVersionIdentifierException(sprintf('Unrecognized version identifier argument: %s', $version_argument)); + } + elseif (count($specifiers) > 2) { + $message = sprintf('The `%s` version negotiator supports a maximum of 2 specifiers. %n given: %s', static::NEGOTIATOR_NAME, count($specifiers), implode(', ', $specifiers)); throw new InvalidVersionIdentifierException($message); } + list($first, $second) = $specifiers; + if (!is_numeric($second) && !in_array($second, [static::WORKING_COPY, static::LATEST_VERSION])) { + $message = sprintf('`%s` is not a unique version identifier argument. It must be a revision ID, `%s` or `%s`.', $second, static::WORKING_COPY, static::LATEST_VERSION); + throw new InvalidVersionIdentifierException($message); + } + $resolved = is_numeric($second) + ? $this->loadRevision($entity, $second) + : $this->getRevision($entity, $second); + return $this->getRevisionId($resolved, $first); + } + + /** + * Gets a revision ID prior to the revision ID given, if one exists. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to fetch a relative revision ID. + * @param bool $default + * Whether to consider non-default revision IDs. + * + * @return int|null + * The first prior default or non-default revision ID relative to the given + * revision ID. + */ + protected function getPriorRevisionId($entity, $default) { + $revision_ids = $this->revisionIds($entity, $default); + assert(!empty($revision_ids), 'Must not be empty because at least one revision exists.'); + $offset = array_search($entity->id(), $revision_ids, TRUE); + return $offset > 0 ? $revision_ids[$offset - 1] : NULL; + } + + + /** + * Gets a revision ID subsequent to the revision ID given, if one exists. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to fetch a relative revision ID. + * @param bool $default + * Whether to consider non-default revision IDs. + * + * @return int|null + * The first subsequent default or non-default revision ID relative to the + * given revision ID. + */ + protected function getSubsequentRevisionId($entity, $default) { + $revision_ids = $this->revisionIds($entity, $default); + assert(!empty($revision_ids), 'Must not be empty because at least one revision exists.'); + $offset = array_search($entity->id(), $revision_ids, TRUE); + return $offset < count($revision_ids) + 1 ? $revision_ids[$offset + 1] : NULL; + } + + /** + * Gets an array of revision IDs for the given entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to fetch a list of revision IDs. + * @param bool $exclude_non_default_ids + * Whether non-default revision IDs should be excluded from the query. + * + * @return int[] + * The list of revision IDs for the given entity, in ascending order. + * + * @todo: consider removing or replacing this method when https://www.drupal.org/project/drupal/issues/298602 lands. + */ + protected function revisionIds(EntityInterface $entity, $exclude_non_default_ids) { + $entity_type = $entity->getEntityType(); + if ($revision_table = $entity_type->getRevisionTable()) { + $query = \Drupal::database()->select($revision_table); + $query->condition($entity_type->getKey('id'), $entity->id()); + if ($exclude_non_default_ids) { + $query->condition('revision_default', 1); + } + $query->fields($revision_table, [$entity_type->getKey('revision')]); + $query->orderBy($entity_type->getKey('revision'), 'ASC'); + return $query->execute()->fetchCol(); + } + return []; } } diff --git a/src/Revisions/PluginNegotiationBase.php b/src/Revisions/PluginNegotiationBase.php index 5a581e0..2bc4eb9 100644 --- a/src/Revisions/PluginNegotiationBase.php +++ b/src/Revisions/PluginNegotiationBase.php @@ -34,49 +34,16 @@ abstract class PluginNegotiationBase extends PluginBase implements ContainerFact * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. */ - public function __construct( - array $configuration, - $plugin_id, - $plugin_definition, - EntityTypeManagerInterface $entity_type_manager - ) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); - $this->entityTypeManager = $entity_type_manager; } /** * {@inheritdoc} */ - public static function create( - ContainerInterface $container, - array $configuration, - $plugin_id, - $plugin_definition - ) { - /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ - $entity_type_manager = $container->get('entity_type.manager'); - return new static( - $configuration, - $plugin_id, - $plugin_definition, - $entity_type_manager - ); - } - - /** - * {@inheritdoc} - */ - public function getRevision(EntityInterface $entity, $version_argument) { - $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); - $revision = $storage->loadRevision($this->getRevisionId($entity, $version_argument)); - if (is_null($revision)) { - throw new VersionNotFoundException(); - } - elseif ($revision->id() !== $entity->id()) { - throw new InvalidVersionIdentifierException('The requested resource does not have a version with the given ID.'); - } - return $revision; + 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')); } /** @@ -97,4 +64,54 @@ abstract class PluginNegotiationBase extends PluginBase implements ContainerFact */ abstract protected function getRevisionId(EntityInterface $entity, $version_argument); + /** + * {@inheritdoc} + */ + public function getRevision(EntityInterface $entity, $version_argument) { + return $this->loadRevision($entity, $this->getRevisionId($entity, $version_argument)); + } + + /** + * Loads an entity revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity for which to load a revision. + * @param $revision_id + * The revision ID to be loaded. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The revision or NULL if the revision does not exists. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function loadRevision(EntityInterface $entity, $revision_id) { + $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + $revision = static::ensureVersionFound($storage->loadRevision($this->getRevisionId($entity, $revision_id))); + if ($revision->id() !== $entity->id()) { + throw new InvalidVersionIdentifierException('The requested resource does not have a version with the given ID.'); + } + return $revision; + } + + /** + * Helper method that ensures that a version exists. + * + * @param int|\Drupal\Core\Entity\EntityInterface $revision + * A revision ID, or NULL if one was not found. + * + * @return int|\Drupal\Core\Entity\EntityInterface + * A revision or revision ID, if one was found. + * + * @throws \Drupal\jsonapi\Revisions\VersionNotFoundException + * Thrown if the given value is NULL, meaning the requested version was not + * found. + */ + protected static function ensureVersionFound($revision) { + if (is_null($revision)) { + throw new VersionNotFoundException(); + } + return $revision; + } + }