From f6fa46e5fb299435ef00d1ba1d592eafdae73a29 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Fri, 28 Apr 2017 08:31:59 -0400
Subject: [PATCH] Issue #2721313 by timmillwood, amateescu, dawehner, jeqq,
 jibran, plach, catch, jhedstrom: Upgrade path between revisionable /
 non-revisionable entities

---
 .../Entity/Sql/SqlContentEntityStorage.php    |  36 +-
 .../Sql/SqlContentEntityStorageSchema.php     |  16 +-
 ...SqlContentEntityStorageSchemaConverter.php | 461 ++++++++++++++++++
 .../Core/Entity/Sql/TemporaryTableMapping.php |  46 ++
 ...ontentEntityStorageSchemaConverterTest.php | 262 ++++++++++
 ...8.entity-test-schema-converter-enabled.php |  36 ++
 .../entity_test_schema_converter.info.yml     |   8 +
 ...tity_test_schema_converter.post_update.php |  41 ++
 8 files changed, 902 insertions(+), 4 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchemaConverter.php
 create mode 100644 core/lib/Drupal/Core/Entity/Sql/TemporaryTableMapping.php
 create mode 100644 core/modules/system/src/Tests/Entity/Update/SqlContentEntityStorageSchemaConverterTest.php
 create mode 100644 core/modules/system/tests/fixtures/update/drupal-8.entity-test-schema-converter-enabled.php
 create mode 100644 core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.info.yml
 create mode 100644 core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.post_update.php

diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
index 2b9204b4046b..1172406e346b 100644
--- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
+++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorage.php
@@ -117,6 +117,13 @@ class SqlContentEntityStorage extends ContentEntityStorageBase implements SqlEnt
    */
   protected $languageManager;
 
+  /**
+   * Whether this storage should use the temporary table mapping.
+   *
+   * @var bool
+   */
+  protected $temporary = FALSE;
+
   /**
    * {@inheritdoc}
    */
@@ -266,6 +273,31 @@ public function setEntityType(EntityTypeInterface $entity_type) {
     }
   }
 
