diff --git a/core/lib/Drupal/Core/Entity/Sql/RevisionableSchemaConverter.php b/core/lib/Drupal/Core/Entity/Sql/RevisionableSchemaConverter.php new file mode 100644 index 0000000..584e361 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Sql/RevisionableSchemaConverter.php @@ -0,0 +1,493 @@ +entityTypeId = $entity_type_id; + $this->entityTypeManager = $entity_type_manager; + $this->entityDefinitionUpdateManager = $entity_definition_update_manager; + $this->lastInstalledSchemaRepository = $last_installed_schema_repository; + $this->database = $database; + } + + /** + * Converts the schema of an entity type with existing data to revisionable. + * + * @param array $sandbox + * The sandbox array from a hook_update_N() implementation. + * @param array $schema + * The database schema array that we are updating to. This is usually + * generated by + * \Drupal\Core\Entity\Sql\RevisionableSchemaConverter::getFullEntitySchema(). + * @param string $revision_table + * The name of the revision table for the updated entity type. + * @param string $revision_data_table + * (optional) The name of the revision data table for the updated entity + * type. Defaults to NULL. + * @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. + */ + public function convertSchema(array &$sandbox, array $schema, $revision_key, $revision_table, $revision_data_table = NULL, array $fields_to_update = []) { + // If 'progress' is not set, then this will be the first run of the batch, + // so we need to initialize the updated entity type and field storage + // definitions and create the temporary tables. + if (!isset($sandbox['progress'])) { + $entity_type = $this->lastInstalledSchemaRepository->getLastInstalledDefinition($this->entityTypeId); + $storage_definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($this->entityTypeId); + + $sandbox['entity_type'] = $this->updateEntityTypeDefinition($entity_type, $revision_key, $revision_table, $revision_data_table); + $sandbox['storage_definitions'] = $this->updateFieldStorageDefinitions($entity_type, $storage_definitions, $fields_to_update); + + // @todo Instead of switching the wrapped entity type, we should be able + // to instantiate a new table mapping for each entity type definition. + // See https://www.drupal.org/node/2274017. + $storage = $this->entityTypeManager->getStorage($entity_type->id()); + $storage->setEntityType($entity_type); + + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $sandbox['table_mapping'] = $storage->getTableMapping($sandbox['storage_definitions']); + + // Create the new tables using temporary table names. + $this->createTables($schema); + } + + // Copy over the existing data to the new temporary tables. + $this->copyData($sandbox); + + // If the data copying has finished successfully, we can drop the existing + // tables and call the appropriate update mechanisms. + if ($sandbox['#finished'] === 1) { + // Replace the original tables with the temporary ones. + $this->replaceTables($sandbox, $schema); + + // Update the entity type definition. + $this->lastInstalledSchemaRepository->setLastInstalledDefinition($sandbox['entity_type']); + $this->entityDefinitionUpdateManager->updateEntityType($sandbox['entity_type']); + + // Update the field storage definitions. + static::getStorageSchemaHandler($sandbox['entity_type'])->updateFieldSchemaData($sandbox['storage_definitions']); + foreach ($sandbox['storage_definitions'] as $field_name => $storage_definition) { + if ($field_name === $sandbox['entity_type']->getKey('revision')) { + $this->entityDefinitionUpdateManager->installFieldStorageDefinition($storage_definition->getName(), $sandbox['entity_type']->id(), $sandbox['entity_type']->getProvider(), $storage_definition); + } + else { + $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($storage_definition); + } + } + } + } + + /** + * Copies existing data to new tables. + * + * @param array $sandbox + * The sandbox array from a hook_update_N() implementation. + */ + protected function copyData(array &$sandbox) { + $entity_type = $sandbox['entity_type']; + $storage_definitions = $sandbox['storage_definitions']; + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $sandbox['table_mapping']; + + // If 'progress' is not set, then this will be the first run of the batch. + $base_table = $entity_type->getBaseTable(); + if (!isset($sandbox['progress'])) { + $sandbox['progress'] = 0; + $sandbox['current_id'] = 0; + $sandbox['max'] = $this->database->select($base_table) + ->countQuery() + ->execute() + ->fetchField(); + } + + $id = $entity_type->getKey('id'); + + // Get the next 50 entity IDs to migrate. + $entity_ids = $this->database->select($base_table) + ->fields($base_table, [$id]) + ->condition($id, $sandbox['current_id'], '>') + ->range(0, 50) + ->execute() + ->fetchAllKeyed(0, 0); + + foreach ($entity_ids as $entity_id) { + $this->copySingleEntity($entity_type, $storage_definitions, $table_mapping, $entity_id); + $sandbox['progress']++; + $sandbox['current_id'] = $entity_id; + } + $sandbox['#finished'] = empty($sandbox['max']) ? 1 : ($sandbox['progress'] / $sandbox['max']); + } + + /** + * Gets field data for an entity table. + * + * @param string $table_name + * The entity table name. + * @param array $conditions + * An array of database conditions. + * + * @return array + * An array of field data for the specified entity table. + */ + protected function getTableData($table_name, array $conditions) { + $query = $this->database->select($table_name, 't') + ->fields('t'); + + foreach ($conditions as $field => $value) { + $query->condition($field, $value); + } + + return $query->execute()->fetchAll(\PDO::FETCH_ASSOC); + } + + /** + * Copies field data for an entity. + * + * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type + * The entity type definition. + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $storage_definitions + * An array of field storage definitions. + * @param \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping + * The table mapping. + * @param int|string $entity_id + * The entity ID. + */ + protected function copySingleEntity(ContentEntityTypeInterface $entity_type, array $storage_definitions, DefaultTableMapping $table_mapping, $entity_id) { + $id_key = $entity_type->getKey('id'); + $revision_key = $entity_type->getKey('revision'); + + // Copy data to the revision base table. + $base_table = $entity_type->getBaseTable(); + $revision_table = $entity_type->getRevisionTable(); + $revision_column_names = $table_mapping->getAllColumns($revision_table); + + $base_table_data = $this->getTableData($base_table, [$id_key => $entity_id]); + $this->database->insert($this->getTempTableName($base_table)) + ->fields((array) reset($base_table_data) + [$revision_key => $entity_id]) + ->execute(); + $this->database->insert($this->getTempTableName($revision_table)) + ->fields(array_intersect_key((array) reset($base_table_data) + [$revision_key => $entity_id], array_flip($revision_column_names))) + ->execute(); + + // Copy data to the revision data table. + if ($entity_type->isTranslatable()) { + $data_table = $entity_type->getDataTable(); + $revision_data_table = $entity_type->getRevisionDataTable(); + $data_table_column_names = $table_mapping->getAllColumns($data_table); + $revision_data_column_names = $table_mapping->getAllColumns($revision_data_table); + $data_table_data = $this->getTableData($data_table, [$id_key => $entity_id]); + + // Note: These values could be by language. + foreach ($data_table_data as $values) { + $primary_keys_data = [$id_key => $entity_id]; + $primary_keys_revision_data = [$revision_key => $entity_id]; + if ($entity_type->hasKey('langcode')) { + $langcode_key = $entity_type->getKey('langcode'); + $primary_keys_data[$langcode_key] = $values[$langcode_key]; + $primary_keys_revision_data[$langcode_key] = $values[$langcode_key]; + } + + $data = (array) $values + [$revision_key => $entity_id]; + $this->database->insert($this->getTempTableName($data_table)) + ->fields(array_intersect_key($data, array_flip($data_table_column_names))) + ->execute(); + + $this->database->insert($this->getTempTableName($revision_data_table)) + ->fields(array_intersect_key($data, array_flip($revision_data_column_names))) + ->execute(); + } + } + + // Copy data to the dedicated tables. + $dedicated_fields = array_filter($storage_definitions, function (FieldStorageDefinitionInterface $field_storage) use ($table_mapping) { + return $table_mapping->requiresDedicatedTableStorage($field_storage); + }); + + // For copying the data of dedicated field tables we can simply use the + // "INSERT INTO ... SELECT * ..." syntax. + foreach ($dedicated_fields as $dedicated_field => $field_storage) { + $dedicated_table_name = $table_mapping->getFieldTableName($dedicated_field); + $dedicated_revision_table = $table_mapping->getDedicatedRevisionTableName($field_storage); + + $query = $this->database->select($dedicated_table_name, 'dt') + ->fields('dt') + ->condition('entity_id', $entity_id); + + $this->database->insert($this->getTempTableName($dedicated_table_name)) + ->from($query) + ->execute(); + $this->database->insert($this->getTempTableName($dedicated_revision_table)) + ->from($query) + ->execute(); + } + } + + /** + * Updates the installed entity type definition. + * + * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type + * A content entity type definition. + * @param string $revision_key + * The value of the entity type revision key. + * @param string $revision_table + * The name of the revision table. + * @param string $revision_data_table + * (optional) The name of the revision data table. + * + * @return \Drupal\Core\Entity\ContentEntityTypeInterface + * The udpated entity type definition. + */ + protected function updateEntityTypeDefinition(ContentEntityTypeInterface $entity_type, $revision_key, $revision_table, $revision_data_table = NULL) { + $keys = $entity_type->getKeys(); + $keys['revision'] = $revision_key; + $entity_type->set('entity_keys', $keys); + $entity_type->set('revision_table', $revision_table); + if ($revision_data_table) { + $entity_type->set('revision_data_table', $revision_data_table); + } + + return $entity_type; + } + + /** + * 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. + * + * @return \Drupal\Core\Field\FieldStorageDefinitionInterface[] + * An array of updated field storage definitions. + */ + protected function updateFieldStorageDefinitions(ContentEntityTypeInterface $entity_type, array $storage_definitions, array $fields_to_update = []) { + // Update the 'langcode' field manually, as it is configure 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) { + // Configurable fields are always revisionable, so we only need to care + // about base fields. + if ($storage_definitions[$field_name]->isBaseField()) { + $storage_definitions[$field_name]->setRevisionable(TRUE); + } + } + + // Add the revision ID field. + $storage_definitions[$entity_type->getKey('revision')] = BaseFieldDefinition::create('integer') + ->setName($entity_type->getKey('revision')) + ->setTargetEntityTypeId($entity_type->id()) + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision ID')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + return $storage_definitions; + } + + /** + * Creates the entity tables with the new schema. + * + * @param array $schema + * The Schema API array. + */ + protected function createTables(array $schema) { + foreach ($schema as $table_name => $table_schema) { + if ($this->database->schema()->tableExists($this->getTempTableName($table_name))) { + $this->database->schema()->dropTable($this->getTempTableName($table_name)); + } + + $this->database->schema()->createTable($this->getTempTableName($table_name), $table_schema); + } + } + + /** + * Replaces the existing entity tables with the new ones. + * + * @param array $sandbox + * The sandbox array from a hook_update_N() implementation. + * @param array $schema + * The Schema API array. + */ + protected function replaceTables(array $sandbox, array $schema) { + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $sandbox['table_mapping']; + + // Delete all the existing entity tables. + $table_names = array_unique(array_merge($table_mapping->getTableNames(), $table_mapping->getDedicatedTableNames())); + foreach ($table_names as $table_name) { + $this->database->schema()->dropTable($table_name); + } + + + // Rename the temporary tables to their actual table names. + foreach ($schema as $table_name => $table_schema) { + $this->database->schema()->renameTable($this->getTempTableName($table_name), $table_name); + } + } + + /** + * 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. + * + * @return string + * The final table name. + */ + protected function getTempTableName($table_name) { + $prefix = 'rev_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, 30); + $table_hash = substr(hash('sha256', $table_name), 0, 10); + + $tmp_table_name = $prefix . $short_table_name . $table_hash; + } + return $tmp_table_name; + } + + /** + * Returns the full schema definition for an entity type. + * + * @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. + * + * @return array + * A Schema API array describing the entity schema, including dedicated + * field tables. + */ + public static function getFullEntitySchema(ContentEntityTypeInterface $entity_type, array $storage_definitions) { + $schema_handler = static::getStorageSchemaHandler($entity_type); + return $schema_handler->getFullEntitySchema($entity_type, $storage_definitions); + } + + /** + * Gets the entity type's storage schema object. + * + * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type + * A content entity type definition. + * + * @return \Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema + * The schema handler object. + */ + protected static function getStorageSchemaHandler(ContentEntityTypeInterface $entity_type) { + $entity_manager = \Drupal::entityManager(); + $storage = $entity_manager->getStorage($entity_type->id()); + $database = \Drupal::database(); + + $class = $entity_type->getHandlerClass('storage_schema') ?: 'Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema'; + return new $class($entity_manager, $entity_type, $storage, $database); + } + + /** + * Returns the representation of a variable with short style array syntax. + * + * @param mixed $var + * The variable to export. + * @param string $indent + * (optional) And optional string to use for indentation. Defaults to an + * empty string. + * + * @return mixed + * The variable representation when the return parameter is used and + * evaluates to TRUE. + */ + public static function varExportShortArraySyntax($var, $indent = '') { + switch (gettype($var)) { + case "string": + return "'" . addcslashes($var, "\\\$\"\r\n\t\v\f") . "'"; + case "array": + $indexed = array_keys($var) === range(0, count($var) - 1); + $r = []; + foreach ($var as $key => $value) { + $r[] = "$indent " + . ($indexed ? '' : self::varExportShortArraySyntax($key) . ' => ') + . self::varExportShortArraySyntax($value, "$indent "); + } + return "[\n" . implode(",\n", $r) . (!empty($r) ? ',': '') . "\n" . $indent . ']'; + case "boolean": + return $var ? "TRUE" : "FALSE"; + default: + return var_export($var, TRUE); + } + } + +} diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php index f43461f..6a9e4e1 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php @@ -121,6 +121,52 @@ protected function installedStorageSchema() { } /** + * Returns the full schema definition for an entity type. + * + * @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. + * + * @return array + * A Schema API array describing the entity schema, including dedicated + * field tables. + */ + public function getFullEntitySchema(ContentEntityTypeInterface $entity_type, array $storage_definitions) { + $schema = $this->getEntitySchema($entity_type, TRUE); + + $table_mapping = $this->storage->getTableMapping($storage_definitions); + foreach ($storage_definitions as $storage_definition) { + if ($table_mapping->requiresDedicatedTableStorage($storage_definition)) { + $schema += $this->getDedicatedTableSchema($storage_definition, $entity_type); + } + } + + return $schema; + } + + /** + * Updates the field schema data for the given field storage definitions. + * + * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $storage_definitions + * An array of field storage definitions. + */ + public function updateFieldSchemaData(array $storage_definitions) { + $table_mapping = $this->storage->getTableMapping($storage_definitions); + foreach ($storage_definitions as $field_storage_definition) { + if ($table_mapping->requiresDedicatedTableStorage($field_storage_definition)) { + $schema = $this->getDedicatedTableSchema($field_storage_definition); + $this->saveFieldSchemaData($field_storage_definition, $schema); + } + elseif ($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); + } + } + } + + /** * {@inheritdoc} */ public function requiresEntityStorageSchemaChanges(EntityTypeInterface $entity_type, EntityTypeInterface $original) { @@ -1138,6 +1184,12 @@ protected function createSharedTableSchema(FieldStorageDefinitionInterface $stor $schema[$table_name] = $this->getSharedTableFieldSchema($storage_definition, $table_name, $column_names); if (!$only_save) { foreach ($schema[$table_name]['fields'] as $name => $specifier) { + // Use the value of the entity ID as the initial value for the + // revision ID field. + if($created_field_name == $this->entityType->getKey('revision')) { + $id_column_name = $table_mapping->getColumnNames($this->entityType->getKey('id')); + $specifier['initial_from_field'] = reset($id_column_name); + } // Check if the field exists because it might already have been // created as part of the earlier entity type update event. if (!$schema_handler->fieldExists($table_name, $name)) {