diff --git a/src/Event/DefaultContentEvents.php b/src/Event/DefaultContentEvents.php index 1dc78e5..5d7b391 100644 --- a/src/Event/DefaultContentEvents.php +++ b/src/Event/DefaultContentEvents.php @@ -6,12 +6,13 @@ namespace Drupal\default_content\Event; * Defines the events for Default Content. * * @see \Drupal\default_content\Event\ImportEvent + * @see \Drupal\default_content\Event\UpdateEvent * @see \Drupal\default_content\Event\ExportEvent */ final class DefaultContentEvents { /** - * Name of the event fired when importing default content. + * Name of the event fired when importing new default content. * * This event allows modules to perform actions after the default content has * been imported. The event listener receives a @@ -26,6 +27,21 @@ final class DefaultContentEvents { const IMPORT = 'default_content.import'; /** + * Name of the event fired when updating existing default content. + * + * This event allows modules to perform actions after the default content has + * been updated. The event listener receives a + * \Drupal\default_content\Event\ImportEvent instance. + * + * @Event + * + * @see \Drupal\default_content\Event\UpdateEvent + * + * @var string + */ + const UPDATE = 'default_content.update'; + + /** * Name of the event fired when exporting default content. * * This event allows modules to perform actions after the default content has diff --git a/src/Event/UpdateEvent.php b/src/Event/UpdateEvent.php new file mode 100644 index 0000000..f8ea75e --- /dev/null +++ b/src/Event/UpdateEvent.php @@ -0,0 +1,61 @@ +entities = $entities; + $this->module = $module; + } + + /** + * Get the updated entities. + * + * @return \Drupal\Core\Entity\ContentEntityInterface[] + * An array of content entities that were imported. + */ + public function getUpdatedEntities() { + return $this->entities; + } + + /** + * Gets the module name. + * + * @return string + * The module name that provided the default content. + */ + public function getModule() { + return $this->module; + } + +} diff --git a/src/Importer.php b/src/Importer.php index 600ed5c..88e323d 100644 --- a/src/Importer.php +++ b/src/Importer.php @@ -4,7 +4,9 @@ namespace Drupal\default_content; use Drupal\Component\Graph\Graph; use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; use Drupal\Core\Session\AccountSwitcherInterface; use Drupal\default_content\Event\DefaultContentEvents; use Drupal\default_content\Event\ImportEvent; @@ -114,15 +116,17 @@ class Importer implements ImporterInterface { /** * {@inheritdoc} */ - public function importContent($module) { + public function importContent($module, $update_existing = FALSE) { $created = []; + $updated = []; $folder = drupal_get_path('module', $module) . "/content"; if (file_exists($folder)) { $root_user = $this->entityTypeManager->getStorage('user')->load(1); $this->accountSwitcher->switchTo($root_user); $file_map = []; - foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { + $definitions = $this->entityTypeManager->getDefinitions(); + foreach ($definitions as $entity_type_id => $entity_type) { $reflection = new \ReflectionClass($entity_type->getClass()); // We are only interested in importing content entities. if ($reflection->implementsInterface(ConfigEntityInterface::class)) { @@ -178,19 +182,60 @@ class Importer implements ImporterInterface { if (!empty($file_map[$link])) { $file = $file_map[$link]; $entity_type_id = $file->entity_type_id; - $class = $this->entityTypeManager->getDefinition($entity_type_id)->getClass(); + /* @var $entity_type \Drupal\Core\Entity\EntityTypeInterface */ + $entity_type = $definitions[$entity_type_id]; $contents = $this->parseFile($file); - $entity = $this->serializer->deserialize($contents, $class, 'hal_json', ['request_method' => 'POST']); - $entity->enforceIsNew(TRUE); + + /* @var $entity \Drupal\Core\Entity\EntityInterface */ + $entity = $this->serializer->deserialize($contents, $entity_type->getClass(), 'hal_json', ['request_method' => 'POST']); + + $is_new = TRUE; + + $old_entity = $this->lookupEntity($entity, $entity_type); + + if ($old_entity && $update_existing) { + // All unique keys need to match the old entity. + $entity->{$entity_type->getKey('uuid')} = $old_entity->uuid(); + $entity->{$entity_type->getKey('id')} = $old_entity->id(); + $is_new = FALSE; + if ($this->isRevisionableEntity($entity)) { + $entity->{$entity_type->getKey('revision')} = $old_entity->getRevisionId(); + } + } + elseif (!$old_entity) { + // Don't import site level IDs if they are used. + if ($this->existEntityId($entity, $entity_type)) { + $entity->{$entity_type->getKey('id')} = NULL; + } + $entity->{$entity_type->getKey('revision')} = NULL; + } + + !$is_new && $old_entity ? $entity->setOriginalId($old_entity->id()) : $entity->enforceIsNew($is_new); + if ($this->isRevisionableEntity($entity)) { + $entity->setNewRevision($is_new); + } + // Ensure that the entity is not owned by the anonymous user. if ($entity instanceof EntityOwnerInterface && empty($entity->getOwnerId())) { $entity->setOwner($root_user); } - $entity->save(); - $created[$entity->uuid()] = $entity; + + if ($old_entity && $update_existing) { + $updated[$entity->uuid()] = $entity; + $entity->save(); + } + elseif (!$old_entity) { + $created[$entity->uuid()] = $entity; + $entity->save(); + } } } - $this->eventDispatcher->dispatch(DefaultContentEvents::IMPORT, new ImportEvent($created, $module)); + if (!empty($created)) { + $this->eventDispatcher->dispatch(DefaultContentEvents::IMPORT, new ImportEvent($created, $module)); + } + if (!empty($updated)) { + $this->eventDispatcher->dispatch(DefaultContentEvents::UPDATE, new ImportEvent($updated, $module)); + } $this->accountSwitcher->switchBack(); } // Reset the tree. @@ -201,6 +246,60 @@ class Importer implements ImporterInterface { } /** + * Lookup whether an entity already exists. + * + * For most typical entities this is done by uuid. + * For core user 1 this is done by id. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that will be imported. + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type for this entity. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The old entity, or NULL if no entity. + */ + public function lookupEntity($entity, $entity_type) { + $entity_storage = $this->entityTypeManager->getStorage($entity_type->id()); + + $lookup_properties = [$entity_type->getKey('uuid') => $entity->uuid()]; + // Alter the lookup properties for known core irregularities. + if ($entity_type->id() === 'user' && $entity->id() == 1) { + $lookup_properties = [$entity_type->getKey('id') => $entity->id()]; + } + + $entity_query = $entity_storage->getQuery()->accessCheck(FALSE); + foreach ($lookup_properties as $key => $value) { + // Cast scalars to array so we can consistently use an IN condition. + $entity_query->condition($key, (array) $value, 'IN'); + } + $result = $entity_query->execute(); + + $old_entity = $result ? $entity_storage->load(current($result)) : []; + + return $old_entity; + } + + /** + * Check if an imported entity id already exists. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity that will be imported. + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type of this entity. + * + * @return bool + * TRUE if current entity's id exists. + */ + public function existEntityId($entity, $entity_type) { + $entity_storage = $this->entityTypeManager->getStorage($entity_type->id()); + $entity_query = $entity_storage->getQuery()->accessCheck(FALSE); + $entity_query->condition($entity_type->getKey('id'), (array) $entity->id(), 'IN'); + $result = $entity_query->execute(); + return !empty($result); + } + + /** * Parses content files. * * @param object $file @@ -255,4 +354,17 @@ class Importer implements ImporterInterface { return $this->vertexes[$item_link]; } + /** + * Checks a given entity for revision support. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * A typical drupal entity object. + * + * @return bool + * Whether this entity supports revisions. + */ + protected function isRevisionableEntity(EntityInterface $entity) { + return $entity instanceof RevisionableInterface && $entity->getEntityType()->isRevisionable(); + } + } diff --git a/src/ImporterInterface.php b/src/ImporterInterface.php index 0d300a3..235184b 100644 --- a/src/ImporterInterface.php +++ b/src/ImporterInterface.php @@ -12,10 +12,12 @@ interface ImporterInterface { * * @param string $module * The module to create the default content from. + * @param bool $update_existing + * Whether to update existing entities or ignore them. * * @return \Drupal\Core\Entity\EntityInterface[] * An array of created entities keyed by their UUIDs. */ - public function importContent($module); + public function importContent($module, $update_existing = FALSE); } diff --git a/tests/src/Functional/DefaultContentTest.php b/tests/src/Functional/DefaultContentTest.php index c6807a4..777cf59 100644 --- a/tests/src/Functional/DefaultContentTest.php +++ b/tests/src/Functional/DefaultContentTest.php @@ -102,4 +102,34 @@ class DefaultContentTest extends BrowserTestBase { $this->assertTrue(!empty($term_id), 'Term reference populated'); } + /** + * Test re-importing default content. + */ + public function testReImport() { + // Login as admin. + $this->drupalLogin($this->drupalCreateUser(array_keys(\Drupal::moduleHandler()->invokeAll(('permission'))))); + + // Enable the module and import the content. + \Drupal::service('module_installer')->install(['default_content_test'], TRUE); + $this->rebuildContainer(); + $original_nodes = \Drupal::entityTypeManager()->getListBuilder('node')->getStorage()->loadByProperties(['type' => 'page']); + + // Change the node content. + $node = $this->getNodeByTitle('Imported node'); + $node->title = 'Updated node'; + $node->save(); + + // Re-import the content and check there are no changes. + \Drupal::service('default_content.importer')->importContent('default_content_test'); + $new_nodes = \Drupal::entityTypeManager()->getListBuilder('node')->getStorage()->loadByProperties(['type' => 'page']); + $this->assertSame(array_keys($new_nodes), array_keys($original_nodes), 'No new content has been imported.'); + $node = $this->getNodeByTitle('Imported node'); + $this->assertEmpty($node, "Imported content has not been updated."); + + // Re-import the content and check the content has been updated. + \Drupal::service('default_content.importer')->importContent('default_content_test', TRUE); + $node = $this->getNodeByTitle('Imported node'); + $this->assertNotEmpty($node, "Imported content has been updated."); + } + }