+  /**
+   * Sets the wrapped table mapping definition.
+   *
+   * @param \Drupal\Core\Entity\Sql\TableMappingInterface $table_mapping
+   *   The table mapping.
+   *
+   * @internal Only to be used internally by Entity API. Expected to be removed
+   *   by https://www.drupal.org/node/2554235.
+   */
+  public function setTableMapping(TableMappingInterface $table_mapping) {
+    $this->tableMapping = $table_mapping;
+  }
+
+  /**
+   * Changes the temporary state of the storage.
+   *
+   * @param bool $temporary
+   *   Whether to use a temporary table mapping or not.
+   *
+   * @internal Only to be used internally by Entity API.
+   */
+  public function setTemporary($temporary) {
+    $this->temporary = $temporary;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -279,8 +311,10 @@ public function getTableMapping(array $storage_definitions = NULL) {
     // @todo Clean-up this in https://www.drupal.org/node/2274017 so we can
     //   easily instantiate a new table mapping whenever needed.
     if (!isset($this->tableMapping) || $storage_definitions) {
+      $table_mapping_class = $this->temporary ? TemporaryTableMapping::class : DefaultTableMapping::class;
       $definitions = $storage_definitions ?: $this->entityManager->getFieldStorageDefinitions($this->entityTypeId);
-      $table_mapping = new DefaultTableMapping($this->entityType, $definitions);
+      /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping|\Drupal\Core\Entity\Sql\TemporaryTableMapping $table_mapping */
+      $table_mapping = new $table_mapping_class($this->entityType, $definitions);
 
       $shared_table_definitions = array_filter($definitions, function (FieldStorageDefinitionInterface $definition) use ($table_mapping) {
         return $table_mapping->allowsSharedTableStorage($definition);
diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php
index f9dd0c0850a0..6327656eaa3e 100644
--- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php
+++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php
@@ -237,6 +237,12 @@ protected function getSchemaFromStorageDefinition(FieldStorageDefinitionInterfac
    * {@inheritdoc}
    */
   public function requiresEntityDataMigration(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
+    // Check if the entity type specifies that data migration is being handled
+    // elsewhere.
+    if ($entity_type->get('requires_data_migration') === FALSE) {
+      return FALSE;
+    }
+
     // If the original storage has existing entities, or it is impossible to
     // determine if that is the case, require entity data to be migrated.
     $original_storage_class = $original->getStorageClass();
@@ -1212,10 +1218,14 @@ protected function deleteDedicatedTableSchema(FieldStorageDefinitionInterface $s
     $deleted = !$this->originalDefinitions;
     $table_mapping = $this->storage->getTableMapping();
     $table_name = $table_mapping->getDedicatedDataTableName($storage_definition, $deleted);
-    $this->database->schema()->dropTable($table_name);
+    if ($this->database->schema()->tableExists($table_name)) {
+      $this->database->schema()->dropTable($table_name);
+    }
     if ($this->entityType->isRevisionable()) {
-      $revision_name = $table_mapping->getDedicatedRevisionTableName($storage_definition, $deleted);
-      $this->database->schema()->dropTable($revision_name);
+      $revision_table_name = $table_mapping->getDedicatedRevisionTableName($storage_definition, $deleted);
+      if ($this->database->schema()->tableExists($revision_table_name)) {
+        $this->database->schema()->dropTable($revision_table_name);
+      }
     }
     $this->deleteFieldSchemaData($storage_definition);
   }
diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchemaConverter.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchemaConverter.php
new file mode 100644
index 000000000000..30befd2e9f58
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchemaConverter.php
@@ -0,0 +1,461 @@
+<?php
+
+namespace Drupal\Core\Entity\Sql;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface;
+use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
+use Drupal\Core\Entity\EntityStorageException;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
+use Drupal\Core\Site\Settings;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Defines a schema converter for entity types with existing data.
+ *
+ * For now, this can only be used to convert an entity type from
+ * non-revisionable to revisionable, however, it should be expanded so it can
+ * also handle converting an entity type to be translatable.
+ */
+class SqlContentEntityStorageSchemaConverter {
+
+  /**
+   * The entity type ID this schema converter is responsible for.
+   *
+   * @var string
+   */
+  protected $entityTypeId;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity definition update manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
+   */
+  protected $entityDefinitionUpdateManager;
+
+  /**
+   * The last installed schema repository service.
+   *
+   * @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface
+   */
+  protected $lastInstalledSchemaRepository;
+
+  /**
+   * The key-value collection for tracking installed storage schema.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+   */
+  protected $installedStorageSchema;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * SqlContentEntityStorageSchemaConverter constructor.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $entity_definition_update_manager
+   *   Entity definition update manager service.
+   * @param \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository
+   *   Last installed schema repository service.
+   * @param \Drupal\Core\Database\Connection $database
+   *   Database connection.
+   */
+  public function __construct($entity_type_id, EntityTypeManagerInterface $entity_type_manager, EntityDefinitionUpdateManagerInterface $entity_definition_update_manager, EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository, KeyValueStoreInterface $installed_storage_schema, Connection $database) {
+    $this->entityTypeId = $entity_type_id;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityDefinitionUpdateManager = $entity_definition_update_manager;
+    $this->lastInstalledSchemaRepository = $last_installed_schema_repository;
+    $this->installedStorageSchema = $installed_storage_schema;
+    $this->database = $database;
+  }
+
+  /**
+   * Converts an entity type with existing data to be revisionable.
+   *
+   * This process does the following tasks:
+   *   - creates the schema from scratch with the new revisionable entity type
+   *     definition (i.e. the current definition of the entity type from code)
+   *     using temporary table names;
+   *   - loads the initial entity data by using the last installed entity and
+   *     field storage definitions;
+   *   - saves the entity data to the temporary tables;
+   *   - at the end of the process:
+   *     - deletes the original tables and replaces them with the temporary ones
+   *       that hold the new (revisionable) entity data;
+   *     - updates the installed entity schema data;
+   *     - updates the entity type definition in order to trigger the
+   *       \Drupal\Core\Entity\EntityTypeEvents::UPDATE event;
+   *     - updates the field storage definitions in order to mark the
+   *       revisionable ones as such.
+   *
+   * In case of an error during the entity save process, the temporary tables
+   * are deleted and the original entity type and field storage definitions are
+   * restored.
+   *
+   * @param array $sandbox
+   *   The sandbox array from a hook_update_N() implementation.
+   * @param string[] $fields_to_update
+   *   (optional) An array of field names that should be converted to be
+   *   revisionable. Note that the 'langcode' field, if present, is updated
+   *   automatically. Defaults to an empty array.
+   *
+   * @throws \Exception
+   *   Re-throws any exception raised during the update process.
+   */
+  public function convertToRevisionable(array &$sandbox, array $fields_to_update = []) {
+    // If 'progress' is not set, then this will be the first run of the batch.
+    if (!isset($sandbox['progress'])) {
+      // Store the original entity type and field definitions in the $sandbox
+      // array so we can use them later in the update process.
+      $this->collectOriginalDefinitions($sandbox);
+
+      // Create a temporary environment in which the new data will be stored.
+      $this->createTemporaryDefinitions($sandbox, $fields_to_update);
+
+      // Create the updated entity schema using temporary tables.
+      /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
+      $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
+      $storage->setTemporary(TRUE);
+      $storage->setEntityType($sandbox['temporary_entity_type']);
+      $storage->onEntityTypeCreate($sandbox['temporary_entity_type']);
+    }
+
+    // Copy over the existing data to the new temporary tables.
+    $this->copyData($sandbox);
+
+    // If the data copying has finished successfully, we can drop the temporary
+    // tables and call the appropriate update mechanisms.
+    if ($sandbox['#finished'] == 1) {
+      $this->entityTypeManager->useCaches(FALSE);
+      $actual_entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
+
+      // Rename the original tables so we can put them back in place in case
+      // anything goes wrong.
+      foreach ($sandbox['original_table_mapping']->getTableNames() as $table_name) {
+        $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
+        $this->database->schema()->renameTable($table_name, $old_table_name);
+      }
+
+      // Put the new tables in place and update the entity type and field
+      // storage definitions.
+      try {
+        $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
+        $storage->setEntityType($actual_entity_type);
+        $storage->setTemporary(FALSE);
+        $actual_table_names = $storage->getTableMapping()->getTableNames();
+
+        $table_name_mapping = [];
+        foreach ($actual_table_names as $new_table_name) {
+          $temp_table_name = TemporaryTableMapping::getTempTableName($new_table_name);
+          $table_name_mapping[$temp_table_name] = $new_table_name;
+          $this->database->schema()->renameTable($temp_table_name, $new_table_name);
+        }
+
+        // Rename the tables in the cached entity schema data.
+        $entity_schema_data = $this->installedStorageSchema->get($this->entityTypeId . '.entity_schema_data', []);
+        foreach ($entity_schema_data as $temp_table_name => $schema) {
+          if (isset($table_name_mapping[$temp_table_name])) {
+            $entity_schema_data[$table_name_mapping[$temp_table_name]] = $schema;
+            unset($entity_schema_data[$temp_table_name]);
+          }
+        }
+        $this->installedStorageSchema->set($this->entityTypeId . '.entity_schema_data', $entity_schema_data);
+
+        // Rename the tables in the cached field schema data.
+        foreach ($sandbox['updated_storage_definitions'] as $storage_definition) {
+          $field_schema_data = $this->installedStorageSchema->get($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), []);
+          foreach ($field_schema_data as $temp_table_name => $schema) {
+            if (isset($table_name_mapping[$temp_table_name])) {
+              $field_schema_data[$table_name_mapping[$temp_table_name]] = $schema;
+              unset($field_schema_data[$temp_table_name]);
+            }
+          }
+          $this->installedStorageSchema->set($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), $field_schema_data);
+        }
+
+        // Instruct the entity schema handler that data migration has been
+        // handled already and update the entity type.
+        $actual_entity_type->set('requires_data_migration', FALSE);
+        $this->entityDefinitionUpdateManager->updateEntityType($actual_entity_type);
+
+        // Update the field storage definitions.
+        $this->updateFieldStorageDefinitionsToRevisionable($actual_entity_type, $sandbox['original_storage_definitions'], $fields_to_update);
+      }
+      catch (\Exception $e) {
+        // Something went wrong, bring back the original tables.
+        foreach ($sandbox['original_table_mapping']->getTableNames() as $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($table_name)) {
+            $this->database->schema()->dropTable($table_name);
+          }
+
+          $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
+          $this->database->schema()->renameTable($old_table_name, $table_name);
+        }
+
+        // Re-throw the original exception.
+        throw $e;
+      }
+
+      // At this point the update process either finished successfully or any
+      // error has been handled already, so we can drop the backup entity
+      // tables.
+      foreach ($sandbox['original_table_mapping']->getTableNames() as $table_name) {
+        $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
+        $this->database->schema()->dropTable($old_table_name);
+      }
+    }
+  }
+
+  /**
+   * Loads entities from the original storage and saves them to a temporary one.
+   *
+   * @param array &$sandbox
+   *   The sandbox array from a hook_update_N() implementation.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   Thrown in case of an error during the entity save process.
+   */
+  protected function copyData(array &$sandbox) {
+    /** @var \Drupal\Core\Entity\Sql\TemporaryTableMapping $temporary_table_mapping */
+    $temporary_table_mapping = $sandbox['temporary_table_mapping'];
+    $temporary_entity_type = $sandbox['temporary_entity_type'];
+    $original_table_mapping = $sandbox['original_table_mapping'];
+    $original_entity_type = $sandbox['original_entity_type'];
+
+    $original_base_table = $original_entity_type->getBaseTable();
+
+    $revision_id_key = $temporary_entity_type->getKey('revision');
+
+    // 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'] = 0;
+      $sandbox['max'] = $this->database->select($original_base_table)
+        ->countQuery()
+        ->execute()
+        ->fetchField();
+    }
+
+    $id = $original_entity_type->getKey('id');
+
+    // Define the step size.
+    $step_size = Settings::get('entity_update_batch_size', 50);
+
+    // Get the next entity IDs to migrate.
+    $entity_ids = $this->database->select($original_base_table)
+      ->fields($original_base_table, [$id])
+      ->condition($id, $sandbox['current_id'], '>')
+      ->orderBy($id, 'ASC')
+      ->range(0, $step_size)
+      ->execute()
+      ->fetchAllKeyed(0, 0);
+
+    /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
+    $storage = $this->entityTypeManager->getStorage($temporary_entity_type->id());
+    $storage->setEntityType($original_entity_type);
+    $storage->setTableMapping($original_table_mapping);
+
+    $entities = $storage->loadMultiple($entity_ids);
+
+    // Now inject the temporary entity type definition and table mapping in the
+    // storage and re-save the entities.
+    $storage->setEntityType($temporary_entity_type);
+    $storage->setTableMapping($temporary_table_mapping);
+
+    foreach ($entities as $entity_id => $entity) {
+      try {
+        // Set the revision ID to be same as the entity ID.
+        $entity->set($revision_id_key, $entity_id);
+
+        // 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.
+        $storage->save($entity);
+      }
+      catch (\Exception $e) {
+        // In case of an error during the save process, we need to roll back the
+        // original entity type and field storage definitions and clean up the
+        // temporary tables.
+        $this->restoreOriginalDefinitions($sandbox);
+
+        foreach ($temporary_table_mapping->getTableNames() as $table_name) {
+          $this->database->schema()->dropTable($table_name);
+        }
+
+        // Re-throw the original exception with a helpful message.
+        throw new EntityStorageException("The entity update process failed while processing the entity {$original_entity_type->id()}:$entity_id.", $e->getCode(), $e);
+      }
+
+      $sandbox['progress']++;
+      $sandbox['current_id'] = $entity_id;
+    }
+
+    // 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.
+    $sandbox['max'] = $this->database->select($original_base_table)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+
+    $sandbox['#finished'] = empty($sandbox['max']) ? 1 : ($sandbox['progress'] / $sandbox['max']);
+  }
+
+  /**
+   * Updates field definitions to be revisionable.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
+   *   A content entity type definition.
+   * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $storage_definitions
+   *   An array of field storage definitions.
+   * @param array $fields_to_update
+   *   (optional) An array of field names for which to enable revision support.
+   *   Defaults to an empty array.
+   * @param bool $update_cached_definitions
+   *   (optional) Whether to update the cached field storage definitions in the
+   *   entity definition update manager. Defaults to TRUE.
+   *
+   * @return \Drupal\Core\Field\FieldStorageDefinitionInterface[]
+   *   An array of updated field storage definitions.
+   */
+  protected function updateFieldStorageDefinitionsToRevisionable(ContentEntityTypeInterface $entity_type, array $storage_definitions, array $fields_to_update = [], $update_cached_definitions = TRUE) {
+    $updated_storage_definitions = array_map(function ($storage_definition) { return clone $storage_definition; }, $storage_definitions);
+
+    // Update the 'langcode' field manually, as it is configured in the base
+    // content entity field definitions.
+    if ($entity_type->hasKey('langcode')) {
+      $fields_to_update = array_merge([$entity_type->getKey('langcode')], $fields_to_update);
+    }
+
+    foreach ($fields_to_update as $field_name) {
+      if (!$updated_storage_definitions[$field_name]->isRevisionable()) {
+        $updated_storage_definitions[$field_name]->setRevisionable(TRUE);
+
+        if ($update_cached_definitions) {
+          $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($updated_storage_definitions[$field_name]);
+        }
+      }
+    }
+
+    // Add the revision ID field.
+    $revision_field = BaseFieldDefinition::create('integer')
+      ->setName($entity_type->getKey('revision'))
+      ->setTargetEntityTypeId($entity_type->id())
+      ->setTargetBundle(NULL)
+      ->setLabel(new TranslatableMarkup('Revision ID'))
+      ->setReadOnly(TRUE)
+      ->setSetting('unsigned', TRUE);
+
+    if ($update_cached_definitions) {
+      $this->entityDefinitionUpdateManager->installFieldStorageDefinition($revision_field->getName(), $entity_type->id(), $entity_type->getProvider(), $revision_field);
+    }
+
+    $updated_storage_definitions[$entity_type->getKey('revision')] = $revision_field;
+
+    return $updated_storage_definitions;
+  }
+
+  /**
+   * Collects the original definitions of an entity type and its fields.
+   *
+   * @param array &$sandbox
+   *   A sandbox array from a hook_update_N() implementation.
+   */
+  protected function collectOriginalDefinitions(array &$sandbox) {
+    $original_entity_type = $this->lastInstalledSchemaRepository->getLastInstalledDefinition($this->entityTypeId);
+    $original_storage_definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($this->entityTypeId);
+
+    /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
+    $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
+    $storage->setEntityType($original_entity_type);
+    $original_table_mapping = $storage->getTableMapping($original_storage_definitions);
+
+    $sandbox['original_entity_type'] = $original_entity_type;
+    $sandbox['original_storage_definitions'] = $original_storage_definitions;
+    $sandbox['original_table_mapping'] = $original_table_mapping;
+
+    $sandbox['original_entity_schema_data'] = $this->installedStorageSchema->get($this->entityTypeId . '.entity_schema_data', []);
+    foreach ($original_storage_definitions as $storage_definition) {
+      $sandbox['original_field_schema_data'][$storage_definition->getName()] = $this->installedStorageSchema->get($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), []);
+    }
+  }
+
+  /**
+   * Restores the entity type, field storage definitions and their schema data.
+   *
+   * @param array $sandbox
+   *   The sandbox array from a hook_update_N() implementation.
+   */
+  protected function restoreOriginalDefinitions(array $sandbox) {
+    $original_entity_type = $sandbox['original_entity_type'];
+    $original_storage_definitions = $sandbox['original_storage_definitions'];
+    $original_entity_schema_data = $sandbox['original_entity_schema_data'];
+    $original_field_schema_data = $sandbox['original_field_schema_data'];
+
+    $this->lastInstalledSchemaRepository->setLastInstalledDefinition($original_entity_type);
+    $this->lastInstalledSchemaRepository->setLastInstalledFieldStorageDefinitions($original_entity_type->id(), $original_storage_definitions);
+
+    $this->installedStorageSchema->set($original_entity_type->id() . '.entity_schema_data', $original_entity_schema_data);
+    foreach ($original_field_schema_data as $field_name => $field_schema_data) {
+      $this->installedStorageSchema->set($original_entity_type->id() . '.field_schema_data.' . $field_name, $field_schema_data);
+    }
+  }
+
+  /**
+   * Creates temporary entity type, field storage and table mapping objects.
+   *
+   * @param array &$sandbox
+   *   A sandbox array from a hook_update_N() implementation.
+   * @param string[] $fields_to_update
+   *   (optional) An array of field names that should be converted to be
+   *   revisionable. Note that the 'langcode' field, if present, is updated
+   *   automatically. Defaults to an empty array.
+   */
+  protected function createTemporaryDefinitions(array &$sandbox, array $fields_to_update) {
+    // Make sure to get the latest entity type definition from code.
+    $this->entityTypeManager->useCaches(FALSE);
+    $actual_entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
+
+    $temporary_entity_type = clone $actual_entity_type;
+    $temporary_entity_type->set('base_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getBaseTable()));
+    $temporary_entity_type->set('revision_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getRevisionTable()));
+    if ($temporary_entity_type->isTranslatable()) {
+      $temporary_entity_type->set('data_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getDataTable()));
+      $temporary_entity_type->set('revision_data_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getRevisionDataTable()));
+    }
+
+    /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
+    $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
+    $storage->setTemporary(TRUE);
+    $storage->setEntityType($temporary_entity_type);
+
+    $updated_storage_definitions = $this->updateFieldStorageDefinitionsToRevisionable($temporary_entity_type, $sandbox['original_storage_definitions'], $fields_to_update, FALSE);
+    $temporary_table_mapping = $storage->getTableMapping($updated_storage_definitions);
+
+    $sandbox['temporary_entity_type'] = $temporary_entity_type;
+    $sandbox['temporary_table_mapping'] = $temporary_table_mapping;
+    $sandbox['updated_storage_definitions'] = $updated_storage_definitions;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/Sql/TemporaryTableMapping.php b/core/lib/Drupal/Core/Entity/Sql/TemporaryTableMapping.php
new file mode 100644
index 000000000000..c53e660cb165
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Sql/TemporaryTableMapping.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Core\Entity\Sql;
+
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+
+/**
+ * Defines a temporary table mapping class.
+ */
+class TemporaryTableMapping extends DefaultTableMapping {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function generateFieldTableName(FieldStorageDefinitionInterface $storage_definition, $revision) {
+    return static::getTempTableName(parent::generateFieldTableName($storage_definition, $revision));
+  }
+
+  /**
+   * Generates a temporary table name.
+   *
+   * The method accounts for a maximum table name length of 64 characters.
+   *
+   * @param string $table_name
+   *   The initial table name.
+   * @param string $prefix
+   *   (optional) The prefix to use for the new table name. Defaults to 'tmp_'.
+   *
+   * @return string
+   *   The final table name.
+   */
+  public static function getTempTableName($table_name, $prefix = 'tmp_') {
+    $tmp_table_name = $prefix . $table_name;
+
+    // Limit the string to 48 characters, keeping a 16 characters margin for db
+    // prefixes.
+    if (strlen($table_name) > 48) {
+      $short_table_name = substr($table_name, 0, 34);
+      $table_hash = substr(hash('sha256', $table_name), 0, 10);
+
+      $tmp_table_name = $prefix . $short_table_name . $table_hash;
+    }
+    return $tmp_table_name;
+  }
+
+}
diff --git a/core/modules/system/src/Tests/Entity/Update/SqlContentEntityStorageSchemaConverterTest.php b/core/modules/system/src/Tests/Entity/Update/SqlContentEntityStorageSchemaConverterTest.php
new file mode 100644
index 000000000000..3a0aca2aef09
--- /dev/null
+++ b/core/modules/system/src/Tests/Entity/Update/SqlContentEntityStorageSchemaConverterTest.php
@@ -0,0 +1,262 @@
+<?php
+
+namespace Drupal\system\Tests\Entity\Update;
+
+use Drupal\Core\Entity\Sql\TemporaryTableMapping;
+use Drupal\system\Tests\Entity\EntityDefinitionTestTrait;
+use Drupal\system\Tests\Update\UpdatePathTestBase;
+
+/**
+ * Tests updating an entity type with existing data to be revisionable.
+ *
+ * @group Entity
+ * @group Update
+ */
+class SqlContentEntityStorageSchemaConverterTest extends UpdatePathTestBase {
+
+  use EntityDefinitionTestTrait;
+
+  /**
+   * The entity manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityManagerInterface
+   */
+  protected $entityManager;
+
+  /**
+   * The entity definition update manager.
+   *
+   * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
+   */
+  protected $entityDefinitionUpdateManager;
+
+  /**
+   * The last installed schema repository service.
+   *
+   * @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface
+   */
+  protected $lastInstalledSchemaRepository;
+
+  /**
+   * The key-value collection for tracking installed storage schema.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
+   */
+  protected $installedStorageSchema;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->entityManager = \Drupal::entityManager();
+    $this->entityDefinitionUpdateManager = \Drupal::entityDefinitionUpdateManager();
+    $this->lastInstalledSchemaRepository = \Drupal::service('entity.last_installed_schema.repository');
+    $this->installedStorageSchema = \Drupal::keyValue('entity.storage_schema.sql');
+    $this->state = \Drupal::state();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles() {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../tests/fixtures/update/drupal-8.0.0-rc1-filled.standard.entity_test_update_mul.php.gz',
+      __DIR__ . '/../../../../tests/fixtures/update/drupal-8.entity-test-schema-converter-enabled.php',
+    ];
+  }
+
+  /**
+   * Tests the conversion of an entity type to revisionable.
+   */
+  public function testMakeRevisionable() {
+    // Check that entity type is not revisionable prior to running the update
+    // process.
+    $entity_test_update = $this->lastInstalledSchemaRepository->getLastInstalledDefinition('entity_test_update');
+    $this->assertFalse($entity_test_update->isRevisionable());
+
+    // Make the entity type revisionable and translatable and run the updates.
+    $this->updateEntityTypeToRevisionableAndTranslatable();
+
+    $this->runUpdates();
+
+    /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_test_update */
+    $entity_test_update = $this->lastInstalledSchemaRepository->getLastInstalledDefinition('entity_test_update');
+    $this->assertTrue($entity_test_update->isRevisionable());
+
+    /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */
+    $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
+    $this->assertEqual(count($storage->loadMultiple()), 102, 'All test entities were found.');
+
+    // Check that each field value was copied correctly to the revision tables.
+    for ($i = 1; $i <= 102; $i++) {
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */
+      $revision = $storage->loadRevision($i);
+
+      $this->assertEqual($i, $revision->id());
+      $this->assertEqual($i, $revision->getRevisionId());
+
+      $this->assertEqual($i . ' - test single property', $revision->test_single_property->value);
+
+      $this->assertEqual($i . ' - test multiple properties - value1', $revision->test_multiple_properties->value1);
+      $this->assertEqual($i . ' - test multiple properties - value2', $revision->test_multiple_properties->value2);
+
+      $this->assertEqual($i . ' - test single property multiple values 0', $revision->test_single_property_multiple_values->value);
+      $this->assertEqual($i . ' - test single property multiple values 1', $revision->test_single_property_multiple_values[1]->value);
+
+      $this->assertEqual($i . ' - test multiple properties multiple values - value1 0', $revision->test_multiple_properties_multiple_values[0]->value1);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value2 0', $revision->test_multiple_properties_multiple_values[0]->value2);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value1 1', $revision->test_multiple_properties_multiple_values[1]->value1);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value2 1', $revision->test_multiple_properties_multiple_values[1]->value2);
+
+      $this->assertEqual($i . ' - field test configurable field - value1 0', $revision->field_test_configurable_field[0]->value1);
+      $this->assertEqual($i . ' - field test configurable field - value2 0', $revision->field_test_configurable_field[0]->value2);
+      $this->assertEqual($i . ' - field test configurable field - value1 1', $revision->field_test_configurable_field[1]->value1);
+      $this->assertEqual($i . ' - field test configurable field - value2 1', $revision->field_test_configurable_field[1]->value2);
+
+      $this->assertEqual($i . ' - test entity base field info', $revision->test_entity_base_field_info->value);
+
+      // Do the same checks for translated field values.
+      $translation = $revision->getTranslation('ro');
+
+      $this->assertEqual($i . ' - test single property - ro', $translation->test_single_property->value);
+
+      $this->assertEqual($i . ' - test multiple properties - value1 - ro', $translation->test_multiple_properties->value1);
+      $this->assertEqual($i . ' - test multiple properties - value2 - ro', $translation->test_multiple_properties->value2);
+
+      $this->assertEqual($i . ' - test single property multiple values 0 - ro', $translation->test_single_property_multiple_values[0]->value);
+      $this->assertEqual($i . ' - test single property multiple values 1 - ro', $translation->test_single_property_multiple_values[1]->value);
+
+      $this->assertEqual($i . ' - test multiple properties multiple values - value1 0 - ro', $translation->test_multiple_properties_multiple_values[0]->value1);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value2 0 - ro', $translation->test_multiple_properties_multiple_values[0]->value2);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value1 1 - ro', $translation->test_multiple_properties_multiple_values[1]->value1);
+      $this->assertEqual($i . ' - test multiple properties multiple values - value2 1 - ro', $translation->test_multiple_properties_multiple_values[1]->value2);
+
+      $this->assertEqual($i . ' - field test configurable field - value1 0 - ro', $translation->field_test_configurable_field[0]->value1);
+      $this->assertEqual($i . ' - field test configurable field - value2 0 - ro', $translation->field_test_configurable_field[0]->value2);
+      $this->assertEqual($i . ' - field test configurable field - value1 1 - ro', $translation->field_test_configurable_field[1]->value1);
+      $this->assertEqual($i . ' - field test configurable field - value2 1 - ro', $translation->field_test_configurable_field[1]->value2);
+
+      $this->assertEqual($i . ' - test entity base field info - ro', $translation->test_entity_base_field_info->value);
+    }
+
+    // Check that temporary tables have been removed at the end of the process.
+    $schema = \Drupal::database()->schema();
+    foreach ($storage->getTableMapping()->getTableNames() as $table_name) {
+      $this->assertFalse($schema->tableExists(TemporaryTableMapping::getTempTableName($table_name)));
+    }
+
+    // Check that backup tables have been removed at the end of the process.
+    $schema = \Drupal::database()->schema();
+    foreach ($storage->getTableMapping()->getTableNames() as $table_name) {
+      $this->assertFalse($schema->tableExists(TemporaryTableMapping::getTempTableName($table_name, 'old_')));
+    }
+  }
+
+  /**
+   * Tests that a failed "make revisionable" update preserves the existing data.
+   */
+  public function testMakeRevisionableErrorHandling() {
+    $original_entity_type = $this->lastInstalledSchemaRepository->getLastInstalledDefinition('entity_test_update');
+    $original_storage_definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions('entity_test_update');
+
+    $original_entity_schema_data = $this->installedStorageSchema->get('entity_test_update.entity_schema_data', []);
+    foreach ($original_storage_definitions as $storage_definition) {
+      $original_field_schema_data[$storage_definition->getName()] = $this->installedStorageSchema->get('entity_test_update.field_schema_data.' . $storage_definition->getName(), []);
+    }
+
+    // Check that entity type is not revisionable prior to running the update
+    // process.
+    $this->assertFalse($original_entity_type->isRevisionable());
+
+    // Make the update throw an exception during the entity save process.
+    \Drupal::state()->set('entity_test_update.throw_exception', TRUE);
+
+    // Since the update process is interrupted by the exception thrown above,
+    // we can not do the full post update testing offered by UpdatePathTestBase.
+    $this->checkFailedUpdates = FALSE;
+
+    // Make the entity type revisionable and run the updates.
+    $this->updateEntityTypeToRevisionableAndTranslatable();
+
+    $this->runUpdates();
+
+    // Check that the update failed.
+    $this->assertRaw('<strong>' . t('Failed:') . '</strong>');
+
+    // Check that the last installed entity type definition is kept as
+    // non-revisionable.
+    $new_entity_type = $this->lastInstalledSchemaRepository->getLastInstalledDefinition('entity_test_update');
+    $this->assertFalse($new_entity_type->isRevisionable(), 'The entity type is kept unchanged.');
+
+    // Check that the last installed field storage definitions did not change by
+    // looking at the 'langcode' field, which is updated automatically.
+    $new_storage_definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions('entity_test_update');
+    $langcode_key = $original_entity_type->getKey('langcode');
+    $this->assertEqual($original_storage_definitions[$langcode_key]->isRevisionable(), $new_storage_definitions[$langcode_key]->isRevisionable(), "The 'langcode' field is kept unchanged.");
+
+    /** @var \Drupal\Core\Entity\Sql\SqlEntityStorageInterface $storage */
+    $storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
+
+    // Check that installed storage schema did not change.
+    $new_entity_schema_data = $this->installedStorageSchema->get('entity_test_update.entity_schema_data', []);
+    $this->assertEqual($original_entity_schema_data, $new_entity_schema_data);
+
+    foreach ($new_storage_definitions as $storage_definition) {
+      $new_field_schema_data[$storage_definition->getName()] = $this->installedStorageSchema->get('entity_test_update.field_schema_data.' . $storage_definition->getName(), []);
+    }
+    $this->assertEqual($original_field_schema_data, $new_field_schema_data);
+
+    // Check that temporary tables have been removed.
+    $schema = \Drupal::database()->schema();
+    foreach ($storage->getTableMapping()->getTableNames() as $table_name) {
+      $this->assertFalse($schema->tableExists(TemporaryTableMapping::getTempTableName($table_name)));
+    }
+
+    // Check that the original tables still exist and their data is intact.
+    $this->assertTrue($schema->tableExists('entity_test_update'));
+    $this->assertTrue($schema->tableExists('entity_test_update_data'));
+
+    $base_table_count = \Drupal::database()->select('entity_test_update')
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $this->assertEqual($base_table_count, 102);
+
+    $data_table_count = \Drupal::database()->select('entity_test_update_data')
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    // There are two records for each entity, one for English and one for
+    // Romanian.
+    $this->assertEqual($data_table_count, 204);
+
+    $base_table_row = \Drupal::database()->select('entity_test_update')
+      ->fields('entity_test_update')
+      ->condition('id', 1, '=')
+      ->condition('langcode', 'en', '=')
+      ->execute()
+      ->fetchAllAssoc('id');
+    $this->assertEqual('843e9ac7-3351-4cc1-a202-2dbffffae21c', $base_table_row[1]->uuid);
+
+    $data_table_row = \Drupal::database()->select('entity_test_update_data')
+      ->fields('entity_test_update_data')
+      ->condition('id', 1, '=')
+      ->condition('langcode', 'en', '=')
+      ->execute()
+      ->fetchAllAssoc('id');
+    $this->assertEqual('1 - test single property', $data_table_row[1]->test_single_property);
+    $this->assertEqual('1 - test multiple properties - value1', $data_table_row[1]->test_multiple_properties__value1);
+    $this->assertEqual('1 - test multiple properties - value2', $data_table_row[1]->test_multiple_properties__value2);
+    $this->assertEqual('1 - test entity base field info', $data_table_row[1]->test_entity_base_field_info);
+  }
+
+}
diff --git a/core/modules/system/tests/fixtures/update/drupal-8.entity-test-schema-converter-enabled.php b/core/modules/system/tests/fixtures/update/drupal-8.entity-test-schema-converter-enabled.php
new file mode 100644
index 000000000000..3a5bc181bc7e
--- /dev/null
+++ b/core/modules/system/tests/fixtures/update/drupal-8.entity-test-schema-converter-enabled.php
@@ -0,0 +1,36 @@
+<?php
+// @codingStandardsIgnoreFile
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+// Set the schema version.
+$connection->merge('key_value')
+  ->fields([
+    'value' => 'i:8000;',
+    'name' => 'entity_test_schema_converter',
+    'collection' => 'system.schema',
+  ])
+  ->condition('collection', 'system.schema')
+  ->condition('name', 'entity_test_schema_converter')
+  ->execute();
+
+// Update core.extension.
+$extensions = $connection->select('config')
+  ->fields('config', ['data'])
+  ->condition('collection', '')
+  ->condition('name', 'core.extension')
+  ->execute()
+  ->fetchField();
+$extensions = unserialize($extensions);
+$extensions['module']['entity_test_schema_converter'] = 8000;
+$connection->update('config')
+  ->fields([
+    'data' => serialize($extensions),
+    'collection' => '',
+    'name' => 'core.extension',
+  ])
+  ->condition('collection', '')
+  ->condition('name', 'core.extension')
+  ->execute();
diff --git a/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.info.yml b/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.info.yml
new file mode 100644
index 000000000000..b77990a6c1f7
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.info.yml
@@ -0,0 +1,8 @@
+name: 'Entity Schema Converter Test'
+type: module
+description: 'Provides testing for the entity schema converter.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - entity_test_update
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.post_update.php b/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.post_update.php
new file mode 100644
index 000000000000..1de57900715b
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_schema_converter/entity_test_schema_converter.post_update.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @file
+ * Post update functions for entity_test_schema_converter.
+ */
+
+use \Drupal\Core\Entity\Sql\SqlContentEntityStorageSchemaConverter;
+
+/**
+ * @addtogroup updates-8.4.x
+ * @{
+ */
+
+/**
+ * Update entity_test_update to be revisionable.
+ */
+function entity_test_schema_converter_post_update_make_revisionable(&$sandbox) {
+  $revisionableSchemaConverter = new SqlContentEntityStorageSchemaConverter(
+    'entity_test_update',
+    \Drupal::entityTypeManager(),
+    \Drupal::entityDefinitionUpdateManager(),
+    \Drupal::service('entity.last_installed_schema.repository'),
+    \Drupal::keyValue('entity.storage_schema.sql'),
+    \Drupal::database()
+  );
+
+  $revisionableSchemaConverter->convertToRevisionable(
+    $sandbox,
+    [
+      'test_single_property',
+      'test_multiple_properties',
+      'test_single_property_multiple_values',
+      'test_multiple_properties_multiple_values',
+      'test_entity_base_field_info',
+    ]);
+}
+
+/**
+ * @} End of "addtogroup updates-8.4.x".
+ */
-- 
GitLab