diff --git a/composer.json b/composer.json index abaf559..fa2e460 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,12 @@ "description": "Extends the revision support for content entities.", "type": "drupal-module", "require": { - "relaxedws/lca": "dev-master" + "relaxedws/lca": "dev-master", + "drupal/key_value": "^1", + "drupal/conflict": "^1-beta" + }, + "require-dev": { + "drupal/paragraphs": "^1.1.0" }, "license": "GPL-2.0+" } diff --git a/config/install/multiversion.settings.yml b/config/install/multiversion.settings.yml index 69917b2..0cf0b62 100644 --- a/config/install/multiversion.settings.yml +++ b/config/install/multiversion.settings.yml @@ -14,3 +14,4 @@ enabled_entity_types: - entity_test_local - content_moderation_state - replication_log + - paragraph diff --git a/multiversion.info.yml b/multiversion.info.yml index c7cf054..24d1eda 100644 --- a/multiversion.info.yml +++ b/multiversion.info.yml @@ -8,3 +8,5 @@ dependencies: - serialization - conflict - system (>=8.1.0) +test_dependencies: + - paragraphs diff --git a/multiversion.module b/multiversion.module index f776103..0c06c10 100644 --- a/multiversion.module +++ b/multiversion.module @@ -77,6 +77,7 @@ function multiversion_entity_type_alter(array &$entity_types) { // We can only override the storage handler for entity types we know // what to expect of. if (in_array($storage_class, [NULL, 'Drupal\Core\Entity\Sql\SqlContentEntityStorage'])) { + $entity_type->setHandlerClass('original_storage', $entity_type->getHandlerClass('storage')); $entity_type->setHandlerClass('storage', "$namespace\\ContentEntityStorage"); } break; @@ -170,6 +171,9 @@ function multiversion_field_info_alter(&$info) { $info['entity_reference']['class'] = '\Drupal\multiversion\EntityReferenceItem'; $info['file']['class'] = '\Drupal\multiversion\FileItem'; $info['image']['class'] = '\Drupal\multiversion\ImageItem'; + if (isset($info['entity_reference_revisions'])) { + $info['entity_reference_revisions']['class'] = '\Drupal\multiversion\EntityReferenceRevisionsItem'; + } } /** diff --git a/src/Entity/Storage/ContentEntityStorageInterface.php b/src/Entity/Storage/ContentEntityStorageInterface.php index 8eebfdf..e4f8ae7 100644 --- a/src/Entity/Storage/ContentEntityStorageInterface.php +++ b/src/Entity/Storage/ContentEntityStorageInterface.php @@ -3,6 +3,7 @@ namespace Drupal\multiversion\Entity\Storage; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityInterface; interface ContentEntityStorageInterface extends EntityStorageInterface { @@ -30,4 +31,19 @@ interface ContentEntityStorageInterface extends EntityStorageInterface { * @param array $entities */ public function purge(array $entities); + + /** + * Save the given entity without forcing a new revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Entity that should be saved. + * + * @return + * SAVED_NEW or SAVED_UPDATED is returned depending on the operation + * performed. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * In case of failures, an exception is thrown. + */ + public function saveWithoutForcingNewRevision(EntityInterface $entity); } diff --git a/src/Entity/Storage/ContentEntityStorageTrait.php b/src/Entity/Storage/ContentEntityStorageTrait.php index 9614dec..6502838 100644 --- a/src/Entity/Storage/ContentEntityStorageTrait.php +++ b/src/Entity/Storage/ContentEntityStorageTrait.php @@ -21,6 +21,11 @@ trait ContentEntityStorageTrait { protected $workspaceId = NULL; /** + * @var \Drupal\Core\Entity\EntityStorageInterface + */ + protected $originalStorage; + + /** * {@inheritdoc} */ public function getQueryServiceName() { @@ -28,6 +33,22 @@ trait ContentEntityStorageTrait { } /** + * Get original entity type storage handler (not the multiversion one). + * + * @param string $type + * Entity type. + * + * @return \Drupal\Core\Entity\EntityStorageInterface + * Original entity type storage handler. + */ + protected function getOriginalStorage($type) { + if ($this->originalStorage == NULL) { + $this->originalStorage = $this->entityManager->getHandler($type, 'original_storage'); + } + return $this->originalStorage; + } + + /** * {@inheritdoc} */ protected function buildQuery($ids, $revision_id = FALSE) { @@ -121,6 +142,13 @@ trait ContentEntityStorageTrait { /** * {@inheritdoc} */ + public function saveWithoutForcingNewRevision(EntityInterface $entity) { + $this->getOriginalStorage($entity->getEntityTypeId())->save($entity); + } + + /** + * {@inheritdoc} + */ public function save(EntityInterface $entity) { // Every update is a new revision with this storage model. $entity->setNewRevision(); diff --git a/src/EntityReferenceRevisionsItem.php b/src/EntityReferenceRevisionsItem.php new file mode 100644 index 0000000..9398a58 --- /dev/null +++ b/src/EntityReferenceRevisionsItem.php @@ -0,0 +1,121 @@ +hasNewEntity(); + + // If it is a new entity, parent will save it. + $this->entityReferencePreSave(); + + if (!$has_new) { + // Create a new revision if it is a composite entity in a host with a new + // revision. + $host = $this->getEntity(); + $needs_save = $this->entity instanceof EntityNeedsSaveInterface && $this->entity->needsSave(); + if (!$host->isNew() && $host->isNewRevision() && $this->entity && $this->entity->getEntityType()->get('entity_revision_parent_id_field')) { + $this->entity->setNewRevision(); + if ($host->isDefaultRevision()) { + $this->entity->isDefaultRevision(TRUE); + } + $needs_save = TRUE; + } + if ($needs_save) { + if ($host->_deleted->value == TRUE) { + $this->entity->delete(); + } + else { + $this->entity->save(); + } + } + } + if ($this->entity) { + $this->target_revision_id = $this->entity->getRevisionId(); + } + } + + /** + * Change the logic around revisions handling. + * + * By default multiversion storage forces new revision on entity save. + * But this should be not done on "postSave" call, as we will finish the save + * process only after this method call. + * + * @see \Drupal\entity_reference_revisions\Plugin\Field\FieldType\EntityReferenceRevisionsItem::postSave() + * @see \Drupal\multiversion\Entity\Storage\ContentEntityStorageTrait::saveWithoutForcingNewRevision() + */ + public function postSave($update) { + $needs_save = FALSE; + // If any of entity, parent type or parent id is missing then return. + if (!$this->entity + || !$this->entity->getEntityType()->get('entity_revision_parent_type_field') + || !$this->entity->getEntityType()->get('entity_revision_parent_id_field')) { + return; + } + + $entity = $this->entity; + $parent_entity = $this->getEntity(); + + // If the entity has a parent field name get the key. + if ($entity->getEntityType()->get('entity_revision_parent_field_name_field')) { + $parent_field_name = $entity->getEntityType()->get('entity_revision_parent_field_name_field'); + + // If parent field name has changed then set it. + if ($entity->get($parent_field_name)->value != $this->getFieldDefinition()->getName()) { + $entity->set($parent_field_name, $this->getFieldDefinition()->getName()); + $needs_save = TRUE; + } + } + + $parent_type = $entity->getEntityType()->get('entity_revision_parent_type_field'); + $parent_id = $entity->getEntityType()->get('entity_revision_parent_id_field'); + + // If the parent type has changed then set it. + if ($entity->get($parent_type)->value != $parent_entity->getEntityTypeId()) { + $entity->set($parent_type, $parent_entity->getEntityTypeId()); + $needs_save = TRUE; + } + // If the parent id has changed then set it. + if ($entity->get($parent_id)->value != $parent_entity->id()) { + $entity->set($parent_id, $parent_entity->id()); + $needs_save = TRUE; + } + + if ($needs_save) { + // Check if any of the keys has changed, save it, do not create a new + // revision. + $entity->setNewRevision(FALSE); + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = \Drupal::service('entity_type.manager'); + /** @var \Drupal\multiversion\MultiversionManagerInterface $multiversion_manager */ + $multiversion_manager = \Drupal::service('multiversion.manager'); + $entity_type_id = $entity->getEntityTypeId(); + $entity_type = $entity_type_manager->getDefinition($entity_type_id); + + if ($multiversion_manager->isEnabledEntityType($entity_type)) { + /** @var \Drupal\multiversion\Entity\Storage\ContentEntityStorageInterface $storage */ + $storage = $entity_type_manager->getStorage($entity_type_id); + $storage->saveWithoutForcingNewRevision($entity); + } + } + } + +} diff --git a/tests/modules/multiversion_test_paragraphs/config/install/field.field.node.paragraphs_node_type.field_paragraph.yml b/tests/modules/multiversion_test_paragraphs/config/install/field.field.node.paragraphs_node_type.field_paragraph.yml new file mode 100644 index 0000000..49b826a --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/field.field.node.paragraphs_node_type.field_paragraph.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.node.field_paragraph + - node.type.paragraphs_node_type + - paragraphs.paragraphs_type.test_paragraph_type + module: + - entity_reference_revisions +id: node.paragraphs_node_type.field_paragraph +field_name: field_paragraph +entity_type: node +bundle: paragraphs_node_type +label: Paragraph +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: + handler: 'default:paragraph' + handler_settings: + target_bundles: + test_paragraph_type: test_paragraph_type + target_bundles_drag_drop: + test_paragraph_type: + enabled: true + weight: 1 +field_type: entity_reference_revisions diff --git a/tests/modules/multiversion_test_paragraphs/config/install/field.field.paragraph.test_paragraph_type.field_test_field.yml b/tests/modules/multiversion_test_paragraphs/config/install/field.field.paragraph.test_paragraph_type.field_test_field.yml new file mode 100644 index 0000000..1ff39d6 --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/field.field.paragraph.test_paragraph_type.field_test_field.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.paragraph.field_test_field + - paragraphs.paragraphs_type.test_paragraph_type +id: paragraph.test_paragraph_type.field_test_field +field_name: field_test_field +entity_type: paragraph +bundle: test_paragraph_type +label: 'Test field' +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/tests/modules/multiversion_test_paragraphs/config/install/field.storage.node.field_paragraph.yml b/tests/modules/multiversion_test_paragraphs/config/install/field.storage.node.field_paragraph.yml new file mode 100644 index 0000000..b8dfe78 --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/field.storage.node.field_paragraph.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - entity_reference_revisions + - node + - paragraphs +id: node.field_paragraph +field_name: field_paragraph +entity_type: node +type: entity_reference_revisions +settings: + target_type: paragraph +module: entity_reference_revisions +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/tests/modules/multiversion_test_paragraphs/config/install/field.storage.paragraph.field_test_field.yml b/tests/modules/multiversion_test_paragraphs/config/install/field.storage.paragraph.field_test_field.yml new file mode 100644 index 0000000..3cfc517 --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/field.storage.paragraph.field_test_field.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - paragraphs +id: paragraph.field_test_field +field_name: field_test_field +entity_type: paragraph +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/tests/modules/multiversion_test_paragraphs/config/install/node.type.paragraphs_node_type.yml b/tests/modules/multiversion_test_paragraphs/config/install/node.type.paragraphs_node_type.yml new file mode 100644 index 0000000..ea41c4f --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/node.type.paragraphs_node_type.yml @@ -0,0 +1,9 @@ +langcode: en +status: true +name: 'Paragraphs node type' +type: paragraphs_node_type +description: '' +help: '' +new_revision: true +preview_mode: 1 +display_submitted: false diff --git a/tests/modules/multiversion_test_paragraphs/config/install/paragraphs.paragraphs_type.test_paragraph_type.yml b/tests/modules/multiversion_test_paragraphs/config/install/paragraphs.paragraphs_type.test_paragraph_type.yml new file mode 100644 index 0000000..b84aaf2 --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/config/install/paragraphs.paragraphs_type.test_paragraph_type.yml @@ -0,0 +1,6 @@ +langcode: en +status: true +dependencies: { } +id: test_paragraph_type +label: 'Test paragraph type' +behavior_plugins: { } diff --git a/tests/modules/multiversion_test_paragraphs/multiversion_test_paragraphs.info.yml b/tests/modules/multiversion_test_paragraphs/multiversion_test_paragraphs.info.yml new file mode 100644 index 0000000..e12f0ec --- /dev/null +++ b/tests/modules/multiversion_test_paragraphs/multiversion_test_paragraphs.info.yml @@ -0,0 +1,10 @@ +name: 'Multiversion test paragraphs' +type: module +description: 'Provides default node type and paragraph type for testing.' +package: Testing +version: VERSION +core: 8.x +dependencies: + - multiversion + - node + - language diff --git a/tests/src/Kernel/ParagraphsTest.php b/tests/src/Kernel/ParagraphsTest.php new file mode 100644 index 0000000..18162e9 --- /dev/null +++ b/tests/src/Kernel/ParagraphsTest.php @@ -0,0 +1,189 @@ +container->get('entity_type.manager'); + $this->installConfig(['multiversion', 'multiversion_test_paragraphs']); + $this->installEntitySchema('workspace'); + $this->installSchema('node', 'node_access'); + $this->installSchema('key_value', 'key_value_sorted'); + $multiversion_manager = $this->container->get('multiversion.manager'); + $multiversion_manager->enableEntityTypes(); + $workspace = Workspace::create([ + 'machine_name' => 'live', + 'label' => 'Live', + 'type' => 'basic', + ]); + $workspace->save(); + $this->nodeStorage = $entityTypeManager->getStorage('node'); + $this->paragraphStorage = $entityTypeManager->getStorage('paragraph'); + } + + /** + * Tests that paragraphs revisions created right when saving parent entity. + */ + public function testDefaultParagraphsBehaviour() { + $paragraph = $this->paragraphStorage->create([ + 'title' => 'Stub of real paragraph', + 'type' => 'test_paragraph_type', + 'field_test_field' => 'First revision title', + ]); + $node = $this->nodeStorage->create([ + 'type' => 'paragraphs_node_type', + 'title' => 'Test node', + 'field_paragraph' => $paragraph, + ]); + $node->save(); + + $node_revision_id = $node->getRevisionId(); + $paragraph_entity_id = $node->field_paragraph->target_id; + $paragraph_entity = $this->paragraphStorage->load($paragraph_entity_id); + /** @var \Drupal\paragraphs\Entity\Paragraph $paragraph_entity */ + list($i, $hash) = explode('-', $paragraph_entity->_rev->value); + $this->assertEquals($i, '1', 'After saving new node with paragraph we have new paragraph with one revision.'); + + $paragraph->field_test_field = 'Second revision title'; + $node->field_paragraph = $paragraph; + $node->save(); + + $paragraph_entity_revision_id = $node->field_paragraph->target_revision_id; + $paragraph_entity = $this->paragraphStorage->loadRevision($paragraph_entity_revision_id); + + $this->assertRevNumber($paragraph_entity, 2); + $this->assertEquals($paragraph_entity->field_test_field->value, 'Second revision title'); + + $node_first_revision = $this->nodeStorage->loadRevision($node_revision_id); + $paragraph_entity_revision_id = $node_first_revision->field_paragraph->target_revision_id; + $paragraph_entity = $this->paragraphStorage->loadRevision($paragraph_entity_revision_id); + $this->assertEquals($paragraph_entity->field_test_field->value, 'First revision title'); + } + + /** + * Tests stub handling for paragraph when it is created after parent entity. + */ + public function testParagraphStubCreatedAfterParent() { + $paragraph_stub = $this->paragraphStorage->create([ + 'title' => 'Stub of real paragraph', + 'type' => 'test_paragraph_type', + ]); + $paragraph_stub->_rev->is_stub = TRUE; + $node = $this->nodeStorage->create([ + 'type' => 'paragraphs_node_type', + 'title' => 'Test node', + 'field_paragraph' => $paragraph_stub, + ]); + $node->save(); + $paragraph_stub_entity_id = $node->field_paragraph->target_id; + $paragraph_stub_entity = $this->paragraphStorage->load($paragraph_stub_entity_id); + $this->assertRevNumber($paragraph_stub_entity, 0); + + $paragraph_real = $this->paragraphStorage->create([ + 'type' => 'test_paragraph_type', + 'id' => $paragraph_stub_entity->id(), + ]); + $paragraph_real->enforceIsNew(FALSE); + $paragraph_real->_rev->is_stub = FALSE; + $paragraph_real->save(); + + $this->assertEquals($paragraph_real->id(), $paragraph_stub_entity_id); + $this->assertRevNumber($paragraph_real, 1); + } + + /** + * Tests stub handling for paragraph when it is created before parent entity. + */ + public function testParagraphStubCreatedBeforeParent() { + // Create and save real paragraph. + $paragraph = $this->paragraphStorage->create([ + 'title' => 'Real paragraph', + 'type' => 'test_paragraph_type', + ]); + $paragraph->save(); + // Assert that created paragraph is not a stub and it is the first revision. + $this->assertRevNumber($paragraph, 1); + + // Create stub paragraph with same uuid as real paragraph. + $paragraph_stub_in_node = $this->paragraphStorage->create([ + 'type' => 'test_paragraph_type', + 'uuid' => $paragraph->uuid, + ]); + $paragraph_stub_in_node->_rev->is_stub = TRUE; + + // Create node with paragraph stub. + $node = $this->nodeStorage->create([ + 'type' => 'paragraphs_node_type', + 'title' => 'Test node', + 'field_paragraph' => $paragraph_stub_in_node, + ]); + $node->save(); + + $paragraph_entity_id_from_node = $node->field_paragraph->target_id; + $this->assertEquals($paragraph_entity_id_from_node, $paragraph->id()); + + $paragraph_entity = $this->paragraphStorage->load($paragraph->id()); + $this->assertRevNumber($paragraph_entity, 1); + } + + /** + * Assert that entity has given _rev number. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * Given entity. + * @param int $expected_rev_number + * Expected _rev number. + */ + protected function assertRevNumber(EntityInterface $entity, $expected_rev_number) { + list($rev_number) = explode('-', $entity->_rev->value); + $this->assertEquals($expected_rev_number, $rev_number); + } + +}