diff --git a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php index 85c1d0945d..35b02ed05f 100644 --- a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php +++ b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php @@ -241,6 +241,20 @@ public function uninstallEntityType(EntityTypeInterface $entity_type) { $this->entityTypeListener->onEntityTypeDelete($entity_type); } + /** + * {@inheritdoc} + */ + public function updateFieldableEntityType(EntityTypeInterface $entity_type, array $field_storage_definitions, array &$sandbox = NULL) { + $original = $this->getEntityType($entity_type->id()); + + if ($this->requiresEntityDataMigration($entity_type, $original) && $sandbox === NULL) { + throw new \InvalidArgumentException('The entity schema update for the ' . $entity_type->id() . ' entity type requires a data migration.'); + } + + $original_field_storage_definitions = $this->entityLastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($entity_type->id()); + $this->entityTypeListener->onFieldableEntityTypeUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + /** * {@inheritdoc} */ @@ -442,6 +456,22 @@ protected function requiresFieldStorageSchemaChanges(FieldStorageDefinitionInter return ($storage instanceof DynamicallyFieldableEntityStorageSchemaInterface) && $storage->requiresFieldStorageSchemaChanges($storage_definition, $original); } + /** + * Checks if existing data would be lost if the schema changes were applied. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * + * @return bool + * TRUE if data migration is required, FALSE otherwise. + */ + protected function requiresEntityDataMigration(EntityTypeInterface $entity_type, EntityTypeInterface $original) { + $storage = $this->entityTypeManager->getStorage($entity_type->id()); + return ($storage instanceof EntityStorageSchemaInterface) && $storage->requiresEntityDataMigration($entity_type, $original); + } + /** * Clears necessary caches to apply entity/field definition updates. */ diff --git a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php index a219f3c046..02cbe2cf13 100644 --- a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php @@ -144,6 +144,20 @@ public function updateEntityType(EntityTypeInterface $entity_type); */ public function uninstallEntityType(EntityTypeInterface $entity_type); + /** + * Applies any change performed to a fieldable entity type definition. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $field_storage_definitions + * The updated field storage definitions, including possibly new ones. + * @param array &$sandbox + * (optional) A sandbox array provided by a hook_update_N() implementation + * or a Batch API callback. If the entity schema update requires a data + * migration, this parameter is mandatory. Defaults to NULL. + */ + public function updateFieldableEntityType(EntityTypeInterface $entity_type, array $field_storage_definitions, array &$sandbox = NULL); + /** * Returns a field storage definition ready to be manipulated. * diff --git a/core/lib/Drupal/Core/Entity/EntityManager.php b/core/lib/Drupal/Core/Entity/EntityManager.php index ef666d5bfa..86a337e2c3 100644 --- a/core/lib/Drupal/Core/Entity/EntityManager.php +++ b/core/lib/Drupal/Core/Entity/EntityManager.php @@ -547,6 +547,19 @@ public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeI $this->container->get('entity_type.listener')->onEntityTypeUpdate($entity_type, $original); } + /** + * {@inheritdoc} + * + * @deprecated in Drupal 8.7.0, will be removed before Drupal 9.0.0. + * Use \Drupal\Core\Entity\EntityTypeListenerInterface::onFieldableEntityTypeUpdate() + * instead. + * + * @see https://www.drupal.org/project/drupal/issues/2984782 + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $this->container->get('entity_type.listener')->onFieldableEntityTypeUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + /** * {@inheritdoc} * diff --git a/core/lib/Drupal/Core/Entity/EntityStorageBase.php b/core/lib/Drupal/Core/Entity/EntityStorageBase.php index 5bea7297e6..2ceb19c2b1 100644 --- a/core/lib/Drupal/Core/Entity/EntityStorageBase.php +++ b/core/lib/Drupal/Core/Entity/EntityStorageBase.php @@ -533,6 +533,17 @@ protected function doPostSave(EntityInterface $entity, $update) { unset($entity->original); } + /** + * {@inheritdoc} + */ + public function restore(EntityInterface $entity) { + // Allow code to run before saving. + $entity->preSave($this); + + // The restore process does not invoke any post-save operations. + $this->doSave($entity->id(), $entity); + } + /** * Builds an entity query. * diff --git a/core/lib/Drupal/Core/Entity/EntityStorageInterface.php b/core/lib/Drupal/Core/Entity/EntityStorageInterface.php index 1db273945a..0c776ab3b4 100644 --- a/core/lib/Drupal/Core/Entity/EntityStorageInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityStorageInterface.php @@ -154,6 +154,23 @@ public function delete(array $entities); */ public function save(EntityInterface $entity); + /** + * Restores a previously saved entity. + * + * Note that the entity is assumed to be in a valid state for the storage, so + * the restore process does not invoke any hooks, nor does it perform any + * post-save operations. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to restore. + * + * @throws \Drupal\Core\Entity\EntityStorageException + * In case of failures, an exception is thrown. + * + * @internal + */ + public function restore(EntityInterface $entity); + /** * Determines if the storage contains any data. * diff --git a/core/lib/Drupal/Core/Entity/EntityTypeEventSubscriberTrait.php b/core/lib/Drupal/Core/Entity/EntityTypeEventSubscriberTrait.php index 98223ce044..126132955d 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeEventSubscriberTrait.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeEventSubscriberTrait.php @@ -65,6 +65,12 @@ public function onEntityTypeCreate(EntityTypeInterface $entity_type) { public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original) { } + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/EntityTypeListener.php b/core/lib/Drupal/Core/Entity/EntityTypeListener.php index 4dc1e9cf32..8e256f41b2 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeListener.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeListener.php @@ -115,4 +115,27 @@ public function onEntityTypeDelete(EntityTypeInterface $entity_type) { $this->entityLastInstalledSchemaRepository->deleteLastInstalledDefinition($entity_type_id); } + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $entity_type_id = $entity_type->id(); + + // @todo Forward this to all interested handlers, not only storage, once + // iterating handlers is possible: https://www.drupal.org/node/2332857. + $storage = $this->entityTypeManager->createHandlerInstance($entity_type->getStorageClass(), $entity_type); + if ($storage instanceof EntityTypeListenerInterface) { + $storage->onFieldableEntityTypeUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + + if ($sandbox === NULL || (isset($sandbox['#finished']) && $sandbox['#finished'] === 1)) { + $this->eventDispatcher->dispatch(EntityTypeEvents::UPDATE, new EntityTypeEvent($entity_type, $original)); + + $this->entityLastInstalledSchemaRepository->setLastInstalledDefinition($entity_type); + if ($entity_type->entityClassImplements(FieldableEntityInterface::class)) { + $this->entityLastInstalledSchemaRepository->setLastInstalledFieldStorageDefinitions($entity_type_id, $field_storage_definitions); + } + } + } + } diff --git a/core/lib/Drupal/Core/Entity/EntityTypeListenerInterface.php b/core/lib/Drupal/Core/Entity/EntityTypeListenerInterface.php index 912f28fa7c..c99305f988 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeListenerInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeListenerInterface.php @@ -25,6 +25,24 @@ public function onEntityTypeCreate(EntityTypeInterface $entity_type); */ public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original); + /** + * Reacts to the update of a fieldable entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $field_storage_definitions + * The updated field storage definitions, including possibly new ones. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $original_field_storage_definitions + * The original field storage definitions. + * @param array &$sandbox + * (optional) A sandbox array provided by a hook_update_N() implementation + * or a Batch API callback. If the entity schema update requires a data + * migration, this parameter is mandatory. Defaults to NULL. + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL); + /** * Reacts to the deletion of the entity type. * diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php index 7edc4f6307..457e546e5d 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php @@ -787,6 +787,57 @@ public function save(EntityInterface $entity) { } } + /** + * {@inheritdoc} + */ + public function restore(EntityInterface $entity) { + $transaction = $this->database->startTransaction(); + try { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + // Allow code to run before saving. + $entity->preSave($this); + $this->invokeFieldMethod('preSave', $entity); + + // Insert the entity data in the base and data tables only for default + // revisions. + if ($entity->isDefaultRevision()) { + $record = $this->mapToStorageRecord($entity->getUntranslated(), $this->baseTable); + $this->database + ->insert($this->baseTable) + ->fields((array) $record) + ->execute(); + + if ($this->dataTable) { + $this->saveToSharedTables($entity); + } + } + + // Insert the entity data in the revision and revision data tables. + if ($this->revisionTable) { + $record = $this->mapToStorageRecord($entity->getUntranslated(), $this->revisionTable); + $this->database + ->insert($this->revisionTable) + ->fields((array) $record) + ->execute(); + + if ($this->revisionDataTable) { + $this->saveToSharedTables($entity, $this->revisionDataTable); + } + } + + // Insert the entity data in the dedicated tables. + $this->saveToDedicatedTables($entity, FALSE, []); + + // Ignore replica server temporarily. + \Drupal::service('database.replica_kill_switch')->trigger(); + } + catch (\Exception $e) { + $transaction->rollBack(); + watchdog_exception($this->entityTypeId, $e); + throw new EntityStorageException($e->getMessage(), $e->getCode(), $e); + } + } + /** * {@inheritdoc} */ @@ -1424,6 +1475,15 @@ public function onEntityTypeDelete(EntityTypeInterface $entity_type) { }); } + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $this->wrapSchemaException(function () use ($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, &$sandbox) { + $this->getStorageSchema()->onFieldableEntityTypeUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + }); + } + /** * {@inheritdoc} */ diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php index f4505bdbbf..6387d70427 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php @@ -28,6 +28,9 @@ class SqlContentEntityStorageSchema implements DynamicallyFieldableEntityStorageSchemaInterface { use DependencySerializationTrait; + use SqlFieldableEntityTypeListenerTrait { + onFieldableEntityTypeUpdate as traitOnFieldableEntityTypeUpdate; + } /** * The entity manager. @@ -408,6 +411,186 @@ public function onEntityTypeDelete(EntityTypeInterface $entity_type) { $this->deleteEntitySchemaData($entity_type); } + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $this->entityType = $entity_type; + $this->fieldStorageDefinitions = $field_storage_definitions; + $this->traitOnFieldableEntityTypeUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + + /** + * {@inheritdoc} + */ + protected function preUpdateEntityTypeSchema(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + // Make sure that each storage object has a proper table mapping. + /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $temporary_storage */ + $temporary_storage = &$sandbox['temporary_storage']; + $temporary_prefix = static::getTemporaryTableMappingPrefix($entity_type, $field_storage_definitions); + $temporary_table_mapping = $temporary_storage->getCustomTableMapping($entity_type, $field_storage_definitions, $temporary_prefix); + $temporary_storage->setTableMapping($temporary_table_mapping); + + /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $original_storage */ + $original_storage = &$sandbox['original_storage']; + $original_table_mapping = $original_storage->getCustomTableMapping($original, $original_field_storage_definitions); + $original_storage->setTableMapping($original_table_mapping); + + $sandbox['new_table_mapping'] = $temporary_storage->getCustomTableMapping($entity_type, $field_storage_definitions); + $sandbox['backup_table_mapping'] = $original_storage->getCustomTableMapping($original, $original_field_storage_definitions, 'old_'); + + // Create temporary tables based on the new entity type and field storage + // definitions. + $schema_handler = $this->database->schema(); + + // Create entity tables. + $temporary_table_names = array_combine($sandbox['new_table_mapping']->getTableNames(), $temporary_table_mapping->getTableNames()); + $schema = $this->getEntitySchema($entity_type, TRUE); + + // Filter out tables which are not part of the table mapping. + $schema = array_intersect_key($schema, $temporary_table_names); + foreach ($schema as $table_name => $table_schema) { + $temp_table_name = $temporary_table_names[$table_name]; + if (!$schema_handler->tableExists($temp_table_name)) { + $schema_handler->createTable($temp_table_name, $table_schema); + } + } + + // Create dedicated field tables. + foreach ($field_storage_definitions as $field_storage_definition) { + if ($temporary_table_mapping->requiresDedicatedTableStorage($field_storage_definition)) { + $schema = $this->getDedicatedTableSchema($field_storage_definition); + + // Filter out tables which are not part of the table mapping. + $schema = array_intersect_key($schema, $temporary_table_names); + foreach ($schema as $table_name => $table_schema) { + $temp_table_name = $temporary_table_names[$table_name]; + if (!$schema_handler->tableExists($temp_table_name)) { + $schema_handler->createTable($temp_table_name, $table_schema); + } + } + } + } + } + + /** + * {@inheritdoc} + */ + protected function postUpdateEntityTypeSchema(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + /** @var \Drupal\Core\Entity\Sql\TableMappingInterface $temporary_table_mapping */ + $temporary_table_mapping = $sandbox['temporary_storage']->getTableMapping(); + /** @var \Drupal\Core\Entity\Sql\TableMappingInterface $original_table_mapping */ + $original_table_mapping = $sandbox['original_storage']->getTableMapping(); + /** @var \Drupal\Core\Entity\Sql\TableMappingInterface $new_table_mapping */ + $new_table_mapping = $sandbox['new_table_mapping']; + /** @var \Drupal\Core\Entity\Sql\TableMappingInterface $backup_table_mapping */ + $backup_table_mapping = $sandbox['backup_table_mapping']; + + // Rename the original tables so we can put them back in place in case + // anything goes wrong. + $backup_table_names = array_combine($original_table_mapping->getTableNames(), $backup_table_mapping->getTableNames()); + $renamed_tables = []; + try { + foreach ($backup_table_names as $original_table_name => $backup_table_name) { + $this->database->schema()->renameTable($original_table_name, $backup_table_name); + $renamed_tables[$original_table_name] = $backup_table_name; + } + } + catch (\Exception $e) { + foreach ($renamed_tables as $original_table_name => $backup_table_name) { + $this->database->schema()->renameTable($backup_table_name, $original_table_name); + } + + // Re-throw the original exception. + throw $e; + } + + // Put the new tables in place and update the entity type and field storage + // definitions. + try { + $table_name_mapping = array_combine($temporary_table_mapping->getTableNames(), $new_table_mapping->getTableNames()); + foreach ($table_name_mapping as $temp_table_name => $current_table_name) { + $this->database->schema()->renameTable($temp_table_name, $current_table_name); + } + + // Store the updated entity schema. + $entity_schema = $this->getEntitySchema($entity_type, TRUE); + $this->saveEntitySchemaData($entity_type, $entity_schema); + + // Store the updated field schema for each field storage. + foreach ($field_storage_definitions as $field_storage_definition) { + if ($new_table_mapping->requiresDedicatedTableStorage($field_storage_definition)) { + $this->createDedicatedTableSchema($field_storage_definition, TRUE); + } + elseif ($new_table_mapping->allowsSharedTableStorage($field_storage_definition)) { + // The shared tables are already fully created, but we need to save the + // per-field schema definitions for later use. + $this->createSharedTableSchema($field_storage_definition, TRUE); + } + } + } + catch (\Exception $e) { + // Something went wrong, bring back the original tables. + foreach ($backup_table_names as $original_table_name => $backup_table_name) { + // We are in the 'original data recovery' phase, so we need to be sure + // that the initial tables can be properly restored. + if ($this->database->schema()->tableExists($original_table_name)) { + $this->database->schema()->dropTable($original_table_name); + } + + $this->database->schema()->renameTable($backup_table_name, $original_table_name); + } + + // Re-throw the original exception. + throw $e; + } + + // At this point the update process either finished successfully or any + // error has been thrown already, so we can drop the backup entity tables. + foreach ($backup_table_names as $original_table_name => $backup_table_name) { + $this->database->schema()->dropTable($backup_table_name); + } + } + + /** + * {@inheritdoc} + */ + protected function handleEntityTypeSchemaUpdateExceptionOnDataCopy(EntityTypeInterface $entity_type, EntityTypeInterface $original, array &$sandbox) { + // In case of an error during the save process, we need to clean up the + // temporary tables. + /** @var \Drupal\Core\Entity\Sql\TableMappingInterface $temporary_table_mapping */ + $temporary_table_mapping = $sandbox['temporary_storage']->getTableMapping(); + foreach ($temporary_table_mapping->getTableNames() as $table_name) { + $this->database->schema()->dropTable($table_name); + } + } + + /** + * Gets a string to be used as a prefix for a temporary table mapping object. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * An entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $field_storage_definitions + * An array of field storage definitions. + * + * @return string + * A temporary table mapping prefix. + * + * @internal + */ + public static function getTemporaryTableMappingPrefix(EntityTypeInterface $entity_type, array $field_storage_definitions) { + // Construct a unique prefix based on the contents of the entity type and + // field storage definitions. + $prefix_parts[] = spl_object_hash($entity_type); + foreach ($field_storage_definitions as $storage_definition) { + $prefix_parts[] = spl_object_hash($storage_definition); + } + $prefix_parts[] = \Drupal::time()->getRequestTime(); + $hash = hash('sha256', implode('', $prefix_parts)); + + return 'tmp_' . substr($hash, 0, 6); + } + /** * {@inheritdoc} */ @@ -657,8 +840,6 @@ protected function checkEntityType(EntityTypeInterface $entity_type) { * @return array * A Schema API array describing the entity schema, excluding dedicated * field tables. - * - * @throws \Drupal\Core\Field\FieldException */ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) { $this->checkEntityType($entity_type); @@ -666,7 +847,9 @@ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $res if (!isset($this->schema[$entity_type_id]) || $reset) { // Prepare basic information about the entity type. - $tables = $this->getEntitySchemaTables(); + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $this->storage->getCustomTableMapping($entity_type, $this->fieldStorageDefinitions); + $tables = $this->getEntitySchemaTables($table_mapping); // Initialize the table schema. $schema[$tables['base_table']] = $this->initializeBaseTable($entity_type); @@ -681,7 +864,6 @@ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $res } // We need to act only on shared entity schema tables. - $table_mapping = $this->storage->getCustomTableMapping($entity_type, $this->fieldStorageDefinitions); $table_names = array_diff($table_mapping->getTableNames(), $table_mapping->getDedicatedTableNames()); foreach ($table_names as $table_name) { if (!isset($schema[$table_name])) { @@ -739,15 +921,19 @@ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $res /** * Gets a list of entity type tables. * + * @param \Drupal\Core\Entity\Sql\TableMappingInterface $table_mapping + * A table mapping object. + * * @return array * A list of entity type tables, keyed by table key. */ - protected function getEntitySchemaTables() { + protected function getEntitySchemaTables(TableMappingInterface $table_mapping) { + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ return array_filter([ - 'base_table' => $this->storage->getBaseTable(), - 'revision_table' => $this->storage->getRevisionTable(), - 'data_table' => $this->storage->getDataTable(), - 'revision_data_table' => $this->storage->getRevisionDataTable(), + 'base_table' => $table_mapping->getBaseTable(), + 'revision_table' => $table_mapping->getRevisionTable(), + 'data_table' => $table_mapping->getDataTable(), + 'revision_data_table' => $table_mapping->getRevisionDataTable(), ]); } @@ -1293,14 +1479,22 @@ protected function performFieldSchemaOperation($operation, FieldStorageDefinitio * * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage_definition * The storage definition of the field being created. + * @param bool $only_save + * (optional) Whether to skip modification of database tables and only save + * the schema data for future comparison. For internal use only. This is + * used by postUpdateEntityTypeSchema() after it has already fully created + * the dedicated tables. */ - protected function createDedicatedTableSchema(FieldStorageDefinitionInterface $storage_definition) { + protected function createDedicatedTableSchema(FieldStorageDefinitionInterface $storage_definition, $only_save = FALSE) { $schema = $this->getDedicatedTableSchema($storage_definition); - foreach ($schema as $name => $table) { - // Check if the table exists because it might already have been - // created as part of the earlier entity type update event. - if (!$this->database->schema()->tableExists($name)) { - $this->database->schema()->createTable($name, $table); + + if (!$only_save) { + foreach ($schema as $name => $table) { + // Check if the table exists because it might already have been + // created as part of the earlier entity type update event. + if (!$this->database->schema()->tableExists($name)) { + $this->database->schema()->createTable($name, $table); + } } } $this->saveFieldSchemaData($storage_definition, $schema); @@ -1956,8 +2150,8 @@ protected function addSharedTableFieldForeignKey(FieldStorageDefinitionInterface * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage_definition * The field storage definition. * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type - * (optional) The entity type definition. Defaults to the one returned by - * the entity manager. + * (optional) The entity type definition. Defaults to the one provided by + * the entity storage. * * @return array * The schema definition for the table with the following keys: @@ -1972,10 +2166,11 @@ protected function addSharedTableFieldForeignKey(FieldStorageDefinitionInterface * @see hook_schema() */ protected function getDedicatedTableSchema(FieldStorageDefinitionInterface $storage_definition, ContentEntityTypeInterface $entity_type = NULL) { + $entity_type = $entity_type ?: $this->entityType; $description_current = "Data storage for {$storage_definition->getTargetEntityTypeId()} field {$storage_definition->getName()}."; $description_revision = "Revision archive storage for {$storage_definition->getTargetEntityTypeId()} field {$storage_definition->getName()}."; - $id_definition = $this->fieldStorageDefinitions[$this->entityType->getKey('id')]; + $id_definition = $this->fieldStorageDefinitions[$entity_type->getKey('id')]; if ($id_definition->getType() == 'integer') { $id_schema = [ 'type' => 'int', @@ -1994,11 +2189,11 @@ protected function getDedicatedTableSchema(FieldStorageDefinitionInterface $stor } // Define the revision ID schema. - if (!$this->entityType->isRevisionable()) { + if (!$entity_type->isRevisionable()) { $revision_id_schema = $id_schema; $revision_id_schema['description'] = 'The entity revision id this data is attached to, which for an unversioned entity type is the same as the entity id'; } - elseif ($this->fieldStorageDefinitions[$this->entityType->getKey('revision')]->getType() == 'integer') { + elseif ($this->fieldStorageDefinitions[$entity_type->getKey('revision')]->getType() == 'integer') { $revision_id_schema = [ 'type' => 'int', 'unsigned' => TRUE, @@ -2122,7 +2317,6 @@ protected function getDedicatedTableSchema(FieldStorageDefinitionInterface $stor $dedicated_table_schema = [$table_mapping->getDedicatedDataTableName($storage_definition) => $data_schema]; // If the entity type is revisionable, construct the revision table. - $entity_type = $entity_type ?: $this->entityType; if ($entity_type->isRevisionable()) { $revision_schema = $data_schema; $revision_schema['description'] = $description_revision; diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlFieldableEntityTypeListenerTrait.php b/core/lib/Drupal/Core/Entity/Sql/SqlFieldableEntityTypeListenerTrait.php new file mode 100644 index 0000000000..0dd808e9c2 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Sql/SqlFieldableEntityTypeListenerTrait.php @@ -0,0 +1,241 @@ +entityManager->createHandlerInstance($original->getStorageClass(), $original); + $has_data = $original_storage->hasData(); + + // We cannot support updating the schema of an entity type from revisionable + // to non-revisionable or translatable to non-translatable because that + // implies data loss. + $convert_rev_to_non_rev = $original->isRevisionable() && !$entity_type->isRevisionable(); + $convert_mul_to_non_mul = $original->isTranslatable() && !$entity_type->isTranslatable(); + if ($has_data && ($convert_rev_to_non_rev || $convert_mul_to_non_mul)) { + throw new EntityStorageException('Converting an entity type from revisionable to non-revisionable or from translatable to non-translatable is not supported.'); + } + + // Check that the fields required by a revisionable entity type exist. + if ($entity_type->isRevisionable() && !isset($field_storage_definitions[$entity_type->getKey('revision')])) { + throw new EntityStorageException('Missing revision field.'); + } + if ($entity_type->isRevisionable() && !isset($field_storage_definitions[$entity_type->getRevisionMetadataKey('revision_default')])) { + throw new EntityStorageException('Missing revision_default field.'); + } + + // Check that the fields required by a translatable entity type exist. + if ($entity_type->isTranslatable() && !isset($field_storage_definitions[$entity_type->getKey('langcode')])) { + throw new EntityStorageException('Missing langcode field.'); + } + if ($entity_type->isTranslatable() && !isset($field_storage_definitions[$entity_type->getKey('default_langcode')])) { + throw new EntityStorageException('Missing default_langcode field.'); + } + + // Check that the fields required by a revisionable and translatable entity + // type exist. + if ($entity_type->isRevisionable() && $entity_type->isTranslatable() && !isset($field_storage_definitions[$entity_type->getKey('revision_translation_affected')])) { + throw new EntityStorageException('Missing revision_translation_affected field.'); + } + + // If 'progress' is not set, then this will be the first run of the batch. + if (!isset($sandbox['progress'])) { + $sandbox['original_storage'] = $original_storage; + $sandbox['temporary_storage'] = $this->entityManager->createHandlerInstance($entity_type->getStorageClass(), $entity_type); + + $this->preUpdateEntityTypeSchema($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + + // Copy data from the original storage to the temporary one. + if ($has_data) { + $this->copyData($entity_type, $original, $sandbox); + } + else { + // If there is no existing data, we still need to run the + // post-schema-update tasks. + $sandbox['#finished'] = 1; + } + + // If the data copying has finished successfully, allow the storage schema + // to do any required cleanup tasks. For example, this process should take + // care of transforming the temporary storage into the current storage. + if ($sandbox['#finished'] === 1) { + $this->postUpdateEntityTypeSchema($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + } + + /** + * Allows subscribers to prepare their schema before data copying. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $field_storage_definitions + * The updated field storage definitions, including possibly new ones. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $original_field_storage_definitions + * The original field storage definitions. + * @param array &$sandbox + * (optional) A sandbox array provided by a hook_update_N() implementation + * or a Batch API callback. If the entity schema update requires a data + * migration, this parameter is mandatory. Defaults to NULL. + */ + protected function preUpdateEntityTypeSchema(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + } + + /** + * Allows subscribers to do any cleanup necessary after data copying. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $field_storage_definitions + * The updated field storage definitions, including possibly new ones. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $original_field_storage_definitions + * The original field storage definitions. + * @param array &$sandbox + * (optional) A sandbox array provided by a hook_update_N() implementation + * or a Batch API callback. If the entity schema update requires a data + * migration, this parameter is mandatory. Defaults to NULL. + */ + protected function postUpdateEntityTypeSchema(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + } + + /** + * Copies entity data from the original storage to the temporary one. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * @param array &$sandbox + * The sandbox array from a hook_update_N() implementation. + */ + protected function copyData(EntityTypeInterface $entity_type, EntityTypeInterface $original, array &$sandbox) { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $id_key = $entity_type->getKey('id'); + $revision_id_key = $entity_type->getKey('revision'); + $revision_default_key = $entity_type->getRevisionMetadataKey('revision_default'); + $langcode_key = $entity_type->getKey('langcode'); + $default_langcode_key = $entity_type->getKey('default_langcode'); + $revision_translation_affected_key = $entity_type->getKey('revision_translation_affected'); + + $temporary_storage = $sandbox['temporary_storage']; + $original_storage = $sandbox['original_storage']; + + // If 'progress' is not set, then this will be the first run of the batch. + if (!isset($sandbox['progress'])) { + $sandbox['progress'] = 0; + $sandbox['current_id'] = -1; + } + + // If the original entity type is revisionable, we need to copy all the + // revisions. + $load_revisions = $original->isRevisionable(); + if ($load_revisions) { + $table_name = $original->getRevisionTable(); + $identifier_field = $revision_id_key; + } + else { + $table_name = $original->getBaseTable(); + $identifier_field = $id_key; + } + + // Get the next entity identifiers to migrate. + // @todo Use an entity query when it is able to use the last installed + // entity type and field storage definitions. + // @see https://www.drupal.org/project/drupal/issues/2554235 + $step_size = Settings::get('entity_update_batch_size', 50); + $entity_identifiers = $this->database->select($table_name, 't') + ->condition("t.$identifier_field", $sandbox['current_id'], '>') + ->fields('t', [$identifier_field]) + ->orderBy($identifier_field, 'ASC') + ->range(0, $step_size) + ->execute() + ->fetchCol(); + + /** @var \Drupal\Core\Entity\ContentEntityInterface[] $entities */ + $entities = $load_revisions ? $original_storage->loadMultipleRevisions($entity_identifiers) : $original_storage->loadMultiple($entity_identifiers); + foreach ($entities as $identifier => $entity) { + try { + if (!$original->isRevisionable() && $entity_type->isRevisionable()) { + // Set the revision ID to be same as the entity ID. + $entity->set($revision_id_key, $entity->id()); + + // We had no revisions so far, so the existing data belongs to the + // default revision now. + $entity->set($revision_default_key, TRUE); + } + + // Set the 'langcode' and 'default_langcode' values as needed. + if (!$original->isTranslatable() && $entity_type->isTranslatable()) { + if ($entity->get($langcode_key)->isEmpty()) { + $entity->set($langcode_key, \Drupal::languageManager()->getDefaultLanguage()->getId()); + } + + $entity->set($default_langcode_key, TRUE); + } + + // Set the 'revision_translation_affected' field to TRUE to match the + // return value of the case when the field does not exist. + if ((!$original->isRevisionable() || !$original->isTranslatable()) && $entity_type->isRevisionable() && $entity_type->isTranslatable()) { + $entity->set($revision_translation_affected_key, TRUE); + } + + // Finally, save the entity in the temporary storage. + $temporary_storage->restore($entity); + } + catch (\Exception $e) { + $this->handleEntityTypeSchemaUpdateExceptionOnDataCopy($entity_type, $original, $sandbox); + + // Re-throw the original exception with a helpful message. + $error_revision_id = $load_revisions ? ", revision ID: {$entity->getLoadedRevisionId()}" : ''; + throw new EntityStorageException("The entity update process failed while processing the entity type {$entity_type->id()}, ID: {$entity->id()}$error_revision_id.", $e->getCode(), $e); + } + + $sandbox['progress']++; + $sandbox['current_id'] = $identifier; + } + + // Get an updated count of entities that still need to migrated to the new + // storage. + $missing = $this->database->select($table_name, 't') + ->condition("t.$identifier_field", $sandbox['current_id'], '>') + ->orderBy($identifier_field, 'ASC') + ->countQuery() + ->execute() + ->fetchField(); + $sandbox['#finished'] = $missing ? $sandbox['progress'] / ($sandbox['progress'] + (int) $missing) : 1; + } + + /** + * Handles the case when an error occurs during the data copying step. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The updated entity type definition. + * @param \Drupal\Core\Entity\EntityTypeInterface $original + * The original entity type definition. + * @param array &$sandbox + * The sandbox array from a hook_update_N() implementation. + */ + protected function handleEntityTypeSchemaUpdateExceptionOnDataCopy(EntityTypeInterface $entity_type, EntityTypeInterface $original, array &$sandbox) { + } + +} diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php index 9c53665ab5..bd2e129f29 100644 --- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php +++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldType/ChangedItem.php @@ -44,7 +44,7 @@ public function preSave() { /** @var \Drupal\Core\Entity\ContentEntityInterface $original */ $original = $entity->original; $langcode = $entity->language()->getId(); - if (!$entity->isNew() && $original->hasTranslation($langcode)) { + if (!$entity->isNew() && $original && $original->hasTranslation($langcode)) { $original_value = $original->getTranslation($langcode)->get($this->getFieldDefinition()->getName())->value; if ($this->value == $original_value && $entity->hasTranslationChanges()) { $this->value = REQUEST_TIME; diff --git a/core/modules/system/tests/modules/entity_test/src/EntityTestDefinitionSubscriber.php b/core/modules/system/tests/modules/entity_test/src/EntityTestDefinitionSubscriber.php index 2b06d80bd6..6cdceb6d9c 100644 --- a/core/modules/system/tests/modules/entity_test/src/EntityTestDefinitionSubscriber.php +++ b/core/modules/system/tests/modules/entity_test/src/EntityTestDefinitionSubscriber.php @@ -63,6 +63,13 @@ public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeI $this->storeEvent(EntityTypeEvents::UPDATE); } + /** + * {@inheritdoc} + */ + public function onFieldableEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original, array $field_storage_definitions, array $original_field_storage_definitions, array &$sandbox = NULL) { + $this->storeEvent(EntityTypeEvents::UPDATE); + } + /** * {@inheritdoc} */ diff --git a/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php index e873e096f0..2633beb929 100644 --- a/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php +++ b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\system\Functional\Entity\Traits; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\entity_test\FieldStorageDefinition; /** @@ -296,4 +297,127 @@ protected function deleteEntityType() { $this->state->set('entity_test_update.entity_type', 'null'); } + /** + * Returns an entity type definition, possibly updated to be rev or mul. + * + * @param bool $revisionable + * (optional) Whether the entity type should be revisionable or not. + * Defaults toFALSE. + * @param bool $translatable + * (optional) Whether the entity type should be translatable or not. + * Defaults to FALSE. + * + * @return \Drupal\Core\Entity\EntityTypeInterface + * An entity type definition. + */ + protected function getUpdatedEntityTypeDefinition($revisionable = FALSE, $translatable = FALSE) { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + if ($revisionable) { + $keys = $entity_type->getKeys(); + $keys['revision'] = 'revision_id'; + $entity_type->set('entity_keys', $keys); + $entity_type->set('revision_table', 'entity_test_update_revision'); + } + else { + $keys = $entity_type->getKeys(); + $keys['revision'] = ''; + $entity_type->set('entity_keys', $keys); + $entity_type->set('revision_table', NULL); + } + + if ($translatable) { + $entity_type->set('translatable', TRUE); + $entity_type->set('data_table', 'entity_test_update_data'); + } + else { + $entity_type->set('translatable', FALSE); + $entity_type->set('data_table', NULL); + } + + if ($revisionable && $translatable) { + $entity_type->set('revision_data_table', 'entity_test_update_revision_data'); + } + else { + $entity_type->set('revision_data_table', NULL); + } + + $this->state->set('entity_test_update.entity_type', $entity_type); + $this->entityManager->clearCachedDefinitions(); + + return $entity_type; + } + + /** + * Returns the required rev / mul field definitions for an entity type. + * + * @param bool $revisionable + * (optional) Whether the entity type should be revisionable or not. + * Defaults toFALSE. + * @param bool $translatable + * (optional) Whether the entity type should be translatable or not. + * Defaults to FALSE. + * + * @return \Drupal\Core\Field\FieldStorageDefinitionInterface[] + * An array of field storage definition objects. + */ + protected function getUpdatedFieldStorageDefinitions($revisionable = FALSE, $translatable = FALSE) { + $field_storage_definitions = $this->entityManager->getFieldStorageDefinitions('entity_test_update'); + + if ($revisionable) { + // The 'langcode' is already available for the 'entity_test_update' entity + // type because it has the 'langcode' entity key defined. + $field_storage_definitions['langcode']->setRevisionable(TRUE); + + $field_storage_definitions['revision_id'] = BaseFieldDefinition::create('integer') + ->setName('revision_id') + ->setTargetEntityTypeId('entity_test_update') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision ID')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $field_storage_definitions['revision_default'] = BaseFieldDefinition::create('boolean') + ->setName('revision_default') + ->setTargetEntityTypeId('entity_test_update') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Default revision')) + ->setDescription(new TranslatableMarkup('A flag indicating whether this was a default revision when it was saved.')) + ->setStorageRequired(TRUE) + ->setInternal(TRUE) + ->setTranslatable(FALSE) + ->setRevisionable(TRUE); + } + + if ($translatable) { + // The 'langcode' is already available for the 'entity_test_update' entity + // type because it has the 'langcode' entity key defined. + $field_storage_definitions['langcode']->setTranslatable(TRUE); + + $field_storage_definitions['default_langcode'] = BaseFieldDefinition::create('boolean') + ->setName('default_langcode') + ->setTargetEntityTypeId('entity_test_update') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Default translation')) + ->setDescription(new TranslatableMarkup('A flag indicating whether this is the default translation.')) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->setDefaultValue(TRUE); + } + + if ($revisionable && $translatable) { + $field_storage_definitions['revision_translation_affected'] = BaseFieldDefinition::create('boolean') + ->setName('revision_translation_affected') + ->setTargetEntityTypeId('entity_test_update') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision translation affected')) + ->setDescription(new TranslatableMarkup('Indicates if the last edit of a translation belongs to current revision.')) + ->setReadOnly(TRUE) + ->setRevisionable(TRUE) + ->setTranslatable(TRUE); + } + + return $field_storage_definitions; + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/FieldableEntityDefinitionUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/FieldableEntityDefinitionUpdateTest.php new file mode 100644 index 0000000000..3c764d6095 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/FieldableEntityDefinitionUpdateTest.php @@ -0,0 +1,480 @@ +entityDefinitionUpdateManager = $this->container->get('entity.definition_update_manager'); + $this->entityTypeManager = $this->container->get('entity_type.manager'); + $this->entityFieldManager = $this->container->get('entity_field.manager'); + $this->database = $this->container->get('database'); + + // The 'changed' field type has a special behavior because it updates itself + // automatically if any of the other field values of an entity have been + // updated, so add it to the entity type that is being tested in order to + // provide test coverage for this special case. + $fields['changed'] = BaseFieldDefinition::create('changed') + ->setLabel(t('Changed')) + ->setDescription(t('The time that the custom block was last edited.')) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE); + $this->state->set('entity_test_update.additional_base_field_definitions', $fields); + + $this->installEntitySchema($this->entityTypeId); + $this->installEntitySchema('configurable_language'); + + // Enable an additional language. + ConfigurableLanguage::createFromLangcode('ro')->save(); + } + + /** + * @covers ::updateFieldableEntityType + * @dataProvider providerTestFieldableEntityTypeUpdates + */ + public function testFieldableEntityTypeUpdates($initial_rev, $initial_mul, $new_rev, $new_mul, $data_migration_supported) { + // The 'entity_test_update' entity type is neither revisionable nor + // translatable by default, so we need to get it into the initial testing + // state. This also covers the "no existing data" scenario for fieldable + // entity type updates. + if ($initial_rev || $initial_mul) { + $entity_type = $this->getUpdatedEntityTypeDefinition($initial_rev, $initial_mul); + $field_storage_definitions = $this->getUpdatedFieldStorageDefinitions($initial_rev, $initial_mul); + + $this->entityDefinitionUpdateManager->updateFieldableEntityType($entity_type, $field_storage_definitions); + $this->assertEntityTypeSchema($initial_rev, $initial_mul); + } + + // Add a few entities so we can test the data copying step. + $this->insertInitialData($initial_rev, $initial_mul); + + $updated_entity_type = $this->getUpdatedEntityTypeDefinition($new_rev, $new_mul); + $updated_field_storage_definitions = $this->getUpdatedFieldStorageDefinitions($new_rev, $new_mul); + + if (!$data_migration_supported) { + $this->setExpectedException(EntityStorageException::class, 'Converting an entity type from revisionable to non-revisionable or from translatable to non-translatable is not supported.'); + } + + $sandbox = []; + $this->entityDefinitionUpdateManager->updateFieldableEntityType($updated_entity_type, $updated_field_storage_definitions, $sandbox); + $this->assertEntityTypeSchema($new_rev, $new_mul); + $this->assertEntityData($initial_rev, $initial_mul); + } + + /** + * Data provider for testFieldableEntityTypeUpdates(). + */ + public function providerTestFieldableEntityTypeUpdates() { + return [ + 'no change' => [ + 'initial_rev' => FALSE, + 'initial_mul' => FALSE, + 'new_rev' => FALSE, + 'new_mul' => FALSE, + 'data_migration_supported' => TRUE, + ], + 'non_rev non_mul to rev non_mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => FALSE, + 'new_rev' => TRUE, + 'new_mul' => FALSE, + 'data_migration_supported' => TRUE, + ], + 'non_rev non_mul to rev mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => FALSE, + 'new_rev' => TRUE, + 'new_mul' => TRUE, + 'data_migration_supported' => TRUE, + ], + 'non_rev non_mul to non_rev mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => FALSE, + 'new_rev' => FALSE, + 'new_mul' => TRUE, + 'data_migration_supported' => TRUE, + ], + 'rev non_mul to non_rev non_mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => FALSE, + 'new_rev' => FALSE, + 'new_mul' => FALSE, + 'data_migration_supported' => FALSE, + ], + 'rev non_mul to non_rev mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => FALSE, + 'new_rev' => FALSE, + 'new_mul' => TRUE, + 'data_migration_supported' => FALSE, + ], + 'rev non_mul to rev mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => FALSE, + 'new_rev' => TRUE, + 'new_mul' => TRUE, + 'data_migration_supported' => TRUE, + ], + 'non_rev mul to non_rev non_mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => TRUE, + 'new_rev' => FALSE, + 'new_mul' => FALSE, + 'data_migration_supported' => FALSE, + ], + 'non_rev mul to rev non_mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => TRUE, + 'new_rev' => TRUE, + 'new_mul' => FALSE, + 'data_migration_supported' => FALSE, + ], + 'non_rev mul to rev mul' => [ + 'initial_rev' => FALSE, + 'initial_mul' => TRUE, + 'new_rev' => TRUE, + 'new_mul' => TRUE, + 'data_migration_supported' => TRUE, + ], + 'rev mul to non_rev non_mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => TRUE, + 'new_rev' => FALSE, + 'new_mul' => FALSE, + 'data_migration_supported' => FALSE, + ], + 'rev mul to rev non_mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => TRUE, + 'new_rev' => TRUE, + 'new_mul' => FALSE, + 'data_migration_supported' => FALSE, + ], + 'rev mul to non_rev mul' => [ + 'initial_rev' => TRUE, + 'initial_mul' => TRUE, + 'new_rev' => FALSE, + 'new_mul' => TRUE, + 'data_migration_supported' => FALSE, + ], + ]; + } + + /** + * Generates test entities for the 'entity_test_update' entity type. + * + * @param bool $revisionable + * Whether the entity type is revisionable or not. + * @param bool $translatable + * Whether the entity type is translatable or not. + */ + protected function insertInitialData($revisionable, $translatable) { + // Add three test entities in order to make the "data copy" step run at + // least three times. + /** @var \Drupal\Core\Entity\TranslatableRevisionableStorageInterface|\Drupal\Core\Entity\EntityStorageInterface $storage */ + $storage = $this->entityTypeManager->getStorage($this->entityTypeId); + + // Create test entities with two translations and two revisions. + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + for ($i = 1; $i <= 3; $i++) { + $entity = $storage->create([ + 'id' => $i, + 'name' => 'test entity - ' . $i . ' - en', + 'test_multiple_properties_multiple_values' => [ + 'value1' => 'dedicated table - ' . $i . ' - value 1 - en', + 'value2' => 'dedicated table - ' . $i . ' - value 2 - en', + ], + ]); + $entity->save(); + + if ($translatable) { + $translation = $entity->addTranslation('ro', [ + 'name' => 'test entity - ' . $i . ' - ro', + 'test_multiple_properties_multiple_values' => [ + 'value1' => 'dedicated table - ' . $i . ' - value 1 - ro', + 'value2' => 'dedicated table - ' . $i . ' - value 2 - ro', + ], + ]); + $translation->save(); + } + + if ($revisionable) { + // Create a new pending revision. + $revision_2 = $storage->createRevision($entity, FALSE); + $revision_2->name = 'test entity - ' . $i . ' - en - rev2'; + $revision_2->test_multiple_properties_multiple_values->value1 = 'dedicated table - ' . $i . ' - value 1 - en - rev2'; + $revision_2->test_multiple_properties_multiple_values->value2 = 'dedicated table - ' . $i . ' - value 2 - en - rev2'; + $revision_2->save(); + + if ($translatable) { + $revision_2_translation = $storage->createRevision($entity->getTranslation('ro'), FALSE); + $revision_2_translation->name = 'test entity - ' . $i . ' - ro - rev2'; + $revision_2_translation->test_multiple_properties_multiple_values->value1 = 'dedicated table - ' . $i . ' - value 1 - ro - rev2'; + $revision_2_translation->test_multiple_properties_multiple_values->value2 = 'dedicated table - ' . $i . ' - value 2 - ro - rev2'; + $revision_2_translation->save(); + } + } + } + } + + /** + * Asserts test entity data after a fieldable entity type update. + * + * @param bool $revisionable + * Whether the entity type was revisionable prior to the update. + * @param bool $translatable + * Whether the entity type was translatable prior to the update. + */ + protected function assertEntityData($revisionable, $translatable) { + $entities = $this->entityTypeManager->getStorage($this->entityTypeId)->loadMultiple(); + $this->assertCount(3, $entities); + foreach ($entities as $entity_id => $entity) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $this->assertEquals("test entity - {$entity->id()} - en", $entity->label()); + $this->assertEquals("dedicated table - {$entity->id()} - value 1 - en", $entity->test_multiple_properties_multiple_values->value1); + $this->assertEquals("dedicated table - {$entity->id()} - value 2 - en", $entity->test_multiple_properties_multiple_values->value2); + + if ($translatable) { + $translation = $entity->getTranslation('ro'); + $this->assertEquals("test entity - {$entity->id()} - ro", $translation->label()); + $this->assertEquals("dedicated table - {$translation->id()} - value 1 - ro", $translation->test_multiple_properties_multiple_values->value1); + $this->assertEquals("dedicated table - {$translation->id()} - value 2 - ro", $translation->test_multiple_properties_multiple_values->value2); + } + } + + if ($revisionable) { + $revisions_result = $this->entityTypeManager->getStorage($this->entityTypeId)->getQuery()->allRevisions()->execute(); + $revisions = $this->entityTypeManager->getStorage($this->entityTypeId)->loadMultipleRevisions(array_keys($revisions_result)); + $this->assertCount(6, $revisions); + + foreach ($revisions as $revision) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + $revision_label = $revision->isDefaultRevision() ? NULL : ' - rev2'; + $this->assertEquals("test entity - {$revision->id()} - en{$revision_label}", $revision->label()); + $this->assertEquals("dedicated table - {$revision->id()} - value 1 - en{$revision_label}", $revision->test_multiple_properties_multiple_values->value1); + $this->assertEquals("dedicated table - {$revision->id()} - value 2 - en{$revision_label}", $revision->test_multiple_properties_multiple_values->value2); + + if ($translatable) { + $translation = $revision->getTranslation('ro'); + $this->assertEquals("test entity - {$translation->id()} - ro{$revision_label}", $translation->label()); + $this->assertEquals("dedicated table - {$translation->id()} - value 1 - ro{$revision_label}", $translation->test_multiple_properties_multiple_values->value1); + $this->assertEquals("dedicated table - {$translation->id()} - value 2 - ro{$revision_label}", $translation->test_multiple_properties_multiple_values->value2); + } + } + } + } + + /** + * Asserts revisionable and/or translatable characteristics of an entity type. + * + * @param bool $revisionable + * Whether the entity type is revisionable or not. + * @param bool $translatable + * Whether the entity type is translatable or not. + */ + protected function assertEntityTypeSchema($revisionable, $translatable) { + if ($revisionable && $translatable) { + $this->assertRevisionableAndTranslatable(); + } + elseif ($revisionable) { + $this->assertRevisionable(); + } + elseif ($translatable) { + $this->assertTranslatable(); + } + else { + $this->assertNonRevisionableAndNonTranslatable(); + } + } + + /** + * Asserts the revisionable characteristics of an entity type. + */ + protected function assertRevisionable() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->entityDefinitionUpdateManager->getEntityType($this->entityTypeId); + $this->assertTrue($entity_type->isRevisionable()); + + // Check that the required field definitions of a revisionable entity type + // exists and are stored in the correct tables. + $revision_key = $entity_type->getKey('revision'); + $revision_default_key = $entity_type->getRevisionMetadataKey('revision_default'); + $revision_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($revision_key, $entity_type->id()); + $revision_default_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($revision_default_key, $entity_type->id()); + $this->assertNotNull($revision_field); + $this->assertNotNull($revision_default_field); + + $database_schema = $this->database->schema(); + $base_table = $entity_type->getBaseTable(); + $revision_table = $entity_type->getRevisionTable(); + $this->assertTrue($database_schema->tableExists($revision_table)); + + $this->assertTrue($database_schema->fieldExists($base_table, $revision_key)); + $this->assertTrue($database_schema->fieldExists($revision_table, $revision_key)); + + $this->assertFalse($database_schema->fieldExists($base_table, $revision_default_key)); + $this->assertTrue($database_schema->fieldExists($revision_table, $revision_default_key)); + + // Also check the revision metadata keys, if they exist. + foreach (['revision_log_message', 'revision_user', 'revision_created'] as $key) { + if ($revision_metadata_key = $entity_type->getRevisionMetadataKey($key)) { + $revision_metadata_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($revision_metadata_key, $entity_type->id()); + $this->assertNotNull($revision_metadata_field); + $this->assertFalse($database_schema->fieldExists($base_table, $revision_metadata_key)); + $this->assertTrue($database_schema->fieldExists($revision_table, $revision_metadata_key)); + } + } + } + + /** + * Asserts the translatable characteristics of an entity type. + */ + protected function assertTranslatable() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->entityDefinitionUpdateManager->getEntityType($this->entityTypeId); + $this->assertTrue($entity_type->isTranslatable()); + + // Check that the required field definitions of a translatable entity type + // exists and are stored in the correct tables. + $langcode_key = $entity_type->getKey('langcode'); + $default_langcode_key = $entity_type->getKey('default_langcode'); + $langcode_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($langcode_key, $entity_type->id()); + $default_langcode_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($default_langcode_key, $entity_type->id()); + $this->assertNotNull($langcode_field); + $this->assertNotNull($default_langcode_field); + + $database_schema = $this->database->schema(); + $base_table = $entity_type->getBaseTable(); + $data_table = $entity_type->getDataTable(); + $this->assertTrue($database_schema->tableExists($data_table)); + + $this->assertTrue($database_schema->fieldExists($base_table, $langcode_key)); + $this->assertTrue($database_schema->fieldExists($data_table, $langcode_key)); + + $this->assertFalse($database_schema->fieldExists($base_table, $default_langcode_key)); + $this->assertTrue($database_schema->fieldExists($data_table, $default_langcode_key)); + } + + /** + * Asserts the revisionable / translatable characteristics of an entity type. + */ + protected function assertRevisionableAndTranslatable() { + $this->assertRevisionable(); + $this->assertTranslatable(); + + // Check that the required field definitions of a revisionable and + // translatable entity type exists and are stored in the correct tables. + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->entityDefinitionUpdateManager->getEntityType($this->entityTypeId); + $langcode_key = $entity_type->getKey('langcode'); + $revision_translation_affected_key = $entity_type->getKey('revision_translation_affected'); + $revision_translation_affected_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($revision_translation_affected_key, $entity_type->id()); + $this->assertNotNull($revision_translation_affected_field); + + $database_schema = $this->database->schema(); + $base_table = $entity_type->getBaseTable(); + $data_table = $entity_type->getDataTable(); + $revision_table = $entity_type->getRevisionTable(); + $revision_data_table = $entity_type->getRevisionDataTable(); + $this->assertTrue($database_schema->tableExists($revision_data_table)); + + $this->assertTrue($database_schema->fieldExists($base_table, $langcode_key)); + $this->assertTrue($database_schema->fieldExists($data_table, $langcode_key)); + $this->assertTrue($database_schema->fieldExists($revision_table, $langcode_key)); + $this->assertTrue($database_schema->fieldExists($revision_data_table, $langcode_key)); + + $this->assertFalse($database_schema->fieldExists($base_table, $revision_translation_affected_key)); + $this->assertFalse($database_schema->fieldExists($revision_table, $revision_translation_affected_key)); + $this->assertTrue($database_schema->fieldExists($data_table, $revision_translation_affected_key)); + $this->assertTrue($database_schema->fieldExists($revision_data_table, $revision_translation_affected_key)); + + // Also check the revision metadata keys, if they exist. + foreach (['revision_log_message', 'revision_user', 'revision_created'] as $key) { + if ($revision_metadata_key = $entity_type->getRevisionMetadataKey($key)) { + $revision_metadata_field = $this->entityDefinitionUpdateManager->getFieldStorageDefinition($revision_metadata_key, $entity_type->id()); + $this->assertNotNull($revision_metadata_field); + $this->assertFalse($database_schema->fieldExists($base_table, $revision_metadata_key)); + $this->assertTrue($database_schema->fieldExists($revision_table, $revision_metadata_key)); + $this->assertFalse($database_schema->fieldExists($data_table, $revision_metadata_key)); + $this->assertFalse($database_schema->fieldExists($revision_data_table, $revision_metadata_key)); + } + } + } + + /** + * Asserts that an entity type is neither revisionable nor translatable. + */ + protected function assertNonRevisionableAndNonTranslatable() { + /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type */ + $entity_type = $this->entityDefinitionUpdateManager->getEntityType($this->entityTypeId); + $this->assertFalse($entity_type->isRevisionable()); + $this->assertFalse($entity_type->isTranslatable()); + + $database_schema = $this->database->schema(); + $this->assertTrue($database_schema->tableExists($entity_type->getBaseTable())); + $this->assertFalse($database_schema->tableExists($entity_type->getDataTable())); + $this->assertFalse($database_schema->tableExists($entity_type->getRevisionTable())); + $this->assertFalse($database_schema->tableExists($entity_type->getRevisionDataTable())); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageSchemaTest.php b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageSchemaTest.php index cf18e2bb9c..c744e663f0 100644 --- a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageSchemaTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageSchemaTest.php @@ -409,7 +409,7 @@ public function testGetSchemaRevisionable() { ->method('getRevisionMetadataKeys') ->will($this->returnValue([])); - $this->storage->expects($this->exactly(2)) + $this->storage->expects($this->exactly(1)) ->method('getRevisionTable') ->will($this->returnValue('entity_test_revision')); @@ -505,6 +505,7 @@ public function testGetSchemaTranslatable() { 'id' => 'id', 'langcode' => 'langcode', ], + 'translatable' => TRUE, ]); $this->storage->expects($this->any()) @@ -622,24 +623,22 @@ public function testGetSchemaRevisionableTranslatable() { 'revision' => 'revision_id', 'langcode' => 'langcode', ], + 'revision_data_table' => 'entity_test_revision_field_data', ], ]) - ->setMethods(['getRevisionMetadataKeys']) + ->setMethods(['isRevisionable', 'isTranslatable', 'getRevisionMetadataKeys']) ->getMock(); $this->entityType->expects($this->any()) - ->method('getRevisionMetadataKeys') - ->will($this->returnValue([])); + ->method('isRevisionable') + ->will($this->returnValue(TRUE)); + $this->entityType->expects($this->any()) + ->method('isTranslatable') + ->will($this->returnValue(TRUE)); - $this->storage->expects($this->exactly(3)) + $this->storage->expects($this->exactly(2)) ->method('getRevisionTable') ->will($this->returnValue('entity_test_revision')); - $this->storage->expects($this->once()) - ->method('getDataTable') - ->will($this->returnValue('entity_test_field_data')); - $this->storage->expects($this->once()) - ->method('getRevisionDataTable') - ->will($this->returnValue('entity_test_revision_field_data')); $this->setUpStorageDefinition('revision_id', [ 'columns' => [