diff --git a/core/modules/system/system.module b/core/modules/system/system.module index c7b2937684da87cfbaf87fe1d9cfed737675f6fa..4d2a7f6cbf046029fdd728b3b81ebe9d67629735 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -1454,3 +1454,25 @@ function system_element_info_alter(&$type) { $type['page']['#theme_wrappers']['off_canvas_page_wrapper'] = ['#weight' => -1000]; } } + +/** + * Implements hook_modules_uninstalled(). + */ +function system_modules_uninstalled($modules) { + // @todo Remove this when modules are able to maintain their revision metadata + // keys. + // @see https://www.drupal.org/project/drupal/issues/3074333 + if (!in_array('workspaces', $modules, TRUE)) { + return; + } + + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + foreach ($entity_definition_update_manager->getEntityTypes() as $entity_type) { + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + if ($revision_metadata_keys && array_key_exists('workspace', $revision_metadata_keys)) { + unset($revision_metadata_keys['workspace']); + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + $entity_definition_update_manager->updateEntityType($entity_type); + } + } +} diff --git a/core/modules/workspaces/src/Entity/WorkspaceAssociation.php b/core/modules/workspaces/src/Entity/WorkspaceAssociation.php deleted file mode 100644 index 6c65c81ed024c60b426fee0955dc4fcbf48bfb71..0000000000000000000000000000000000000000 --- a/core/modules/workspaces/src/Entity/WorkspaceAssociation.php +++ /dev/null @@ -1,77 +0,0 @@ -<?php - -namespace Drupal\workspaces\Entity; - -use Drupal\Core\Entity\ContentEntityBase; -use Drupal\Core\Entity\EntityTypeInterface; -use Drupal\Core\Field\BaseFieldDefinition; -use Drupal\Core\StringTranslation\TranslatableMarkup; - -/** - * Defines the Workspace association entity. - * - * @ContentEntityType( - * id = "workspace_association", - * label = @Translation("Workspace association"), - * label_collection = @Translation("Workspace associations"), - * label_singular = @Translation("workspace association"), - * label_plural = @Translation("workspace associations"), - * label_count = @PluralTranslation( - * singular = "@count workspace association", - * plural = "@count workspace associations" - * ), - * handlers = { - * "storage" = "Drupal\workspaces\WorkspaceAssociationStorage" - * }, - * base_table = "workspace_association", - * revision_table = "workspace_association_revision", - * internal = TRUE, - * entity_keys = { - * "id" = "id", - * "revision" = "revision_id", - * "uuid" = "uuid", - * } - * ) - * - * @internal - * This entity is marked internal because it should not be used directly to - * alter the workspace an entity belongs to. - */ -class WorkspaceAssociation extends ContentEntityBase { - - /** - * {@inheritdoc} - */ - public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { - $fields = parent::baseFieldDefinitions($entity_type); - - $fields['workspace'] = BaseFieldDefinition::create('entity_reference') - ->setLabel(new TranslatableMarkup('workspace')) - ->setDescription(new TranslatableMarkup('The workspace of the referenced content.')) - ->setSetting('target_type', 'workspace') - ->setRequired(TRUE) - ->setRevisionable(TRUE); - - $fields['target_entity_type_id'] = BaseFieldDefinition::create('string') - ->setLabel(new TranslatableMarkup('Content entity type ID')) - ->setDescription(new TranslatableMarkup('The ID of the content entity type associated with this workspace.')) - ->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH) - ->setRequired(TRUE) - ->setRevisionable(TRUE); - - $fields['target_entity_id'] = BaseFieldDefinition::create('integer') - ->setLabel(new TranslatableMarkup('Content entity ID')) - ->setDescription(new TranslatableMarkup('The ID of the content entity associated with this workspace.')) - ->setRequired(TRUE) - ->setRevisionable(TRUE); - - $fields['target_entity_revision_id'] = BaseFieldDefinition::create('integer') - ->setLabel(new TranslatableMarkup('Content entity revision ID')) - ->setDescription(new TranslatableMarkup('The revision ID of the content entity associated with this workspace.')) - ->setRequired(TRUE) - ->setRevisionable(TRUE); - - return $fields; - } - -} diff --git a/core/modules/workspaces/src/EntityOperations.php b/core/modules/workspaces/src/EntityOperations.php index 6182da200f2cbc7606a7d0071f0e3c90741e67b6..4409162a282b2dcd967bed2286af0f6c14f222b7 100644 --- a/core/modules/workspaces/src/EntityOperations.php +++ b/core/modules/workspaces/src/EntityOperations.php @@ -34,6 +34,13 @@ class EntityOperations implements ContainerInjectionInterface { */ protected $workspaceManager; + /** + * The workspace association service. + * + * @var \Drupal\workspaces\WorkspaceAssociationInterface + */ + protected $workspaceAssociation; + /** * Constructs a new EntityOperations instance. * @@ -41,10 +48,13 @@ class EntityOperations implements ContainerInjectionInterface { * The entity type manager service. * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager * The workspace manager service. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association) { $this->entityTypeManager = $entity_type_manager; $this->workspaceManager = $workspace_manager; + $this->workspaceAssociation = $workspace_association; } /** @@ -53,7 +63,8 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Wor public static function create(ContainerInterface $container) { return new static( $container->get('entity_type.manager'), - $container->get('workspaces.manager') + $container->get('workspaces.manager'), + $container->get('workspaces.association') ); } @@ -74,31 +85,13 @@ public function entityPreload(array $ids, $entity_type_id) { // Get a list of revision IDs for entities that have a revision set for the // current active workspace. If an entity has multiple revisions set for a // workspace, only the one with the highest ID is returned. - $max_revision_id = 'max_target_entity_revision_id'; - $query = $this->entityTypeManager - ->getStorage('workspace_association') - ->getAggregateQuery() - ->accessCheck(FALSE) - ->allRevisions() - ->aggregate('target_entity_revision_id', 'MAX', NULL, $max_revision_id) - ->groupBy('target_entity_id') - ->condition('target_entity_type_id', $entity_type_id) - ->condition('workspace', $this->workspaceManager->getActiveWorkspace()->id()); - - if ($ids) { - $query->condition('target_entity_id', $ids, 'IN'); - } - - $results = $query->execute(); - - if ($results) { + if ($tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->workspaceManager->getActiveWorkspace()->id(), $entity_type_id, $ids)) { /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */ $storage = $this->entityTypeManager->getStorage($entity_type_id); // Swap out every entity which has a revision set for the current active // workspace. - $swap_revision_ids = array_column($results, $max_revision_id); - foreach ($storage->loadMultipleRevisions($swap_revision_ids) as $revision) { + foreach ($storage->loadMultipleRevisions(array_keys($tracked_entities[$entity_type_id])) as $revision) { $entities[$revision->id()] = $revision; } } @@ -142,6 +135,10 @@ public function entityPresave(EntityInterface $entity) { // become the default revision only when it is replicated to the default // workspace. $entity->isDefaultRevision(FALSE); + + // Track the workspaces in which the new revision was saved. + $field_name = $entity_type->getRevisionMetadataKey('workspace'); + $entity->{$field_name}->target_id = $this->workspaceManager->getActiveWorkspace()->id(); } // When a new published entity is inserted in a non-default workspace, we @@ -174,7 +171,7 @@ public function entityInsert(EntityInterface $entity) { return; } - $this->trackEntity($entity); + $this->workspaceAssociation->trackEntity($entity, $this->workspaceManager->getActiveWorkspace()); // When an entity is newly created in a workspace, it should be published in // that workspace, but not yet published on the live workspace. It is first @@ -211,7 +208,7 @@ public function entityUpdate(EntityInterface $entity) { // Only track new revisions. /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ if ($entity->getLoadedRevisionId() != $entity->getRevisionId()) { - $this->trackEntity($entity); + $this->workspaceAssociation->trackEntity($entity, $this->workspaceManager->getActiveWorkspace()); } } @@ -240,51 +237,6 @@ public function entityPredelete(EntityInterface $entity) { } } - /** - * Updates or creates a WorkspaceAssociation entity for a given entity. - * - * If the passed-in entity can belong to a workspace and already has a - * WorkspaceAssociation entity, then a new revision of this will be created with - * the new information. Otherwise, a new WorkspaceAssociation entity is created to - * store the passed-in entity's information. - * - * @param \Drupal\Core\Entity\RevisionableInterface $entity - * The entity to update or create from. - */ - protected function trackEntity(RevisionableInterface $entity) { - // If the entity is not new, check if there's an existing - // WorkspaceAssociation entity for it. - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); - if (!$entity->isNew()) { - $workspace_associations = $workspace_association_storage->loadByProperties([ - 'target_entity_type_id' => $entity->getEntityTypeId(), - 'target_entity_id' => $entity->id(), - ]); - - /** @var \Drupal\Core\Entity\ContentEntityInterface $workspace_association */ - $workspace_association = reset($workspace_associations); - } - - // If there was a WorkspaceAssociation entry create a new revision, - // otherwise create a new entity with the type and ID. - if (!empty($workspace_association)) { - $workspace_association->setNewRevision(TRUE); - } - else { - $workspace_association = $workspace_association_storage->create([ - 'target_entity_type_id' => $entity->getEntityTypeId(), - 'target_entity_id' => $entity->id(), - ]); - } - - // Add the revision ID and the workspace ID. - $workspace_association->set('target_entity_revision_id', $entity->getRevisionId()); - $workspace_association->set('workspace', $this->workspaceManager->getActiveWorkspace()->id()); - - // Save without updating the tracked content entity. - $workspace_association->save(); - } - /** * Alters entity forms to disallow concurrent editing in multiple workspaces. * @@ -298,7 +250,7 @@ protected function trackEntity(RevisionableInterface $entity) { * @see hook_form_alter() */ public function entityFormAlter(array &$form, FormStateInterface $form_state, $form_id) { - /** @var \Drupal\Core\Entity\EntityInterface $entity */ + /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ $entity = $form_state->getFormObject()->getEntity(); if (!$this->workspaceManager->isEntityTypeSupported($entity->getEntityType())) { return; @@ -318,9 +270,7 @@ public function entityFormAlter(array &$form, FormStateInterface $form_state, $f $form['#entity_builders'][] = [get_called_class(), 'entityFormEntityBuild']; } - /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); - if ($workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity)) { + if ($workspace_ids = $this->workspaceAssociation->getEntityTrackingWorkspaceIds($entity)) { // An entity can only be edited in one workspace. $workspace_id = reset($workspace_ids); diff --git a/core/modules/workspaces/src/EntityTypeInfo.php b/core/modules/workspaces/src/EntityTypeInfo.php index 5495c7fa4e7c09c31db6484bc881ad7126279341..7a72eb246d26cc8beefb42fc5000599e04bb53e1 100644 --- a/core/modules/workspaces/src/EntityTypeInfo.php +++ b/core/modules/workspaces/src/EntityTypeInfo.php @@ -3,7 +3,10 @@ namespace Drupal\workspaces; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -66,6 +69,10 @@ public function entityTypeBuild(array &$entity_types) { foreach ($entity_types as $entity_type) { if ($this->workspaceManager->isEntityTypeSupported($entity_type)) { $entity_type->addConstraint('EntityWorkspaceConflict'); + + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + $revision_metadata_keys['workspace'] = 'workspace'; + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); } } } @@ -84,4 +91,30 @@ public function fieldInfoAlter(&$definitions) { } } + /** + * Provides custom base field definitions for a content entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type definition. + * + * @return \Drupal\Core\Field\FieldDefinitionInterface[] + * An array of field definitions, keyed by field name. + * + * @see hook_entity_base_field_info() + */ + public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { + if ($this->workspaceManager->isEntityTypeSupported($entity_type)) { + $field_name = $entity_type->getRevisionMetadataKey('workspace'); + $fields[$field_name] = BaseFieldDefinition::create('entity_reference') + ->setLabel(new TranslatableMarkup('Workspace')) + ->setDescription(new TranslatableMarkup('Indicates the workspace that this revision belongs to.')) + ->setSetting('target_type', 'workspace') + ->setInternal(TRUE) + ->setTranslatable(FALSE) + ->setRevisionable(TRUE); + + return $fields; + } + } + } diff --git a/core/modules/workspaces/src/EventSubscriber/EntitySchemaSubscriber.php b/core/modules/workspaces/src/EventSubscriber/EntitySchemaSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..30fd77aa6445cca0ed96aacd74acaef04a63c299 --- /dev/null +++ b/core/modules/workspaces/src/EventSubscriber/EntitySchemaSubscriber.php @@ -0,0 +1,147 @@ +<?php + +namespace Drupal\workspaces\EventSubscriber; + +use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface; +use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface; +use Drupal\Core\Entity\EntityTypeEventSubscriberTrait; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeListenerInterface; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\workspaces\WorkspaceManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines a class for listening to entity schema changes. + */ +class EntitySchemaSubscriber implements EntityTypeListenerInterface, EventSubscriberInterface { + + use EntityTypeEventSubscriberTrait; + use StringTranslationTrait; + + /** + * The definition update manager. + * + * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface + */ + protected $entityDefinitionUpdateManager; + + /** + * The last installed schema definitions. + * + * @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface + */ + protected $entityLastInstalledSchemaRepository; + + /** + * The workspace manager. + * + * @var \Drupal\workspaces\WorkspaceManagerInterface + */ + protected $workspaceManager; + + /** + * Constructs a new EntitySchemaSubscriber. + * + * @param \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager + * Definition update manager. + * @param \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository + * Last definitions. + * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager + * The workspace manager. + */ + public function __construct(EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager, EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository, WorkspaceManagerInterface $workspace_manager) { + $this->entityDefinitionUpdateManager = $entityDefinitionUpdateManager; + $this->entityLastInstalledSchemaRepository = $entityLastInstalledSchemaRepository; + $this->workspaceManager = $workspace_manager; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return static::getEntityTypeEvents(); + } + + /** + * {@inheritdoc} + */ + public function onEntityTypeCreate(EntityTypeInterface $entity_type) { + // Nothing to do here. + } + + /** + * {@inheritdoc} + */ + public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original) { + // If the entity type is now supported by Workspaces, add the revision + // metadata field. + if ($this->workspaceManager->isEntityTypeSupported($entity_type) && !$this->workspaceManager->isEntityTypeSupported($original)) { + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + + if (!isset($revision_metadata_keys['workspace'])) { + // Bail out if there's an existing field called 'workspace'. + if ($this->entityDefinitionUpdateManager->getFieldStorageDefinition('workspace', $entity_type->id())) { + throw new \RuntimeException("An existing 'workspace' field was found for the '{$entity_type->id()}' entity type. Set the 'workspace' revision metadata key to use a different field name and run this update function again."); + } + + $revision_metadata_keys['workspace'] = 'workspace'; + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + + // We are only adding a revision metadata key so we don't need to go + // through the entity update process. + $this->entityLastInstalledSchemaRepository->setLastInstalledDefinition($entity_type); + } + + $this->entityDefinitionUpdateManager->installFieldStorageDefinition($revision_metadata_keys['workspace'], $entity_type->id(), 'workspaces', $this->getWorkspaceFieldDefinition()); + } + + // If the entity type is no longer supported by Workspaces, remove the + // revision metadata field. + if ($this->workspaceManager->isEntityTypeSupported($original) && !$this->workspaceManager->isEntityTypeSupported($entity_type)) { + $revision_metadata_keys = $original->get('revision_metadata_keys'); + $field_storage_definition = $this->entityLastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($entity_type->id())[$revision_metadata_keys['workspace']]; + $this->entityDefinitionUpdateManager->uninstallFieldStorageDefinition($field_storage_definition); + + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + unset($revision_metadata_keys['workspace']); + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + + // We are only removing a revision metadata key so we don't need to go + // through the entity update process. + $this->entityLastInstalledSchemaRepository->setLastInstalledDefinition($entity_type); + } + } + + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $this->onEntityTypeUpdate($entity_type, $original); + } + + /** + * {@inheritdoc} + */ + public function onEntityTypeDelete(EntityTypeInterface $entity_type) { + // Nothing to do here. + } + + /** + * Gets the base field definition for the 'workspace' revision metadata field. + * + * @return \Drupal\Core\Field\BaseFieldDefinition + * The base field definition. + */ + protected function getWorkspaceFieldDefinition() { + return BaseFieldDefinition::create('entity_reference') + ->setLabel($this->t('Workspace')) + ->setDescription($this->t('Indicates the workspace that this revision belongs to.')) + ->setSetting('target_type', 'workspace') + ->setInternal(TRUE) + ->setTranslatable(FALSE) + ->setRevisionable(TRUE); + } + +} diff --git a/core/modules/workspaces/src/Form/WorkspaceDeleteForm.php b/core/modules/workspaces/src/Form/WorkspaceDeleteForm.php index 8086873f9ade48b67d544f764fdbc7b1b9aaaf04..195cac732fd28135267597680714a079850962a9 100644 --- a/core/modules/workspaces/src/Form/WorkspaceDeleteForm.php +++ b/core/modules/workspaces/src/Form/WorkspaceDeleteForm.php @@ -2,8 +2,13 @@ namespace Drupal\workspaces\Form; +use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Entity\ContentEntityDeleteForm; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\workspaces\WorkspaceAssociationInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Provides a form for deleting a workspace. @@ -19,14 +24,52 @@ class WorkspaceDeleteForm extends ContentEntityDeleteForm implements WorkspaceFo */ protected $entity; + /** + * The workspace association service. + * + * @var \Drupal\workspaces\WorkspaceAssociationInterface + */ + protected $workspaceAssociation; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.repository'), + $container->get('workspaces.association'), + $container->get('entity_type.bundle.info'), + $container->get('datetime.time') + ); + } + + /** + * Constructs a WorkspaceDeleteForm object. + * + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository + * The entity repository service. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service to check how many revisions will be + * deleted. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info + * The entity type bundle service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + */ + public function __construct(EntityRepositoryInterface $entity_repository, WorkspaceAssociationInterface $workspace_association, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) { + parent::__construct($entity_repository, $entity_type_bundle_info, $time); + $this->workspaceAssociation = $workspace_association; + } + /** * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { $form = parent::buildForm($form, $form_state); - $source_rev_diff = $this->entityTypeManager->getStorage('workspace_association')->getTrackedEntities($this->entity->id()); + $tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->entity->id()); $items = []; - foreach ($source_rev_diff as $entity_type_id => $revision_ids) { + foreach (array_keys($tracked_entities) as $entity_type_id => $entity_ids) { + $revision_ids = $this->workspaceAssociation->getAssociatedRevisions($this->entity->id(), $entity_type_id, $entity_ids); $label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel(); $items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]); } diff --git a/core/modules/workspaces/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php b/core/modules/workspaces/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php index 070e89050bb8adae926b7336766230b72be792cf..1543b5fc2b30fca4e9499667221522f25b39b2bc 100644 --- a/core/modules/workspaces/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php +++ b/core/modules/workspaces/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php @@ -3,7 +3,7 @@ namespace Drupal\workspaces\Plugin\Validation\Constraint; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; -use Drupal\workspaces\WorkspaceAssociationStorageInterface; +use Drupal\workspaces\WorkspaceAssociationInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; @@ -14,20 +14,20 @@ class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface { /** - * The workspace association storage. + * The workspace association service. * - * @var \Drupal\workspaces\WorkspaceAssociationStorageInterface + * @var \Drupal\workspaces\WorkspaceAssociationInterface */ - protected $workspaceAssociationStorage; + protected $workspaceAssociation; /** * Creates a new DeletedWorkspaceConstraintValidator instance. * - * @param \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage - * The workspace association storage. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. */ - public function __construct(WorkspaceAssociationStorageInterface $workspace_association_storage) { - $this->workspaceAssociationStorage = $workspace_association_storage; + public function __construct(WorkspaceAssociationInterface $workspace_association) { + $this->workspaceAssociation = $workspace_association; } /** @@ -35,7 +35,7 @@ public function __construct(WorkspaceAssociationStorageInterface $workspace_asso */ public static function create(ContainerInterface $container) { return new static( - $container->get('entity_type.manager')->getStorage('workspace_association') + $container->get('workspaces.association') ); } @@ -49,14 +49,7 @@ public function validate($value, Constraint $constraint) { return; } - $count = $this->workspaceAssociationStorage - ->getQuery() - ->allRevisions() - ->accessCheck(FALSE) - ->condition('workspace', $value->getEntity()->id()) - ->count() - ->execute(); - if ($count) { + if ($this->workspaceAssociation->getTrackedEntities($value->getEntity()->id())) { $this->context->addViolation($constraint->message); } } diff --git a/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php index 17adc7c0d2d892930b2f3904f0a9758ce2cacca5..66bb887e7b1243ee6047817035d75063826a6e62 100644 --- a/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php +++ b/core/modules/workspaces/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php @@ -4,6 +4,7 @@ use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\workspaces\WorkspaceAssociationInterface; use Drupal\workspaces\WorkspaceManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\Constraint; @@ -28,6 +29,13 @@ class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator imp */ protected $workspaceManager; + /** + * The workspace association service. + * + * @var \Drupal\workspaces\WorkspaceAssociationInterface + */ + protected $workspaceAssociation; + /** * Constructs an EntityUntranslatableFieldsConstraintValidator object. * @@ -35,10 +43,13 @@ class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator imp * The entity type manager service. * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager * The workspace manager service. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association) { $this->entityTypeManager = $entity_type_manager; $this->workspaceManager = $workspace_manager; + $this->workspaceAssociation = $workspace_association; } /** @@ -47,7 +58,8 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Wor public static function create(ContainerInterface $container) { return new static( $container->get('entity_type.manager'), - $container->get('workspaces.manager') + $container->get('workspaces.manager'), + $container->get('workspaces.association') ); } @@ -57,9 +69,7 @@ public static function create(ContainerInterface $container) { public function validate($entity, Constraint $constraint) { /** @var \Drupal\Core\Entity\EntityInterface $entity */ if (isset($entity) && !$entity->isNew()) { - /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); - $workspace_ids = $workspace_association_storage->getEntityTrackingWorkspaceIds($entity); + $workspace_ids = $this->workspaceAssociation->getEntityTrackingWorkspaceIds($entity); $active_workspace = $this->workspaceManager->getActiveWorkspace(); if ($workspace_ids && (!$active_workspace || !in_array($active_workspace->id(), $workspace_ids, TRUE))) { diff --git a/core/modules/workspaces/src/WorkspaceAssociation.php b/core/modules/workspaces/src/WorkspaceAssociation.php new file mode 100644 index 0000000000000000000000000000000000000000..a67f9546937278b9aaa16adcafe0cbf917cbaae8 --- /dev/null +++ b/core/modules/workspaces/src/WorkspaceAssociation.php @@ -0,0 +1,163 @@ +<?php + +namespace Drupal\workspaces; + +use Drupal\Core\Database\Connection; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\Sql\SqlContentEntityStorage; + +/** + * Provides a class for CRUD operations on workspace associations. + */ +class WorkspaceAssociation implements WorkspaceAssociationInterface { + + /** + * The table for the workspace association storage. + */ + const TABLE = 'workspace_association'; + + /** + * The database connection. + * + * @var \Drupal\Core\Database\Connection + */ + protected $database; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Constructs a WorkspaceAssociation object. + * + * @param \Drupal\Core\Database\Connection $connection + * A database connection for reading and writing path aliases. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager for querying revisions. + */ + public function __construct(Connection $connection, EntityTypeManagerInterface $entity_type_manager) { + $this->database = $connection; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public function trackEntity(RevisionableInterface $entity, WorkspaceInterface $workspace) { + $this->database->merge(static::TABLE) + ->fields([ + 'target_entity_revision_id' => $entity->getRevisionId(), + ]) + ->keys([ + 'workspace' => $workspace->id(), + 'target_entity_type_id' => $entity->getEntityTypeId(), + 'target_entity_id' => $entity->id(), + ]) + ->execute(); + } + + /** + * {@inheritdoc} + */ + public function getTrackedEntities($workspace_id, $entity_type_id = NULL, $entity_ids = NULL) { + $query = $this->database->select(static::TABLE); + $query + ->fields(static::TABLE, ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id']) + ->orderBy('target_entity_revision_id', 'ASC') + ->condition('workspace', $workspace_id); + + if ($entity_type_id) { + $query->condition('target_entity_type_id', $entity_type_id, '='); + + if ($entity_ids) { + $query->condition('target_entity_id', $entity_ids, 'IN'); + } + } + + $tracked_revisions = []; + foreach ($query->execute() as $record) { + $tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id; + } + + return $tracked_revisions; + } + + /** + * {@inheritdoc} + */ + public function getAssociatedRevisions($workspace_id, $entity_type_id, $entity_ids = NULL) { + /** @var \Drupal\Core\Entity\EntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($entity_type_id); + + // If the entity type is not using core's default entity storage, we can't + // assume the table mapping layout so we have to return only the latest + // tracked revisions. + if (!$storage instanceof SqlContentEntityStorage) { + return $this->getTrackedEntities($workspace_id, $entity_type_id, $entity_ids)[$entity_type_id]; + } + + $entity_type = $storage->getEntityType(); + $table_mapping = $storage->getTableMapping(); + $workspace_field = $table_mapping->getColumnNames($entity_type->get('revision_metadata_keys')['workspace'])['target_id']; + $id_field = $table_mapping->getColumnNames($entity_type->getKey('id'))['value']; + $revision_id_field = $table_mapping->getColumnNames($entity_type->getKey('revision'))['value']; + + $query = $this->database->select($entity_type->getRevisionTable(), 'revision'); + $query->leftJoin($entity_type->getBaseTable(), 'base', "revision.$id_field = base.$id_field"); + + $query + ->fields('revision', [$revision_id_field, $id_field]) + ->condition("revision.$workspace_field", $workspace_id) + ->where("revision.$revision_id_field > base.$revision_id_field") + ->orderBy("revision.$revision_id_field", 'ASC'); + + // Restrict the result to a set of entity ID's if provided. + if ($entity_ids) { + $query->condition("revision.$id_field", $entity_ids, 'IN'); + } + + return $query->execute()->fetchAllKeyed(); + } + + /** + * {@inheritdoc} + */ + public function getEntityTrackingWorkspaceIds(RevisionableInterface $entity) { + $query = $this->database->select(static::TABLE) + ->fields(static::TABLE, ['workspace']) + ->condition('target_entity_type_id', $entity->getEntityTypeId()) + ->condition('target_entity_id', $entity->id()); + + return $query->execute()->fetchCol(); + } + + /** + * {@inheritdoc} + */ + public function postPublish(WorkspaceInterface $workspace) { + $this->deleteAssociations($workspace->id()); + } + + /** + * {@inheritdoc} + */ + public function deleteAssociations($workspace_id, $entity_type_id = NULL, $entity_ids = NULL) { + $query = $this->database->delete(static::TABLE) + ->condition('workspace', $workspace_id); + + if ($entity_type_id) { + $query->condition('target_entity_type_id', $entity_type_id, '='); + + if ($entity_ids) { + $query->condition('target_entity_id', $entity_ids, 'IN'); + } + } + + $query->execute(); + } + +} diff --git a/core/modules/workspaces/src/WorkspaceAssociationInterface.php b/core/modules/workspaces/src/WorkspaceAssociationInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..1c93ca4aaa197bc8320cc7c9465dff9dc34ab64c --- /dev/null +++ b/core/modules/workspaces/src/WorkspaceAssociationInterface.php @@ -0,0 +1,103 @@ +<?php + +namespace Drupal\workspaces; + +use Drupal\Core\Entity\RevisionableInterface; + +/** + * Defines an interface for the workspace_association service. + * + * The canonical workspace association data is stored in a revision metadata + * field on each entity revision that is tracked by a workspace. + * + * For the purpose of optimizing workspace-specific queries, the default + * implementation of this interface defines a custom 'workspace_association' + * index table which stores only the latest revisions tracked by a workspace. + * + * @internal + */ +interface WorkspaceAssociationInterface { + + /** + * Updates or creates the association for a given entity and a workspace. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * The entity to update or create from. + * @param \Drupal\workspaces\WorkspaceInterface $workspace + * The workspace in which the entity will be tracked. + */ + public function trackEntity(RevisionableInterface $entity, WorkspaceInterface $workspace); + + /** + * Retrieves the entities tracked by a given workspace. + * + * @param string $workspace_id + * The ID of the workspace. + * @param string|null $entity_type_id + * (optional) An entity type ID to filter the results by. Defaults to NULL. + * @param int[]|string[]|null $entity_ids + * (optional) An array of entity IDs to filter the results by. Defaults to + * NULL. + * + * @return array + * Returns a multidimensional array where the first level keys are entity + * type IDs and the values are an array of entity IDs keyed by revision IDs. + */ + public function getTrackedEntities($workspace_id, $entity_type_id = NULL, $entity_ids = NULL); + + /** + * Retrieves all content revisions tracked by a given workspace. + * + * Since the 'workspace_association' index table only tracks the latest + * associated revisions, this method retrieves all the tracked revisions by + * querying the entity type's revision table directly. + * + * @param string $workspace_id + * The ID of the workspace. + * @param string $entity_type_id + * An entity type ID to find revisions for. + * @param int[]|string[]|null $entity_ids + * (optional) An array of entity IDs to filter the results by. Defaults to + * NULL. + * + * @return array + * Returns an array where the values are an array of entity IDs keyed by + * revision IDs. + */ + public function getAssociatedRevisions($workspace_id, $entity_type_id, $entity_ids = NULL); + + /** + * Gets a list of workspace IDs in which an entity is tracked. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * An entity object. + * + * @return string[] + * An array of workspace IDs where the given entity is tracked, or an empty + * array if it is not tracked anywhere. + */ + public function getEntityTrackingWorkspaceIds(RevisionableInterface $entity); + + /** + * Triggers clean-up operations after publishing a workspace. + * + * @param \Drupal\workspaces\WorkspaceInterface $workspace + * A workspace entity. + */ + public function postPublish(WorkspaceInterface $workspace); + + /** + * Deletes all the workspace association records for the given workspace. + * + * @param string $workspace_id + * A workspace entity ID. + * @param string|null $entity_type_id + * (optional) The target entity type of the associations to delete. Defaults + * to NULL. + * @param string|null $entity_ids + * (optional) The target entity IDs of the associations to delete. Defaults + * to NULL. + */ + public function deleteAssociations($workspace_id, $entity_type_id = NULL, $entity_ids = NULL); + +} diff --git a/core/modules/workspaces/src/WorkspaceAssociationStorage.php b/core/modules/workspaces/src/WorkspaceAssociationStorage.php deleted file mode 100644 index 6355e79220e49aa51251fcf1a9bb033e473ce393..0000000000000000000000000000000000000000 --- a/core/modules/workspaces/src/WorkspaceAssociationStorage.php +++ /dev/null @@ -1,59 +0,0 @@ -<?php - -namespace Drupal\workspaces; - -use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\Sql\SqlContentEntityStorage; - -/** - * Defines the storage handler class for the Workspace association entity type. - */ -class WorkspaceAssociationStorage extends SqlContentEntityStorage implements WorkspaceAssociationStorageInterface { - - /** - * {@inheritdoc} - */ - public function postPush(WorkspaceInterface $workspace) { - $this->database - ->delete($this->entityType->getBaseTable()) - ->condition('workspace', $workspace->id()) - ->execute(); - $this->database - ->delete($this->entityType->getRevisionTable()) - ->condition('workspace', $workspace->id()) - ->execute(); - } - - /** - * {@inheritdoc} - */ - public function getTrackedEntities($workspace_id, $all_revisions = FALSE) { - $table = $all_revisions ? $this->getRevisionTable() : $this->getBaseTable(); - $query = $this->database->select($table, 'base_table'); - $query - ->fields('base_table', ['target_entity_type_id', 'target_entity_id', 'target_entity_revision_id']) - ->orderBy('target_entity_revision_id', 'ASC') - ->condition('workspace', $workspace_id); - - $tracked_revisions = []; - foreach ($query->execute() as $record) { - $tracked_revisions[$record->target_entity_type_id][$record->target_entity_revision_id] = $record->target_entity_id; - } - - return $tracked_revisions; - } - - /** - * {@inheritdoc} - */ - public function getEntityTrackingWorkspaceIds(EntityInterface $entity) { - $query = $this->database->select($this->getBaseTable(), 'base_table'); - $query - ->fields('base_table', ['workspace']) - ->condition('target_entity_type_id', $entity->getEntityTypeId()) - ->condition('target_entity_id', $entity->id()); - - return $query->execute()->fetchCol(); - } - -} diff --git a/core/modules/workspaces/src/WorkspaceAssociationStorageInterface.php b/core/modules/workspaces/src/WorkspaceAssociationStorageInterface.php deleted file mode 100644 index 24663206e30c6d33e04f75e75af8986c8462dc8c..0000000000000000000000000000000000000000 --- a/core/modules/workspaces/src/WorkspaceAssociationStorageInterface.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php - -namespace Drupal\workspaces; - -use Drupal\Core\Entity\ContentEntityStorageInterface; -use Drupal\Core\Entity\EntityInterface; - -/** - * Defines an interface for workspace association entity storage classes. - */ -interface WorkspaceAssociationStorageInterface extends ContentEntityStorageInterface { - - /** - * Triggers clean-up operations after pushing. - * - * @param \Drupal\workspaces\WorkspaceInterface $workspace - * A workspace entity. - */ - public function postPush(WorkspaceInterface $workspace); - - /** - * Retrieves the content revisions tracked by a given workspace. - * - * @param string $workspace_id - * The ID of the workspace. - * @param bool $all_revisions - * (optional) Whether to return all the tracked revisions for each entity or - * just the latest tracked revision. Defaults to FALSE. - * - * @return array - * Returns a multidimensional array where the first level keys are entity - * type IDs and the values are an array of entity IDs keyed by revision IDs. - */ - public function getTrackedEntities($workspace_id, $all_revisions = FALSE); - - /** - * Gets a list of workspace IDs in which an entity is tracked. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * An entity object. - * - * @return string[] - * An array of workspace IDs where the given entity is tracked, or an empty - * array if it is not tracked anywhere. - */ - public function getEntityTrackingWorkspaceIds(EntityInterface $entity); - -} diff --git a/core/modules/workspaces/src/WorkspaceManager.php b/core/modules/workspaces/src/WorkspaceManager.php index 5528988a80baa21e71058e55da8c230c43e1c2c8..b53be3613025a3e12416a50da5fea8a57ea35164 100644 --- a/core/modules/workspaces/src/WorkspaceManager.php +++ b/core/modules/workspaces/src/WorkspaceManager.php @@ -83,6 +83,13 @@ class WorkspaceManager implements WorkspaceManagerInterface { */ protected $classResolver; + /** + * The workspace association service. + * + * @var \Drupal\workspaces\WorkspaceAssociationInterface + */ + protected $workspaceAssociation; + /** * The workspace negotiator service IDs. * @@ -114,10 +121,12 @@ class WorkspaceManager implements WorkspaceManagerInterface { * A logger instance. * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver * The class resolver. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. * @param array $negotiator_ids * The workspace negotiator service IDs. */ - public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, MemoryCacheInterface $entity_memory_cache, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) { + public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, MemoryCacheInterface $entity_memory_cache, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, WorkspaceAssociationInterface $workspace_association, array $negotiator_ids) { $this->requestStack = $request_stack; $this->entityTypeManager = $entity_type_manager; $this->entityMemoryCache = $entity_memory_cache; @@ -125,6 +134,7 @@ public function __construct(RequestStack $request_stack, EntityTypeManagerInterf $this->state = $state; $this->logger = $logger; $this->classResolver = $class_resolver; + $this->workspaceAssociation = $workspace_association; $this->negotiatorIds = $negotiator_ids; } @@ -305,67 +315,35 @@ public function purgeDeletedWorkspacesBatch() { $batch_size = Settings::get('entity_update_batch_size', 50); - /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); - // Get the first deleted workspace from the list and delete the revisions - // associated with it, along with the workspace_association entries. + // associated with it, along with the workspace association records. $workspace_id = reset($deleted_workspace_ids); - $workspace_association_ids = $this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size); - - if ($workspace_association_ids) { - $workspace_associations = $workspace_association_storage->loadMultipleRevisions(array_keys($workspace_association_ids)); - foreach ($workspace_associations as $workspace_association) { - $associated_entity_storage = $this->entityTypeManager->getStorage($workspace_association->target_entity_type_id->value); - // Delete the associated entity revision. - if ($entity = $associated_entity_storage->loadRevision($workspace_association->target_entity_revision_id->value)) { - if ($entity->isDefaultRevision()) { - $entity->delete(); - } - else { - $associated_entity_storage->deleteRevision($workspace_association->target_entity_revision_id->value); - } + $tracked_entities = $this->workspaceAssociation->getTrackedEntities($workspace_id); + + $count = 1; + foreach ($tracked_entities as $entity_type_id => $entities) { + $associated_entity_storage = $this->entityTypeManager->getStorage($entity_type_id); + $associated_revisions = $this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id); + foreach (array_keys($associated_revisions) as $revision_id) { + if ($count > $batch_size) { + continue 2; } - // Delete the workspace_association revision. - if ($workspace_association->isDefaultRevision()) { - $workspace_association->delete(); - } - else { - $workspace_association_storage->deleteRevision($workspace_association->getRevisionId()); - } + // Delete the associated entity revision. + $associated_entity_storage->deleteRevision($revision_id); + $count++; } + // Delete the workspace association entries. + $this->workspaceAssociation->deleteAssociations($workspace_id, $entity_type_id, $entities); } // The purging operation above might have taken a long time, so we need to - // request a fresh list of workspace association IDs. If it is empty, we can - // go ahead and remove the deleted workspace ID entry from state. - if (!$this->getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size)) { + // request a fresh list of tracked entities. If it is empty, we can go ahead + // and remove the deleted workspace ID entry from state. + if (!$this->workspaceAssociation->getTrackedEntities($workspace_id)) { unset($deleted_workspace_ids[$workspace_id]); $this->state->set('workspace.deleted', $deleted_workspace_ids); } } - /** - * Gets a list of workspace association IDs to purge. - * - * @param string $workspace_id - * The ID of the workspace. - * @param int $batch_size - * The maximum number of records that will be purged. - * - * @return array - * An array of workspace association IDs, keyed by their revision IDs. - */ - protected function getWorkspaceAssociationRevisionsToPurge($workspace_id, $batch_size) { - return $this->entityTypeManager->getStorage('workspace_association') - ->getQuery() - ->allRevisions() - ->accessCheck(FALSE) - ->condition('workspace', $workspace_id) - ->sort('revision_id', 'ASC') - ->range(0, $batch_size) - ->execute(); - } - } diff --git a/core/modules/workspaces/src/WorkspaceOperationFactory.php b/core/modules/workspaces/src/WorkspaceOperationFactory.php index d523365667ac508bab102a48d0860ad6d15f7bd4..7b761a4a3994c6f33a6c014b9312a7c72432b959 100644 --- a/core/modules/workspaces/src/WorkspaceOperationFactory.php +++ b/core/modules/workspaces/src/WorkspaceOperationFactory.php @@ -36,6 +36,13 @@ class WorkspaceOperationFactory { */ protected $workspaceManager; + /** + * The workspace association service. + * + * @var \Drupal\workspaces\WorkspaceAssociationInterface + */ + protected $workspaceAssociation; + /** * Constructs a new WorkspacePublisher. * @@ -43,11 +50,16 @@ class WorkspaceOperationFactory { * The entity type manager. * @param \Drupal\Core\Database\Connection $database * Database connection. + * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager + * The workspace manager service. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association) { $this->entityTypeManager = $entity_type_manager; $this->database = $database; $this->workspaceManager = $workspace_manager; + $this->workspaceAssociation = $workspace_association; } /** @@ -60,7 +72,7 @@ public function __construct(EntityTypeManagerInterface $entity_type_manager, Con * A workspace publisher object. */ public function getPublisher(WorkspaceInterface $source) { - return new WorkspacePublisher($this->entityTypeManager, $this->database, $this->workspaceManager, $source); + return new WorkspacePublisher($this->entityTypeManager, $this->database, $this->workspaceManager, $this->workspaceAssociation, $source); } } diff --git a/core/modules/workspaces/src/WorkspacePublisher.php b/core/modules/workspaces/src/WorkspacePublisher.php index c7da6b8be782c14ba48409cbad592f58a61eb244..d38e97383c39be78a01a655ca638260fe81cf987 100644 --- a/core/modules/workspaces/src/WorkspacePublisher.php +++ b/core/modules/workspaces/src/WorkspacePublisher.php @@ -37,18 +37,18 @@ class WorkspacePublisher implements WorkspacePublisherInterface { protected $database; /** - * The workspace association storage. + * The workspace manager. * - * @var \Drupal\workspaces\WorkspaceAssociationStorageInterface + * @var \Drupal\workspaces\WorkspaceManagerInterface */ - protected $workspaceAssociationStorage; + protected $workspaceManager; /** - * The workspace manager. + * The workspace association service. * - * @var \Drupal\workspaces\WorkspaceManagerInterface + * @var \Drupal\workspaces\WorkspaceAssociationInterface */ - protected $workspaceManager; + protected $workspaceAssociation; /** * Constructs a new WorkspacePublisher. @@ -59,12 +59,14 @@ class WorkspacePublisher implements WorkspacePublisherInterface { * Database connection. * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager * The workspace manager. + * @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association + * The workspace association service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager, WorkspaceInterface $source) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database, WorkspaceManagerInterface $workspace_manager, WorkspaceAssociationInterface $workspace_association, WorkspaceInterface $source) { $this->entityTypeManager = $entity_type_manager; $this->database = $database; - $this->workspaceAssociationStorage = $entity_type_manager->getStorage('workspace_association'); $this->workspaceManager = $workspace_manager; + $this->workspaceAssociation = $workspace_association; $this->sourceWorkspace = $source; } @@ -95,6 +97,11 @@ public function publish() { // revisions. $entity->setSyncing(TRUE); $entity->isDefaultRevision(TRUE); + + // The default revision is not workspace-specific anymore. + $field_name = $entity->getEntityType()->getRevisionMetadataKey('workspace'); + $entity->{$field_name}->target_id = NULL; + $entity->original = $default_revisions[$entity->id()]; $entity->save(); } @@ -107,9 +114,8 @@ public function publish() { throw $e; } - // Notify the workspace association storage that a workspace has been - // pushed. - $this->workspaceAssociationStorage->postPush($this->sourceWorkspace); + // Notify the workspace association that a workspace has been published. + $this->workspaceAssociation->postPublish($this->sourceWorkspace); } /** @@ -141,7 +147,7 @@ public function checkConflictsOnTarget() { public function getDifferringRevisionIdsOnTarget() { $target_revision_difference = []; - $tracked_entities = $this->workspaceAssociationStorage->getTrackedEntities($this->sourceWorkspace->id()); + $tracked_entities = $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id()); foreach ($tracked_entities as $entity_type_id => $tracked_revisions) { $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); @@ -171,7 +177,7 @@ public function getDifferringRevisionIdsOnTarget() { */ public function getDifferringRevisionIdsOnSource() { // Get the Workspace association revisions which haven't been pushed yet. - return $this->workspaceAssociationStorage->getTrackedEntities($this->sourceWorkspace->id()); + return $this->workspaceAssociation->getTrackedEntities($this->sourceWorkspace->id()); } /** diff --git a/core/modules/workspaces/tests/fixtures/update/drupal-8.6.0-workspaces_installed.php b/core/modules/workspaces/tests/fixtures/update/drupal-8.6.0-workspaces_installed.php new file mode 100644 index 0000000000000000000000000000000000000000..7e6276abfead76a94a69250aa7db85f9d06582ef Binary files /dev/null and b/core/modules/workspaces/tests/fixtures/update/drupal-8.6.0-workspaces_installed.php differ diff --git a/core/modules/workspaces/tests/src/Functional/Update/WorkspacesUpdateTest.php b/core/modules/workspaces/tests/src/Functional/Update/WorkspacesUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..0fffd21e3784cbccac98fc13d5632cc130024aa9 --- /dev/null +++ b/core/modules/workspaces/tests/src/Functional/Update/WorkspacesUpdateTest.php @@ -0,0 +1,105 @@ +<?php + +namespace Drupal\Tests\workspaces\Functional\Update; + +use Drupal\FunctionalTests\Update\UpdatePathTestBase; + +/** + * Tests the upgrade path for the Workspaces module. + * + * @group workspaces + * @group Update + * @group legacy + */ +class WorkspacesUpdateTest extends UpdatePathTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['workspaces']; + + /** + * {@inheritdoc} + */ + public function setDatabaseDumpFiles() { + $this->databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.filled.standard.php.gz', + __DIR__ . '/../../../fixtures/update/drupal-8.6.0-workspaces_installed.php', + ]; + } + + /** + * Tests the move of workspace association data to a custom table. + * + * @see workspaces_update_8801() + * @see workspaces_post_update_move_association_data() + */ + public function testWorkspaceAssociationRemoval() { + $database = \Drupal::database(); + + // Check that we have two records in the 'workspace_association' base table + // and three records in its revision table. + $wa_records = $database->select('workspace_association')->countQuery()->execute()->fetchField(); + $this->assertEquals(2, $wa_records); + $war_records = $database->select('workspace_association_revision')->countQuery()->execute()->fetchField(); + $this->assertEquals(3, $war_records); + + // Check that the node entity type does not have a 'workspace' field. + $this->assertNull(\Drupal::entityDefinitionUpdateManager()->getFieldStorageDefinition('workspace', 'node')); + + $this->runUpdates(); + + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + + // Check that the 'workspace' field has been installed for an entity type + // that was workspace-supported before Drupal 8.7.0. + $this->assertTrue($entity_definition_update_manager->getFieldStorageDefinition('workspace', 'node')); + + // Check that the 'workspace' field has been installed for an entity type + // which became workspace-supported as part of an entity schema update. + $this->assertTrue($entity_definition_update_manager->getFieldStorageDefinition('workspace', 'taxonomy_term')); + + // Check that the 'workspace' revision metadata field has been created only + // in the revision table. + $schema = $database->schema(); + $this->assertTrue($schema->fieldExists('node_revision', 'workspace')); + $this->assertFalse($schema->fieldExists('node', 'workspace')); + $this->assertFalse($schema->fieldExists('node_field_data', 'workspace')); + $this->assertFalse($schema->fieldExists('node_field_revision', 'workspace')); + + // Check that the 'workspace_association' records have been migrated + // properly. + $wa_records = $database->select('workspace_association')->fields('workspace_association')->execute()->fetchAll(\PDO::FETCH_ASSOC); + $expected = [ + [ + 'workspace' => 'stage', + 'target_entity_type_id' => 'node', + 'target_entity_id' => '1', + 'target_entity_revision_id' => '2', + ], + [ + 'workspace' => 'dev', + 'target_entity_type_id' => 'node', + 'target_entity_id' => '8', + 'target_entity_revision_id' => '10', + ], + ]; + $this->assertEquals($expected, $wa_records); + + // Check that the 'workspace_association' revisions has been migrated + // properly to the new 'workspace' revision metadata field. + $revisions = \Drupal::entityTypeManager()->getStorage('node')->loadMultipleRevisions([2, 9, 10]); + $this->assertEquals('stage', $revisions[2]->workspace->target_id); + $this->assertEquals('dev', $revisions[9]->workspace->target_id); + $this->assertEquals('dev', $revisions[10]->workspace->target_id); + + // Check that the 'workspace_association' entity type has been uninstalled. + $this->assertNull($entity_definition_update_manager->getEntityType('workspace_association')); + $this->assertNull($entity_definition_update_manager->getFieldStorageDefinition('id', 'workspace_association')); + $this->assertNull(\Drupal::keyValue('entity.storage_schema.sql')->get('workspace_association.entity_schema_data')); + + // Check that the 'workspace_association_revision' table has been removed. + $this->assertFalse($schema->tableExists('workspace_association_revision')); + } + +} diff --git a/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php b/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php index e652e3fc826b45a305b34a4e0b9247d5793d75b8..e4932eca3282882270c3f43f311130d27a082dec 100644 --- a/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php +++ b/core/modules/workspaces/tests/src/Functional/WorkspacesUninstallTest.php @@ -36,6 +36,12 @@ public function testUninstallingWorkspace() { $this->drupalPostForm(NULL, [], 'Uninstall'); $session->pageTextContains('The selected modules have been uninstalled.'); $session->pageTextNotContains('Workspaces'); + + $this->assertFalse($this->getDatabaseConnection()->schema()->fieldExists('node_revision', 'workspace')); + + // Verify that the revision metadata key has been removed. + $revision_metadata_keys = \Drupal::entityDefinitionUpdateManager()->getEntityType('node')->get('revision_metadata_keys'); + $this->assertArrayNotHasKey('workspace', $revision_metadata_keys); } } diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceAccessTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceAccessTest.php index b8065a15c8dd84def910ac37be9b364737c7c7e2..f8a4964eda303780adc9441df240ca04390ba86a 100644 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceAccessTest.php +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceAccessTest.php @@ -33,7 +33,6 @@ protected function setUp() { $this->installSchema('system', ['sequences']); $this->installEntitySchema('workspace'); - $this->installEntitySchema('workspace_association'); $this->installEntitySchema('user'); // User 1. diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceCRUDTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceCRUDTest.php index d7d5710c57010e7ad5c58fe21a380a6fb739970a..c8b1705a7e078911b3e27aaac09cf9e5199e7f36 100644 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceCRUDTest.php +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceCRUDTest.php @@ -7,7 +7,6 @@ use Drupal\Tests\node\Traits\NodeCreationTrait; use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\workspaces\Entity\Workspace; -use Drupal\workspaces\Entity\WorkspaceAssociation; /** * Tests CRUD operations for workspaces. @@ -19,6 +18,7 @@ class WorkspaceCRUDTest extends KernelTestBase { use UserCreationTrait; use NodeCreationTrait; use ContentTypeCreationTrait; + use WorkspaceTestTrait; /** * The entity type manager. @@ -66,7 +66,7 @@ protected function setUp() { $this->installSchema('node', ['node_access']); $this->installEntitySchema('workspace'); - $this->installEntitySchema('workspace_association'); + $this->installSchema('workspaces', ['workspace_association']); $this->installEntitySchema('node'); $this->installConfig(['filter', 'node', 'system']); @@ -91,10 +91,8 @@ public function testDeletingWorkspaces() { ]); $this->setCurrentUser($admin); - /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); - /** @var \Drupal\node\NodeStorageInterface $node_storage */ - $node_storage = $this->entityTypeManager->getStorage('node'); + /** @var \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association */ + $workspace_association = \Drupal::service('workspaces.association'); // Create a workspace with a very small number of associated node revisions. $workspace_1 = Workspace::create([ @@ -106,6 +104,12 @@ public function testDeletingWorkspaces() { $workspace_1_node_1 = $this->createNode(['status' => FALSE]); $workspace_1_node_2 = $this->createNode(['status' => FALSE]); + + // The 'live' workspace should have 2 revisions now. The initial revision + // for each node. + $live_revisions = $this->getUnassociatedRevisions('node'); + $this->assertCount(2, $live_revisions); + for ($i = 0; $i < 4; $i++) { $workspace_1_node_1->setNewRevision(TRUE); $workspace_1_node_1->save(); @@ -114,9 +118,17 @@ public function testDeletingWorkspaces() { $workspace_1_node_2->save(); } - // The workspace should have 10 associated node revisions, 5 for each node. - $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE); - $this->assertCount(10, $associated_revisions['node']); + // The workspace should now track 2 nodes. + $tracked_entities = $workspace_association->getTrackedEntities($workspace_1->id()); + $this->assertCount(2, $tracked_entities['node']); + + // There should still be 2 revisions associated with 'live'. + $live_revisions = $this->getUnassociatedRevisions('node'); + $this->assertCount(2, $live_revisions); + + // The other 8 revisions should be associated with 'workspace_1'. + $associated_revisions = $workspace_association->getAssociatedRevisions($workspace_1->id(), 'node'); + $this->assertCount(8, $associated_revisions); // Check that we are allowed to delete the workspace. $this->assertTrue($workspace_1->access('delete', $admin)); @@ -125,14 +137,17 @@ public function testDeletingWorkspaces() { // entities and all the node revisions have been deleted as well. $workspace_1->delete(); - $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE); + // There are no more tracked entities in 'workspace_1'. + $tracked_entities = $workspace_association->getTrackedEntities($workspace_1->id()); + $this->assertEmpty($tracked_entities); + + // There are no more revisions associated with 'workspace_1'. + $associated_revisions = $workspace_association->getAssociatedRevisions($workspace_1->id(), 'node'); $this->assertCount(0, $associated_revisions); - $node_revision_count = $node_storage - ->getQuery() - ->allRevisions() - ->count() - ->execute(); - $this->assertEquals(0, $node_revision_count); + + // There should still be 2 revisions associated with 'live'. + $live_revisions = $this->getUnassociatedRevisions('node'); + $this->assertCount(2, $live_revisions); // Create another workspace, this time with a larger number of associated // node revisions so we can test the batch purge process. @@ -149,16 +164,27 @@ public function testDeletingWorkspaces() { $workspace_2_node_1->save(); } - // The workspace should have 60 associated node revisions. - $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE); - $this->assertCount(60, $associated_revisions['node']); + // Now there is one entity tracked in 'workspace_2'. + $tracked_entities = $workspace_association->getTrackedEntities($workspace_2->id()); + $this->assertCount(1, $tracked_entities['node']); + + // One revision of this entity is in 'live'. + $live_revisions = $this->getUnassociatedRevisions('node', [$workspace_2_node_1->id()]); + $this->assertCount(1, $live_revisions); - // Delete the workspace and check that we still have 10 revision left to + // The other 59 are associated with 'workspace_2'. + $associated_revisions = $workspace_association->getAssociatedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]); + $this->assertCount(59, $associated_revisions); + + // Delete the workspace and check that we still have 9 revision left to // delete. $workspace_2->delete(); + $associated_revisions = $workspace_association->getAssociatedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]); + $this->assertCount(9, $associated_revisions); - $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE); - $this->assertCount(10, $associated_revisions['node']); + // The live revision is also still there. + $live_revisions = $this->getUnassociatedRevisions('node', [$workspace_2_node_1->id()]); + $this->assertCount(1, $live_revisions); $workspace_deleted = \Drupal::state()->get('workspace.deleted'); $this->assertCount(1, $workspace_deleted); @@ -177,41 +203,94 @@ public function testDeletingWorkspaces() { // from the "workspace.delete" state entry. \Drupal::service('cron')->run(); - $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE); + $associated_revisions = $workspace_association->getTrackedEntities($workspace_2->id()); + $this->assertCount(0, $associated_revisions); + + // 'workspace_2 'is empty now. + $associated_revisions = $workspace_association->getAssociatedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]); $this->assertCount(0, $associated_revisions); - $node_revision_count = $node_storage - ->getQuery() - ->allRevisions() - ->count() - ->execute(); - $this->assertEquals(0, $node_revision_count); + $tracked_entities = $workspace_association->getTrackedEntities($workspace_2->id()); + $this->assertEmpty($tracked_entities); + + // The 3 revisions in 'live' remain. + $live_revisions = $this->getUnassociatedRevisions('node'); + $this->assertCount(3, $live_revisions); $workspace_deleted = \Drupal::state()->get('workspace.deleted'); $this->assertCount(0, $workspace_deleted); } /** - * Tests workspace association validation. - * - * @covers \Drupal\workspaces\Entity\WorkspaceAssociation::validate + * Tests that deleting a workspace keeps its already published content. */ - public function testWorkspaceAssociationValidation() { - $workspace = Workspace::create([ - 'id' => 'gibbon', - 'label' => 'Gibbon', + public function testDeletingPublishedWorkspace() { + $admin = $this->createUser([ + 'administer nodes', + 'create workspace', + 'view any workspace', + 'delete any workspace', ]); - $workspace->save(); - $node = $this->createNode(); + $this->setCurrentUser($admin); - $workspace_association = WorkspaceAssociation::create([ - 'workspace' => $workspace, - 'target_entity_type_id' => $node->getEntityTypeId(), - 'target_entity_id' => $node->id(), - 'target_entity_revision_id' => $node->getRevisionId(), + $live_workspace = Workspace::create([ + 'id' => 'live', + 'label' => 'Live', ]); - - $violations = $workspace_association->validate(); - $this->assertCount(0, $violations); + $live_workspace->save(); + $workspace = Workspace::create([ + 'id' => 'stage', + 'label' => 'Stage', + ]); + $workspace->save(); + $this->workspaceManager->setActiveWorkspace($workspace); + + // Create a new node in the 'stage' workspace + $node = $this->createNode(['status' => TRUE]); + + // Create an additional workspace-specific revision for the node. + $node->setNewRevision(TRUE); + $node->save(); + + // The node should have 3 revisions now: a default and 2 pending ones. + $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3]); + $this->assertCount(3, $revisions); + $this->assertTrue($revisions[1]->isDefaultRevision()); + $this->assertFalse($revisions[2]->isDefaultRevision()); + $this->assertFalse($revisions[3]->isDefaultRevision()); + + // Publish the workspace, which should mark revision 3 as the default one + // and keep revision 2 as a 'source' draft revision. + $workspace->publish(); + $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3]); + $this->assertFalse($revisions[1]->isDefaultRevision()); + $this->assertFalse($revisions[2]->isDefaultRevision()); + $this->assertTrue($revisions[3]->isDefaultRevision()); + + // Create two new workspace-revisions for the node. + $node->setNewRevision(TRUE); + $node->save(); + $node->setNewRevision(TRUE); + $node->save(); + + // The node should now have 5 revisions. + $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3, 4, 5]); + $this->assertFalse($revisions[1]->isDefaultRevision()); + $this->assertFalse($revisions[2]->isDefaultRevision()); + $this->assertTrue($revisions[3]->isDefaultRevision()); + $this->assertFalse($revisions[4]->isDefaultRevision()); + $this->assertFalse($revisions[5]->isDefaultRevision()); + + // Delete the workspace and check that only the two new pending revisions + // were deleted by the workspace purging process. + $workspace->delete(); + + $revisions = $this->entityTypeManager->getStorage('node')->loadMultipleRevisions([1, 2, 3, 4, 5]); + $this->assertCount(3, $revisions); + $this->assertFalse($revisions[1]->isDefaultRevision()); + $this->assertFalse($revisions[2]->isDefaultRevision()); + $this->assertTrue($revisions[3]->isDefaultRevision()); + $this->assertFalse(isset($revisions[4])); + $this->assertFalse(isset($revisions[5])); } } diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php index 20a7efd04e30adc75d0cec4cf401851461c09d22..1530f3865830743097c2c761d203c4c3d3f68c8b 100644 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceIntegrationTest.php @@ -275,7 +275,7 @@ public function testWorkspaces() { ], ]); $test_scenarios['add_published_node_in_stage'] = $revision_state; - $expected_workspace_association['add_published_node_in_stage'] = ['stage' => [3, 4, 5, 6, 7]]; + $expected_workspace_association['add_published_node_in_stage'] = ['stage' => [3, 4, 5, 7]]; // Deploying 'stage' to 'live' should simply make the latest revisions in // 'stage' the default ones in 'live'. @@ -365,8 +365,9 @@ public function testEntityQueryWithoutConditions() { $this->switchToWorkspace('stage'); // Add a workspace-specific revision to a pre-existing node. - $this->nodes[1]->title->value = 'stage - 2 - r3 - published'; - $this->nodes[1]->save(); + $node = $this->entityTypeManager->getStorage('node')->load(2); + $node->title->value = 'stage - 2 - r3 - published'; + $node->save(); $query = $this->entityTypeManager->getStorage('node')->getQuery(); $query->sort('nid'); @@ -809,7 +810,7 @@ protected function assertEntityQuery(array $expected_values, $entity_type_id) { } /** - * Checks the workspace_association entries for a test scenario. + * Checks the workspace_association records for a test scenario. * * @param array $expected * An array of expected values, as defined in ::testWorkspaces(). @@ -817,10 +818,10 @@ protected function assertEntityQuery(array $expected_values, $entity_type_id) { * The ID of the entity type that is being tested. */ protected function assertWorkspaceAssociation(array $expected, $entity_type_id) { - /** @var \Drupal\workspaces\WorkspaceAssociationStorageInterface $workspace_association_storage */ - $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association'); + /** @var \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association */ + $workspace_association = \Drupal::service('workspaces.association'); foreach ($expected as $workspace_id => $expected_tracked_revision_ids) { - $tracked_entities = $workspace_association_storage->getTrackedEntities($workspace_id, TRUE); + $tracked_entities = $workspace_association->getTrackedEntities($workspace_id, $entity_type_id); $tracked_revision_ids = isset($tracked_entities[$entity_type_id]) ? $tracked_entities[$entity_type_id] : []; $this->assertEquals($expected_tracked_revision_ids, array_keys($tracked_revision_ids)); } diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceInternalResourceTest.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceInternalResourceTest.php deleted file mode 100644 index 06201ce7446d31c4275a077b912fa3f7d7ca2e8e..0000000000000000000000000000000000000000 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceInternalResourceTest.php +++ /dev/null @@ -1,43 +0,0 @@ -<?php - -namespace Drupal\Tests\workspaces\Kernel; - -use Drupal\Component\Plugin\Exception\PluginNotFoundException; -use Drupal\KernelTests\KernelTestBase; -use Drupal\rest\Entity\RestResourceConfig; -use Drupal\rest\RestResourceConfigInterface; - -/** - * Tests REST module with internal workspace entity types. - * - * @group workspaces - */ -class WorkspaceInternalResourceTest extends KernelTestBase { - - /** - * {@inheritdoc} - */ - public static $modules = ['user', 'serialization', 'rest', 'workspaces']; - - /** - * Tests enabling workspace associations for REST throws an exception. - * - * @see \Drupal\workspaces\Entity\WorkspaceAssociation - */ - public function testCreateWorkspaceAssociationResource() { - $this->expectException(PluginNotFoundException::class); - $this->expectExceptionMessage('The "entity:workspace_association" plugin does not exist.'); - RestResourceConfig::create([ - 'id' => 'entity.workspace_association', - 'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY, - 'configuration' => [ - 'methods' => ['GET'], - 'formats' => ['json'], - 'authentication' => ['cookie'], - ], - ]) - ->enable() - ->save(); - } - -} diff --git a/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php b/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php index 13cff6a3dfebf3dac95c51b25ef7e5b188ed3cd6..50cf4704c396d5961c450169a0eb0fcdaefaf4a7 100644 --- a/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php +++ b/core/modules/workspaces/tests/src/Kernel/WorkspaceTestTrait.php @@ -35,7 +35,7 @@ protected function initializeWorkspacesModule() { $this->workspaceManager = \Drupal::service('workspaces.manager'); $this->installEntitySchema('workspace'); - $this->installEntitySchema('workspace_association'); + $this->installSchema('workspaces', ['workspace_association']); // Create two workspaces by default, 'live' and 'stage'. $this->workspaces['live'] = Workspace::create(['id' => 'live']); @@ -64,4 +64,33 @@ protected function switchToWorkspace($workspace_id) { \Drupal::service('workspaces.manager')->setActiveWorkspace($workspace); } + /** + * Returns all the revisions which are not associated with any workspace. + * + * @param string $entity_type_id + * An entity type ID to find revisions for. + * @param int[]|string[]|null $entity_ids + * (optional) An array of entity IDs to filter the results by. Defaults to + * NULL. + * + * @return array + * An array of entity IDs, keyed by revision IDs. + */ + protected function getUnassociatedRevisions($entity_type_id, $entity_ids = NULL) { + $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id); + + $query = \Drupal::entityTypeManager() + ->getStorage($entity_type_id) + ->getQuery() + ->allRevisions() + ->accessCheck(FALSE) + ->notExists($entity_type->get('revision_metadata_keys')['workspace']); + + if ($entity_ids) { + $query->condition($entity_type->getKey('id'), $entity_ids, 'IN'); + } + + return $query->execute(); + } + } diff --git a/core/modules/workspaces/workspaces.install b/core/modules/workspaces/workspaces.install index c6ac304b6db36a477bb20594639e99b0cbcdb162..2bfc003f0e102871d0d8af9ff9571b4cc9457988 100644 --- a/core/modules/workspaces/workspaces.install +++ b/core/modules/workspaces/workspaces.install @@ -5,6 +5,8 @@ * Contains install, update and uninstall functions for the Workspaces module. */ +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; use Drupal\workspaces\Entity\Workspace; /** @@ -32,6 +34,27 @@ function workspaces_requirements($phase) { return $requirements; } +/** + * Implements hook_module_preinstall(). + */ +function workspaces_module_preinstall($module) { + if ($module !== 'workspaces') { + return; + } + + /** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspaces.manager'); + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + foreach ($entity_definition_update_manager->getEntityTypes() as $entity_type) { + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + if ($workspace_manager->isEntityTypeSupported($entity_type)) { + $revision_metadata_keys['workspace'] = 'workspace'; + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + $entity_definition_update_manager->updateEntityType($entity_type); + } + } +} + /** * Implements hook_install(). */ @@ -61,3 +84,83 @@ function workspaces_install() { 'uid' => $owner_id, ])->save(); } + +/** + * Implements hook_schema(). + */ +function workspaces_schema() { + $schema['workspace_association'] = [ + 'description' => 'Stores the association between entity revisions and their workspace.', + 'fields' => [ + 'workspace' => [ + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The workspace ID.', + ], + 'target_entity_type_id' => [ + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The ID of the associated entity type.', + ], + 'target_entity_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The ID of the associated entity.', + ], + 'target_entity_revision_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The revision ID of the associated entity.', + ], + ], + 'indexes' => [ + 'target_entity_revision_id' => ['target_entity_revision_id'], + ], + 'primary key' => ['workspace', 'target_entity_type_id', 'target_entity_id'], + ]; + + return $schema; +} + +/** + * Add the 'workspace' revision metadata field on all supported entity types. + */ +function workspaces_update_8801() { + /** @var \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager */ + $workspace_manager = \Drupal::service('workspaces.manager'); + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + foreach ($entity_definition_update_manager->getEntityTypes() as $entity_type_id => $entity_type) { + if ($workspace_manager->isEntityTypeSupported($entity_type)) { + $revision_metadata_keys = $entity_type->get('revision_metadata_keys'); + + if (!isset($revision_metadata_keys['workspace'])) { + // Bail out if there's an existing field called 'workspace'. + if ($entity_definition_update_manager->getFieldStorageDefinition('workspace', $entity_type_id)) { + throw new \RuntimeException("An existing 'workspace' field was found for the '$entity_type_id' entity type. Set the 'workspace' revision metadata key to use a different field name and run this update function again."); + } + + $revision_metadata_keys['workspace'] = 'workspace'; + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + $entity_definition_update_manager->updateEntityType($entity_type); + } + + $field_storage = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Workspace')) + ->setDescription(t('Indicates the workspace that this revision belongs to.')) + ->setSetting('target_type', 'workspace') + ->setInternal(TRUE) + ->setTranslatable(FALSE) + ->setRevisionable(TRUE); + + $entity_definition_update_manager->installFieldStorageDefinition($revision_metadata_keys['workspace'], $entity_type_id, 'workspaces', $field_storage); + } + } + + return t("The 'workspace' revision metadata field has been installed."); +} diff --git a/core/modules/workspaces/workspaces.module b/core/modules/workspaces/workspaces.module index d47985b223ded51e652cebe5be2d964adc8bbe70..c3b0d0776274589f7b94c4621be88363b90eee45 100644 --- a/core/modules/workspaces/workspaces.module +++ b/core/modules/workspaces/workspaces.module @@ -8,6 +8,7 @@ use Drupal\Component\Serialization\Json; use Drupal\Core\Entity\EntityFormInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; @@ -66,6 +67,15 @@ function workspaces_field_info_alter(&$definitions) { ->fieldInfoAlter($definitions); } +/** + * Implements hook_entity_base_field_info(). + */ +function workspaces_entity_base_field_info(EntityTypeInterface $entity_type) { + return \Drupal::service('class_resolver') + ->getInstanceFromDefinition(EntityTypeInfo::class) + ->entityBaseFieldInfo($entity_type); +} + /** * Implements hook_entity_preload(). */ diff --git a/core/modules/workspaces/workspaces.post_update.php b/core/modules/workspaces/workspaces.post_update.php index 0df5add2cb8dde0a926effbd8d899c6e322c3d27..ac45f3f93285cf1b9b61a7376beab24725123626 100644 --- a/core/modules/workspaces/workspaces.post_update.php +++ b/core/modules/workspaces/workspaces.post_update.php @@ -5,6 +5,11 @@ * Post update functions for the Workspaces module. */ +use Drupal\Core\Entity\ContentEntityNullStorage; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\Sql\SqlContentEntityStorage; +use Drupal\Core\Site\Settings; + /** * Clear caches due to access changes. */ @@ -19,3 +24,122 @@ function workspaces_post_update_remove_default_workspace() { $workspace->delete(); } } + +/** + * Move the workspace association data to an entity field and a custom table. + */ +function workspaces_post_update_move_association_data(&$sandbox) { + $database = \Drupal::database(); + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + $entity_type_manager = \Drupal::entityTypeManager(); + $entity_type = $entity_definition_update_manager->getEntityType('workspace_association'); + + // We can't migrate the workspace association data if the entity type is not + // using its default storage. + if ($entity_type->getHandlerClasses()['storage'] !== 'Drupal\workspaces\WorkspaceAssociationStorage') { + return; + } + + // Since the custom storage class doesn't exist anymore, we have to use core's + // default storage. + $entity_type->setStorageClass(SqlContentEntityStorage::class); + + // If 'progress' is not set, this will be the first run of the batch. + if (!isset($sandbox['progress'])) { + $sandbox['progress'] = 0; + $sandbox['current_id'] = -1; + + // Create a temporary table for the new workspace_association index. + $schema = [ + 'description' => 'Stores the association between entity revisions and their workspace.', + 'fields' => [ + 'workspace' => [ + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The workspace ID.', + ], + 'target_entity_type_id' => [ + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => TRUE, + 'default' => '', + 'description' => 'The ID of the associated entity type.', + ], + 'target_entity_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The ID of the associated entity.', + ], + 'target_entity_revision_id' => [ + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + 'description' => 'The revision ID of the associated entity.', + ], + ], + 'indexes' => [ + 'target_entity_revision_id' => ['target_entity_revision_id'], + ], + 'primary key' => ['workspace', 'target_entity_type_id', 'target_entity_id'], + ]; + if ($database->schema()->tableExists('tmp_workspace_association')) { + $database->schema()->dropTable('tmp_workspace_association'); + } + $database->schema()->createTable('tmp_workspace_association', $schema); + + // Copy all the data from the base table of the 'workspace_association' + // entity type to the temporary association table. + $select = $database->select($entity_type->getBaseTable()) + ->fields($entity_type->getBaseTable(), ['workspace', 'target_entity_type_id', 'target_entity_id', 'target_entity_revision_id']); + $database->insert('tmp_workspace_association')->from($select)->execute(); + } + + $table_name = $entity_type->getRevisionTable(); + $revision_field_name = 'revision_id'; + + // Get the next entity association revision records to migrate. + $step_size = Settings::get('entity_update_batch_size', 50); + $workspace_association_records = $database->select($table_name, 't') + ->condition("t.$revision_field_name", $sandbox['current_id'], '>') + ->fields('t') + ->orderBy($revision_field_name, 'ASC') + ->range(0, $step_size) + ->execute() + ->fetchAll(); + + foreach ($workspace_association_records as $record) { + // Set the workspace reference on the tracked entity revision. + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + $revision = $entity_type_manager->getStorage($record->target_entity_type_id)->loadRevision($record->target_entity_revision_id); + $revision->set('workspace', $record->workspace); + $revision->setSyncing(TRUE); + $revision->save(); + + $sandbox['progress']++; + $sandbox['current_id'] = $record->{$revision_field_name}; + } + + // Get an updated count of workspace_association revisions that still need to + // be migrated to the new storage. + $missing = $database->select($table_name, 't') + ->condition("t.$revision_field_name", $sandbox['current_id'], '>') + ->orderBy($revision_field_name, 'ASC') + ->countQuery() + ->execute() + ->fetchField(); + $sandbox['#finished'] = $missing ? $sandbox['progress'] / ($sandbox['progress'] + (int) $missing) : 1; + + // Uninstall the 'workspace_association' entity type and rename the temporary + // table. + if ($sandbox['#finished'] == 1) { + $entity_type->setStorageClass(ContentEntityNullStorage::class); + $entity_definition_update_manager->uninstallEntityType($entity_type); + $database->schema()->dropTable('workspace_association'); + $database->schema()->dropTable('workspace_association_revision'); + + $database->schema()->renameTable('tmp_workspace_association', 'workspace_association'); + } +} diff --git a/core/modules/workspaces/workspaces.services.yml b/core/modules/workspaces/workspaces.services.yml index 9823d5ce2ec9125ac961abb408f1f66f8a3ca9fc..45f1d50746141b382da9faf3884bdcdf826716a1 100644 --- a/core/modules/workspaces/workspaces.services.yml +++ b/core/modules/workspaces/workspaces.services.yml @@ -1,12 +1,17 @@ services: workspaces.manager: class: Drupal\workspaces\WorkspaceManager - arguments: ['@request_stack', '@entity_type.manager', '@entity.memory_cache', '@current_user', '@state', '@logger.channel.workspaces', '@class_resolver'] + arguments: ['@request_stack', '@entity_type.manager', '@entity.memory_cache', '@current_user', '@state', '@logger.channel.workspaces', '@class_resolver', '@workspaces.association'] tags: - { name: service_id_collector, tag: workspace_negotiator } workspaces.operation_factory: class: Drupal\workspaces\WorkspaceOperationFactory - arguments: ['@entity_type.manager', '@database', '@workspaces.manager'] + arguments: ['@entity_type.manager', '@database', '@workspaces.manager', '@workspaces.association'] + workspaces.association: + class: Drupal\workspaces\WorkspaceAssociation + arguments: ['@database', '@entity_type.manager'] + tags: + - { name: backend_overridable } workspaces.negotiator.session: class: Drupal\workspaces\Negotiator\SessionWorkspaceNegotiator @@ -25,6 +30,12 @@ services: tags: - { name: access_check, applies_to: _has_active_workspace } + workspaces.entity_schema_listener: + class: Drupal\workspaces\EventSubscriber\EntitySchemaSubscriber + arguments: ['@entity.definition_update_manager', '@entity.last_installed_schema.repository', '@workspaces.manager'] + tags: + - { name: 'event_subscriber' } + cache_context.workspace: class: Drupal\workspaces\WorkspaceCacheContext arguments: ['@workspaces.manager']