Issue #3129762 by amateescu, adriancid, msuthars, Alexj12: Creating an unpublished entity in a workspace does not set the workspace field on the revision

merge-requests/3906/head
catch 2023-05-02 09:05:43 +01:00
parent 40d5e404a4
commit cd12944d9a
9 changed files with 305 additions and 55 deletions

View File

@ -136,8 +136,10 @@ class EntityOperations implements ContainerInjectionInterface {
// 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.
// Track the workspaces in which the new revision was saved.
if (!$entity->isSyncing()) {
$field_name = $entity_type->getRevisionMetadataKey('workspace');
$entity->{$field_name}->target_id = $this->workspaceManager->getActiveWorkspace()->id();
}

View File

@ -3,7 +3,7 @@
namespace Drupal\workspaces\Plugin\Validation\Constraint;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\workspaces\WorkspaceAssociationInterface;
use Drupal\Core\State\StateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
@ -14,20 +14,20 @@ use Symfony\Component\Validator\ConstraintValidator;
class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
/**
* The workspace association service.
* The state service.
*
* @var \Drupal\workspaces\WorkspaceAssociationInterface
* @var \Drupal\Core\State\StateInterface
*/
protected $workspaceAssociation;
protected $state;
/**
* Creates a new DeletedWorkspaceConstraintValidator instance.
*
* @param \Drupal\workspaces\WorkspaceAssociationInterface $workspace_association
* The workspace association service.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
*/
public function __construct(WorkspaceAssociationInterface $workspace_association) {
$this->workspaceAssociation = $workspace_association;
public function __construct(StateInterface $state) {
$this->state = $state;
}
/**
@ -35,7 +35,7 @@ class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('workspaces.association')
$container->get('state')
);
}
@ -49,7 +49,8 @@ class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements
return;
}
if ($this->workspaceAssociation->getTrackedEntities($value->getEntity()->id())) {
$deleted_workspace_ids = $this->state->get('workspace.deleted', []);
if (isset($deleted_workspace_ids[$value->getEntity()->id()])) {
$this->context->addViolation($constraint->message);
}
}

View File

@ -186,13 +186,21 @@ class WorkspaceAssociation implements WorkspaceAssociationInterface, EventSubscr
$id_field = $table_mapping->getColumnNames($entity_type->getKey('id'))['value'];
$revision_id_field = $table_mapping->getColumnNames($entity_type->getKey('revision'))['value'];
$workspace_tree = $this->workspaceRepository->loadTree();
if (isset($workspace_tree[$workspace_id])) {
$workspace_candidates = array_merge([$workspace_id], $workspace_tree[$workspace_id]['ancestors']);
}
else {
$workspace_candidates = [$workspace_id];
}
$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]")
->condition("revision.$workspace_field", $workspace_candidates, 'IN')
->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.
@ -203,6 +211,42 @@ class WorkspaceAssociation implements WorkspaceAssociationInterface, EventSubscr
return $query->execute()->fetchAllKeyed();
}
/**
* {@inheritdoc}
*/
public function getAssociatedInitialRevisions(string $workspace_id, string $entity_type_id, array $entity_ids = []) {
/** @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->getBaseTable(), 'base');
$query->leftJoin($entity_type->getRevisionTable(), 'revision', "[base].[$revision_id_field] = [revision].[$revision_id_field]");
$query
->fields('base', [$revision_id_field, $id_field])
->condition("revision.$workspace_field", $workspace_id, '=')
->orderBy("base.$revision_id_field", 'ASC');
// Restrict the result to a set of entity ID's if provided.
if ($entity_ids) {
$query->condition("base.$id_field", $entity_ids, 'IN');
}
return $query->execute()->fetchAllKeyed();
}
/**
* {@inheritdoc}
*/

View File

@ -74,6 +74,23 @@ interface WorkspaceAssociationInterface {
*/
public function getAssociatedRevisions($workspace_id, $entity_type_id, $entity_ids = NULL);
/**
* Retrieves all content revisions that were created in a given workspace.
*
* @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[] $entity_ids
* (optional) An array of entity IDs to filter the results by. Defaults to
* an empty array.
*
* @return array
* Returns an array where the values are an array of entity IDs keyed by
* revision IDs.
*/
public function getAssociatedInitialRevisions(string $workspace_id, string $entity_type_id, array $entity_ids = []);
/**
* Gets a list of workspace IDs in which an entity is tracked.
*

View File

@ -316,31 +316,60 @@ class WorkspaceManager implements WorkspaceManagerInterface {
// Get the first deleted workspace from the list and delete the revisions
// associated with it, along with the workspace association records.
$workspace_id = reset($deleted_workspace_ids);
$tracked_entities = $this->workspaceAssociation->getTrackedEntities($workspace_id);
$all_associated_revisions = [];
foreach (array_keys($this->getSupportedEntityTypes()) as $entity_type_id) {
$all_associated_revisions[$entity_type_id] = $this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id);
}
$all_associated_revisions = array_filter($all_associated_revisions);
$count = 1;
foreach ($tracked_entities as $entity_type_id => $entities) {
foreach ($all_associated_revisions as $entity_type_id => $associated_revisions) {
$associated_entity_storage = $this->entityTypeManager->getStorage($entity_type_id);
$associated_revisions = $this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id);
// Sort the associated revisions in reverse ID order, so we can delete the
// most recent revisions first.
krsort($associated_revisions);
// Get a list of default revisions tracked by the given workspace, because
// they need to be handled differently than pending revisions.
$initial_revision_ids = $this->workspaceAssociation->getAssociatedInitialRevisions($workspace_id, $entity_type_id);
foreach (array_keys($associated_revisions) as $revision_id) {
if ($count > $batch_size) {
continue 2;
}
// Delete the associated entity revision.
$associated_entity_storage->deleteRevision($revision_id);
// If the workspace is tracking the entity's default revision (i.e. the
// entity was created inside that workspace), we need to delete the
// whole entity after all of its pending revisions are gone.
if (isset($initial_revision_ids[$revision_id])) {
$associated_entity_storage->delete([$associated_entity_storage->load($initial_revision_ids[$revision_id])]);
}
else {
// 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 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)) {
$has_associated_revisions = FALSE;
foreach (array_keys($this->getSupportedEntityTypes()) as $entity_type_id) {
if (!empty($this->workspaceAssociation->getAssociatedRevisions($workspace_id, $entity_type_id))) {
$has_associated_revisions = TRUE;
break;
}
}
if (!$has_associated_revisions) {
unset($deleted_workspace_ids[$workspace_id]);
$this->state->set('workspace.deleted', $deleted_workspace_ids);
// Delete any possible leftover association entries.
$this->workspaceAssociation->deleteAssociations($workspace_id);
}
}

View File

@ -145,6 +145,7 @@ class WorkspaceRepository implements WorkspaceRepositoryInterface {
* {@inheritdoc}
*/
public function resetCache() {
$this->cache->invalidate('workspace_tree');
$this->tree = NULL;
return $this;

View File

@ -0,0 +1,172 @@
<?php
namespace Drupal\Tests\workspaces\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\workspaces\Entity\Workspace;
/**
* Tests workspace associations.
*
* @coversDefaultClass \Drupal\workspaces\WorkspaceAssociation
*
* @group workspaces
*/
class WorkspaceAssociationTest extends KernelTestBase {
use ContentTypeCreationTrait;
use NodeCreationTrait;
use UserCreationTrait;
use WorkspaceTestTrait;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* {@inheritdoc}
*/
protected static $modules = [
'field',
'filter',
'node',
'text',
'user',
'system',
'path_alias',
'workspaces',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->entityTypeManager = \Drupal::entityTypeManager();
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installEntitySchema('workspace');
$this->installConfig(['filter', 'node', 'system']);
$this->installSchema('node', ['node_access']);
$this->installSchema('system', ['sequences']);
$this->installSchema('workspaces', ['workspace_association']);
$this->createContentType(['type' => 'article']);
$permissions = array_intersect([
'administer nodes',
'create workspace',
'edit any workspace',
'view any workspace',
], array_keys($this->container->get('user.permissions')->getPermissions()));
$this->setCurrentUser($this->createUser($permissions));
$this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'label' => 'Stage']);
$this->workspaces['stage']->save();
$this->workspaces['dev'] = Workspace::create(['id' => 'dev', 'parent' => 'stage', 'label' => 'Dev']);
$this->workspaces['dev']->save();
}
/**
* Tests the revisions tracked by a workspace.
*
* @covers ::getTrackedEntities
* @covers ::getAssociatedRevisions
*/
public function testWorkspaceAssociation() {
$this->createNode(['title' => 'Test article 1 - live - unpublished', 'type' => 'article', 'status' => 0]);
$this->createNode(['title' => 'Test article 2 - live - published', 'type' => 'article']);
// Edit one of the existing nodes in 'stage'.
$this->switchToWorkspace('stage');
$node = $this->entityTypeManager->getStorage('node')->load(1);
$node->setTitle('Test article 1 - stage - published');
$node->setPublished();
// This creates rev. 3.
$node->save();
// Generate content with the following structure:
// Stage:
// - Test article 3 - stage - unpublished (rev. 4)
// - Test article 4 - stage - published (rev. 5 and 6)
$this->createNode(['title' => 'Test article 3 - stage - unpublished', 'type' => 'article', 'status' => 0]);
$this->createNode(['title' => 'Test article 4 - stage - published', 'type' => 'article']);
$expected_latest_revisions = [
'stage' => [3, 4, 6],
];
$expected_all_revisions = [
'stage' => [3, 4, 5, 6],
];
$expected_initial_revisions = [
'stage' => [4, 5],
];
$this->assertWorkspaceAssociations('node', $expected_latest_revisions, $expected_all_revisions, $expected_initial_revisions);
// Dev:
// - Test article 1 - stage - published (rev. 3)
// - Test article 3 - stage - unpublished (rev. 4)
// - Test article 4 - stage - published (rev. 5 and 6)
// - Test article 5 - dev - unpublished (rev. 7)
// - Test article 6 - dev - published (rev. 8 and 9)
$this->switchToWorkspace('dev');
$this->createNode(['title' => 'Test article 5 - dev - unpublished', 'type' => 'article', 'status' => 0]);
$this->createNode(['title' => 'Test article 6 - dev - published', 'type' => 'article']);
$expected_latest_revisions += [
'dev' => [3, 4, 6, 7, 9],
];
// Revisions 3, 4, 5 and 6 that were created in the parent 'stage' workspace
// are also considered as being part of the child 'dev' workspace.
$expected_all_revisions += [
'dev' => [3, 4, 5, 6, 7, 8, 9],
];
$expected_initial_revisions += [
'stage' => [7, 8],
];
$this->assertWorkspaceAssociations('node', $expected_latest_revisions, $expected_all_revisions, $expected_initial_revisions);
}
/**
* Checks the workspace associations for a test scenario.
*
* @param string $entity_type_id
* The ID of the entity type that is being tested.
* @param array $expected_latest_revisions
* An array of expected values for the latest tracked revisions.
* @param array $expected_all_revisions
* An array of expected values for all the tracked revisions.
* @param array $expected_initial_revisions
* An array of expected values for the initial revisions, i.e. for the
* entities that were created in the specified workspace.
*/
protected function assertWorkspaceAssociations($entity_type_id, array $expected_latest_revisions, array $expected_all_revisions, array $expected_initial_revisions) {
$workspace_association = \Drupal::service('workspaces.association');
foreach ($expected_latest_revisions as $workspace_id => $expected_tracked_revision_ids) {
$tracked_entities = $workspace_association->getTrackedEntities($workspace_id, $entity_type_id);
$tracked_revision_ids = $tracked_entities[$entity_type_id] ?? [];
$this->assertEquals($expected_tracked_revision_ids, array_keys($tracked_revision_ids));
}
foreach ($expected_all_revisions as $workspace_id => $expected_all_revision_ids) {
$all_associated_revisions = $workspace_association->getAssociatedRevisions($workspace_id, $entity_type_id);
$this->assertEquals($expected_all_revision_ids, array_keys($all_associated_revisions));
}
foreach ($expected_initial_revisions as $workspace_id => $expected_initial_revision_ids) {
$initial_revisions = $workspace_association->getAssociatedInitialRevisions($workspace_id, $entity_type_id);
$this->assertEquals($expected_initial_revision_ids, array_keys($initial_revisions));
}
}
}

View File

@ -69,6 +69,7 @@ class WorkspaceCRUDTest extends KernelTestBase {
$this->installEntitySchema('workspace');
$this->installSchema('workspaces', ['workspace_association']);
$this->installEntitySchema('node');
$this->installEntitySchema('path_alias');
$this->installConfig(['filter', 'node', 'system']);
@ -106,10 +107,9 @@ class WorkspaceCRUDTest extends KernelTestBase {
$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);
// Check that the workspace tracks the initial revisions for both nodes.
$initial_revisions = $workspace_association->getAssociatedInitialRevisions($workspace_1->id(), 'node');
$this->assertCount(2, $initial_revisions);
for ($i = 0; $i < 4; $i++) {
$workspace_1_node_1->setNewRevision(TRUE);
@ -123,13 +123,10 @@ class WorkspaceCRUDTest extends KernelTestBase {
$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'.
// Since all the revisions were created inside a workspace, including the
// default one, 'workspace_1' should be tracking all 10 revisions.
$associated_revisions = $workspace_association->getAssociatedRevisions($workspace_1->id(), 'node');
$this->assertCount(8, $associated_revisions);
$this->assertCount(10, $associated_revisions);
// Check that we are allowed to delete the workspace.
$this->assertTrue($workspace_1->access('delete', $admin));
@ -146,10 +143,6 @@ class WorkspaceCRUDTest extends KernelTestBase {
$associated_revisions = $workspace_association->getAssociatedRevisions($workspace_1->id(), 'node');
$this->assertCount(0, $associated_revisions);
// 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.
$workspace_2 = Workspace::create([
@ -169,23 +162,15 @@ class WorkspaceCRUDTest extends KernelTestBase {
$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);
// The other 59 are associated with 'workspace_2'.
// All 60 are associated with 'workspace_2'.
$associated_revisions = $workspace_association->getAssociatedRevisions($workspace_2->id(), 'node', [$workspace_2_node_1->id()]);
$this->assertCount(59, $associated_revisions);
$this->assertCount(60, $associated_revisions);
// Delete the workspace and check that we still have 9 revision left to
// Delete the workspace and check that we still have 10 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);
// The live revision is also still there.
$live_revisions = $this->getUnassociatedRevisions('node', [$workspace_2_node_1->id()]);
$this->assertCount(1, $live_revisions);
$this->assertCount(10, $associated_revisions);
$workspace_deleted = \Drupal::state()->get('workspace.deleted');
$this->assertCount(1, $workspace_deleted);
@ -204,18 +189,11 @@ class WorkspaceCRUDTest extends KernelTestBase {
// from the "workspace.delete" state entry.
\Drupal::service('cron')->run();
$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);
$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);
$this->assertCount(0, $tracked_entities);
$workspace_deleted = \Drupal::state()->get('workspace.deleted');
$this->assertCount(0, $workspace_deleted);

View File

@ -37,6 +37,12 @@ trait WorkspaceTestTrait {
$this->installEntitySchema('workspace');
$this->installSchema('workspaces', ['workspace_association']);
// Install the entity schema for supported entity types to ensure that the
// 'workspace' revision metadata field gets created.
foreach (array_keys($this->workspaceManager->getSupportedEntityTypes()) as $entity_type_id) {
$this->installEntitySchema($entity_type_id);
}
// Create two workspaces by default, 'live' and 'stage'.
$this->workspaces['live'] = Workspace::create(['id' => 'live', 'label' => 'Live']);
$this->workspaces['live']->save();