diff --git a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php index 85c1d09..0646e7b 100644 --- a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php +++ b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManager.php @@ -32,7 +32,7 @@ class EntityDefinitionUpdateManager implements EntityDefinitionUpdateManagerInte /** * The entity type listener service. * - * @var \Drupal\Core\Entity\EntityTypeListenerInterface + * @var \Drupal\Core\Entity\EntityTypeListenerInterface|\Drupal\Core\Entity\EntityTypeSchemaListenerInterface */ protected $entityTypeListener; @@ -244,6 +244,20 @@ public function uninstallEntityType(EntityTypeInterface $entity_type) { /** * {@inheritdoc} */ + public function updateEntityTypeSchema(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->onEntityTypeSchemaUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + } + + /** + * {@inheritdoc} + */ public function installFieldStorageDefinition($name, $entity_type_id, $provider, FieldStorageDefinitionInterface $storage_definition) { // @todo Pass a mutable field definition interface when we have one. See // https://www.drupal.org/node/2346329. @@ -443,6 +457,22 @@ protected function requiresFieldStorageSchemaChanges(FieldStorageDefinitionInter } /** + * 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. */ protected function clearCachedDefinitions() { diff --git a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php index a219f3c..3494b60 100644 --- a/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php +++ b/core/lib/Drupal/Core/Entity/EntityDefinitionUpdateManagerInterface.php @@ -145,6 +145,20 @@ public function updateEntityType(EntityTypeInterface $entity_type); public function uninstallEntityType(EntityTypeInterface $entity_type); /** + * Applies any schema change performed to the passed 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 updateEntityTypeSchema(EntityTypeInterface $entity_type, array $field_storage_definitions, array &$sandbox = NULL); + + /** * Returns a field storage definition ready to be manipulated. * * When needing to apply updates to existing field storage definitions, this diff --git a/core/lib/Drupal/Core/Entity/EntityTypeListener.php b/core/lib/Drupal/Core/Entity/EntityTypeListener.php index 4dc1e9c..c8b23f6 100644 --- a/core/lib/Drupal/Core/Entity/EntityTypeListener.php +++ b/core/lib/Drupal/Core/Entity/EntityTypeListener.php @@ -9,7 +9,7 @@ * * @see \Drupal\Core\Entity\EntityTypeEvents */ -class EntityTypeListener implements EntityTypeListenerInterface { +class EntityTypeListener implements EntityTypeListenerInterface, EntityTypeSchemaListenerInterface { /** * The entity type manager. @@ -115,4 +115,27 @@ public function onEntityTypeDelete(EntityTypeInterface $entity_type) { $this->entityLastInstalledSchemaRepository->deleteLastInstalledDefinition($entity_type_id); } + /** + * {@inheritdoc} + */ + public function onEntityTypeSchemaUpdate(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 EntityTypeSchemaListenerInterface) { + $storage->onEntityTypeSchemaUpdate($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/EntityTypeSchemaListenerInterface.php b/core/lib/Drupal/Core/Entity/EntityTypeSchemaListenerInterface.php new file mode 100644 index 0000000..d053104 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/EntityTypeSchemaListenerInterface.php @@ -0,0 +1,28 @@ +wrapSchemaException(function () use ($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, &$sandbox) { + $this->getStorageSchema()->onEntityTypeSchemaUpdate($entity_type, $original, $field_storage_definitions, $original_field_storage_definitions, $sandbox); + }); + } + + /** + * {@inheritdoc} + */ public function onFieldStorageDefinitionCreate(FieldStorageDefinitionInterface $storage_definition) { $this->wrapSchemaException(function () use ($storage_definition) { $this->getStorageSchema()->onFieldStorageDefinitionCreate($storage_definition); diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php index f4505bd..369e903 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php @@ -28,6 +28,7 @@ class SqlContentEntityStorageSchema implements DynamicallyFieldableEntityStorageSchemaInterface { use DependencySerializationTrait; + use SqlEntityTypeSchemaListenerTrait; /** * The entity manager. @@ -411,6 +412,138 @@ public function onEntityTypeDelete(EntityTypeInterface $entity_type) { /** * {@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_table_mapping = $temporary_storage->getCustomTableMapping($entity_type, $field_storage_definitions, 'tmp_'); + $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()); + foreach ($backup_table_names as $original_table_name => $backup_table_name) { + $this->database->schema()->renameTable($original_table_name, $backup_table_name); + } + + // 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); + } + } + + /** + * {@inheritdoc} + */ public function onFieldStorageDefinitionCreate(FieldStorageDefinitionInterface $storage_definition) { $this->performFieldSchemaOperation('create', $storage_definition); } @@ -657,8 +790,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 +797,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 +814,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 +871,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 +1429,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 +2100,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 +2116,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 +2139,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 +2267,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/SqlEntityTypeSchemaListenerTrait.php b/core/lib/Drupal/Core/Entity/Sql/SqlEntityTypeSchemaListenerTrait.php new file mode 100644 index 0000000..d6f840b --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Sql/SqlEntityTypeSchemaListenerTrait.php @@ -0,0 +1,247 @@ +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.'); + } + + $this->fieldStorageDefinitions = $field_storage_definitions; + + // 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); + } + + // Treat the entity as new in order to make the storage do an INSERT + // rather than an UPDATE. + $entity->enforceIsNew(TRUE); + + // Finally, save the entity in the temporary storage. + $temporary_storage->save($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; + } + + // If we're not in maintenance mode, the number of entities could change at + // any time so make sure that we always use the latest record count. + $missing = $this->database->select($table_name, 't') + ->condition("t.$identifier_field", $sandbox['current_id'], '>') + ->orderBy($identifier_field, 'ASC') + ->countQuery() + ->execute() + ->fetchField(); + $sandbox['#finished'] = $sandbox['progress'] / ($sandbox['progress'] + (int) $missing); + } + + /** + * 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/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionSchemaUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionSchemaUpdateTest.php new file mode 100644 index 0000000..451b462 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionSchemaUpdateTest.php @@ -0,0 +1,589 @@ +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'); + + $this->installEntitySchema($this->entityTypeId); + $this->installEntitySchema('configurable_language'); + + // Enable an additional language. + ConfigurableLanguage::createFromLangcode('ro')->save(); + } + + /** + * @covers ::updateEntityTypeSchema + * @dataProvider providerTestEntityTypeSchemaUpdates + */ + public function testEntityTypeSchemaUpdates($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 entity schema + // 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->updateEntityTypeSchema($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->updateEntityTypeSchema($updated_entity_type, $updated_field_storage_definitions, $sandbox); + $this->assertEntityTypeSchema($new_rev, $new_mul); + $this->assertEntityData($initial_rev, $initial_mul); + } + + /** + * Data provider for testEntityTypeSchemaUpdates(). + */ + public function providerTestEntityTypeSchemaUpdates() { + 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, + ], + // @todo Entity schema updates with data migration for revisionable entity + // types is not supported yet. + // 'rev non_mul to rev mul' => [], + '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 entity type schema 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 = $this->entityTypeManager->getStorage($this->entityTypeId)->loadMultipleRevisions(); + $this->assertCount(9, $revisions); + + foreach ($revisions as $revision) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + $revision_label = $revision->isDefaultRevision() ?: ' - 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); + } + } + } + } + + /** + * 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->entityTypeManager->getDefinition($this->entityTypeId); + + 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->entityTypeManager->clearCachedDefinitions(); + $this->entityFieldManager->clearCachedFieldDefinitions(); + + 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->entityFieldManager->getFieldStorageDefinitions($this->entityTypeId); + + 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($this->entityTypeId) + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision ID')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $field_storage_definitions['revision_default'] = BaseFieldDefinition::create('boolean') + ->setName('revision_default') + ->setTargetEntityTypeId($this->entityTypeId) + ->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($this->entityTypeId) + ->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($this->entityTypeId) + ->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; + } + + /** + * 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 cf18e2b..c744e66 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' => [