3 namespace Drupal\Core\Entity\Sql;
5 use Drupal\Core\Database\Connection;
6 use Drupal\Core\Entity\ContentEntityTypeInterface;
7 use Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface;
8 use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
9 use Drupal\Core\Entity\EntityStorageException;
10 use Drupal\Core\Entity\EntityTypeManagerInterface;
11 use Drupal\Core\Field\BaseFieldDefinition;
12 use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
13 use Drupal\Core\Site\Settings;
14 use Drupal\Core\StringTranslation\TranslatableMarkup;
17 * Defines a schema converter for entity types with existing data.
19 * For now, this can only be used to convert an entity type from
20 * non-revisionable to revisionable, however, it should be expanded so it can
21 * also handle converting an entity type to be translatable.
23 class SqlContentEntityStorageSchemaConverter {
26 * The entity type ID this schema converter is responsible for.
30 protected $entityTypeId;
33 * The entity type manager.
35 * @var \Drupal\Core\Entity\EntityTypeManagerInterface
37 protected $entityTypeManager;
40 * The entity definition update manager service.
42 * @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface
44 protected $entityDefinitionUpdateManager;
47 * The last installed schema repository service.
49 * @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface
51 protected $lastInstalledSchemaRepository;
54 * The key-value collection for tracking installed storage schema.
56 * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
58 protected $installedStorageSchema;
61 * The database connection.
63 * @var \Drupal\Core\Database\Connection
68 * SqlContentEntityStorageSchemaConverter constructor.
70 * @param string $entity_type_id
71 * The ID of the entity type.
72 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
73 * The entity type manager.
74 * @param \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $entity_definition_update_manager
75 * Entity definition update manager service.
76 * @param \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository
77 * Last installed schema repository service.
78 * @param \Drupal\Core\Database\Connection $database
79 * Database connection.
81 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) {
82 $this->entityTypeId = $entity_type_id;
83 $this->entityTypeManager = $entity_type_manager;
84 $this->entityDefinitionUpdateManager = $entity_definition_update_manager;
85 $this->lastInstalledSchemaRepository = $last_installed_schema_repository;
86 $this->installedStorageSchema = $installed_storage_schema;
87 $this->database = $database;
91 * Converts an entity type with existing data to be revisionable.
93 * This process does the following tasks:
94 * - creates the schema from scratch with the new revisionable entity type
95 * definition (i.e. the current definition of the entity type from code)
96 * using temporary table names;
97 * - loads the initial entity data by using the last installed entity and
98 * field storage definitions;
99 * - saves the entity data to the temporary tables;
100 * - at the end of the process:
101 * - deletes the original tables and replaces them with the temporary ones
102 * that hold the new (revisionable) entity data;
103 * - updates the installed entity schema data;
104 * - updates the entity type definition in order to trigger the
105 * \Drupal\Core\Entity\EntityTypeEvents::UPDATE event;
106 * - updates the field storage definitions in order to mark the
107 * revisionable ones as such.
109 * In case of an error during the entity save process, the temporary tables
110 * are deleted and the original entity type and field storage definitions are
113 * @param array $sandbox
114 * The sandbox array from a hook_update_N() implementation.
115 * @param string[] $fields_to_update
116 * (optional) An array of field names that should be converted to be
117 * revisionable. Note that the 'langcode' field, if present, is updated
118 * automatically. Defaults to an empty array.
121 * Re-throws any exception raised during the update process.
123 public function convertToRevisionable(array &$sandbox, array $fields_to_update = []) {
124 // If 'progress' is not set, then this will be the first run of the batch.
125 if (!isset($sandbox['progress'])) {
126 // Store the original entity type and field definitions in the $sandbox
127 // array so we can use them later in the update process.
128 $this->collectOriginalDefinitions($sandbox);
130 // Create a temporary environment in which the new data will be stored.
131 $this->createTemporaryDefinitions($sandbox, $fields_to_update);
133 // Create the updated entity schema using temporary tables.
134 /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
135 $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
136 $storage->setTemporary(TRUE);
137 $storage->setEntityType($sandbox['temporary_entity_type']);
138 $storage->onEntityTypeCreate($sandbox['temporary_entity_type']);
141 // Copy over the existing data to the new temporary tables.
142 $this->copyData($sandbox);
144 // If the data copying has finished successfully, we can drop the temporary
145 // tables and call the appropriate update mechanisms.
146 if ($sandbox['#finished'] == 1) {
147 $this->entityTypeManager->useCaches(FALSE);
148 $actual_entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
150 // Rename the original tables so we can put them back in place in case
151 // anything goes wrong.
152 foreach ($sandbox['original_table_mapping']->getTableNames() as $table_name) {
153 $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
154 $this->database->schema()->renameTable($table_name, $old_table_name);
157 // Put the new tables in place and update the entity type and field
158 // storage definitions.
160 $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
161 $storage->setEntityType($actual_entity_type);
162 $storage->setTemporary(FALSE);
163 $actual_table_names = $storage->getTableMapping()->getTableNames();
165 $table_name_mapping = [];
166 foreach ($actual_table_names as $new_table_name) {
167 $temp_table_name = TemporaryTableMapping::getTempTableName($new_table_name);
168 $table_name_mapping[$temp_table_name] = $new_table_name;
169 $this->database->schema()->renameTable($temp_table_name, $new_table_name);
172 // Rename the tables in the cached entity schema data.
173 $entity_schema_data = $this->installedStorageSchema->get($this->entityTypeId . '.entity_schema_data', []);
174 foreach ($entity_schema_data as $temp_table_name => $schema) {
175 if (isset($table_name_mapping[$temp_table_name])) {
176 $entity_schema_data[$table_name_mapping[$temp_table_name]] = $schema;
177 unset($entity_schema_data[$temp_table_name]);
180 $this->installedStorageSchema->set($this->entityTypeId . '.entity_schema_data', $entity_schema_data);
182 // Rename the tables in the cached field schema data.
183 foreach ($sandbox['updated_storage_definitions'] as $storage_definition) {
184 $field_schema_data = $this->installedStorageSchema->get($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), []);
185 foreach ($field_schema_data as $temp_table_name => $schema) {
186 if (isset($table_name_mapping[$temp_table_name])) {
187 $field_schema_data[$table_name_mapping[$temp_table_name]] = $schema;
188 unset($field_schema_data[$temp_table_name]);
191 $this->installedStorageSchema->set($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), $field_schema_data);
194 // Instruct the entity schema handler that data migration has been
195 // handled already and update the entity type.
196 $actual_entity_type->set('requires_data_migration', FALSE);
197 $this->entityDefinitionUpdateManager->updateEntityType($actual_entity_type);
199 // Update the field storage definitions.
200 $this->updateFieldStorageDefinitionsToRevisionable($actual_entity_type, $sandbox['original_storage_definitions'], $fields_to_update);
202 catch (\Exception $e) {
203 // Something went wrong, bring back the original tables.
204 foreach ($sandbox['original_table_mapping']->getTableNames() as $table_name) {
205 // We are in the 'original data recovery' phase, so we need to be sure
206 // that the initial tables can be properly restored.
207 if ($this->database->schema()->tableExists($table_name)) {
208 $this->database->schema()->dropTable($table_name);
211 $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
212 $this->database->schema()->renameTable($old_table_name, $table_name);
215 // Re-throw the original exception.
219 // At this point the update process either finished successfully or any
220 // error has been handled already, so we can drop the backup entity
222 foreach ($sandbox['original_table_mapping']->getTableNames() as $table_name) {
223 $old_table_name = TemporaryTableMapping::getTempTableName($table_name, 'old_');
224 $this->database->schema()->dropTable($old_table_name);
230 * Loads entities from the original storage and saves them to a temporary one.
232 * @param array &$sandbox
233 * The sandbox array from a hook_update_N() implementation.
235 * @throws \Drupal\Core\Entity\EntityStorageException
236 * Thrown in case of an error during the entity save process.
238 protected function copyData(array &$sandbox) {
239 /** @var \Drupal\Core\Entity\Sql\TemporaryTableMapping $temporary_table_mapping */
240 $temporary_table_mapping = $sandbox['temporary_table_mapping'];
241 $temporary_entity_type = $sandbox['temporary_entity_type'];
242 $original_table_mapping = $sandbox['original_table_mapping'];
243 $original_entity_type = $sandbox['original_entity_type'];
245 $original_base_table = $original_entity_type->getBaseTable();
247 $revision_id_key = $temporary_entity_type->getKey('revision');
248 $revision_default_key = $temporary_entity_type->getRevisionMetadataKey('revision_default');
249 $revision_translation_affected_key = $temporary_entity_type->getKey('revision_translation_affected');
251 // If 'progress' is not set, then this will be the first run of the batch.
252 if (!isset($sandbox['progress'])) {
253 $sandbox['progress'] = 0;
254 $sandbox['current_id'] = 0;
255 $sandbox['max'] = $this->database->select($original_base_table)
261 $id = $original_entity_type->getKey('id');
263 // Define the step size.
264 $step_size = Settings::get('entity_update_batch_size', 50);
266 // Get the next entity IDs to migrate.
267 $entity_ids = $this->database->select($original_base_table)
268 ->fields($original_base_table, [$id])
269 ->condition($id, $sandbox['current_id'], '>')
270 ->orderBy($id, 'ASC')
271 ->range(0, $step_size)
273 ->fetchAllKeyed(0, 0);
275 /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
276 $storage = $this->entityTypeManager->getStorage($temporary_entity_type->id());
277 $storage->setEntityType($original_entity_type);
278 $storage->setTableMapping($original_table_mapping);
280 $entities = $storage->loadMultiple($entity_ids);
282 // Now inject the temporary entity type definition and table mapping in the
283 // storage and re-save the entities.
284 $storage->setEntityType($temporary_entity_type);
285 $storage->setTableMapping($temporary_table_mapping);
287 foreach ($entities as $entity_id => $entity) {
289 // Set the revision ID to be same as the entity ID.
290 $entity->set($revision_id_key, $entity_id);
292 // We had no revisions so far, so the existing data belongs to the
293 // default revision now.
294 $entity->set($revision_default_key, TRUE);
296 // Set the 'revision_translation_affected' flag to TRUE to match the
297 // previous API return value: if the field was not defined the value
298 // returned was always TRUE.
299 if ($temporary_entity_type->isTranslatable()) {
300 $entity->set($revision_translation_affected_key, TRUE);
303 // Treat the entity as new in order to make the storage do an INSERT
304 // rather than an UPDATE.
305 $entity->enforceIsNew(TRUE);
307 // Finally, save the entity in the temporary storage.
308 $storage->save($entity);
310 catch (\Exception $e) {
311 // In case of an error during the save process, we need to roll back the
312 // original entity type and field storage definitions and clean up the
314 $this->restoreOriginalDefinitions($sandbox);
316 foreach ($temporary_table_mapping->getTableNames() as $table_name) {
317 $this->database->schema()->dropTable($table_name);
320 // Re-throw the original exception with a helpful message.
321 throw new EntityStorageException("The entity update process failed while processing the entity {$original_entity_type->id()}:$entity_id.", $e->getCode(), $e);
324 $sandbox['progress']++;
325 $sandbox['current_id'] = $entity_id;
328 // If we're not in maintenance mode, the number of entities could change at
329 // any time so make sure that we always use the latest record count.
330 $sandbox['max'] = $this->database->select($original_base_table)
335 $sandbox['#finished'] = empty($sandbox['max']) ? 1 : ($sandbox['progress'] / $sandbox['max']);
339 * Updates field definitions to be revisionable.
341 * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
342 * A content entity type definition.
343 * @param \Drupal\Core\Field\FieldStorageDefinitionInterface[] $storage_definitions
344 * An array of field storage definitions.
345 * @param array $fields_to_update
346 * (optional) An array of field names for which to enable revision support.
347 * Defaults to an empty array.
348 * @param bool $update_cached_definitions
349 * (optional) Whether to update the cached field storage definitions in the
350 * entity definition update manager. Defaults to TRUE.
352 * @return \Drupal\Core\Field\FieldStorageDefinitionInterface[]
353 * An array of updated field storage definitions.
355 protected function updateFieldStorageDefinitionsToRevisionable(ContentEntityTypeInterface $entity_type, array $storage_definitions, array $fields_to_update = [], $update_cached_definitions = TRUE) {
356 $updated_storage_definitions = array_map(function ($storage_definition) {
357 return clone $storage_definition;
358 }, $storage_definitions);
360 // Update the 'langcode' field manually, as it is configured in the base
361 // content entity field definitions.
362 if ($entity_type->hasKey('langcode')) {
363 $fields_to_update = array_merge([$entity_type->getKey('langcode')], $fields_to_update);
366 foreach ($fields_to_update as $field_name) {
367 if (!$updated_storage_definitions[$field_name]->isRevisionable()) {
368 $updated_storage_definitions[$field_name]->setRevisionable(TRUE);
370 if ($update_cached_definitions) {
371 $this->entityDefinitionUpdateManager->updateFieldStorageDefinition($updated_storage_definitions[$field_name]);
376 // Add the revision ID field.
377 $revision_field = BaseFieldDefinition::create('integer')
378 ->setName($entity_type->getKey('revision'))
379 ->setTargetEntityTypeId($entity_type->id())
380 ->setTargetBundle(NULL)
381 ->setLabel(new TranslatableMarkup('Revision ID'))
383 ->setSetting('unsigned', TRUE);
385 if ($update_cached_definitions) {
386 $this->entityDefinitionUpdateManager->installFieldStorageDefinition($revision_field->getName(), $entity_type->id(), $entity_type->getProvider(), $revision_field);
388 $updated_storage_definitions[$entity_type->getKey('revision')] = $revision_field;
390 // Add the default revision flag field.
391 $field_name = $entity_type->getRevisionMetadataKey('revision_default');
392 $storage_definition = BaseFieldDefinition::create('boolean')
393 ->setName($field_name)
394 ->setTargetEntityTypeId($entity_type->id())
395 ->setTargetBundle(NULL)
396 ->setLabel(t('Default revision'))
397 ->setDescription(t('A flag indicating whether this was a default revision when it was saved.'))
398 ->setStorageRequired(TRUE)
399 ->setTranslatable(FALSE)
400 ->setRevisionable(TRUE);
402 if ($update_cached_definitions) {
403 $this->entityDefinitionUpdateManager->installFieldStorageDefinition($field_name, $entity_type->id(), $entity_type->getProvider(), $storage_definition);
405 $updated_storage_definitions[$field_name] = $storage_definition;
407 // Add the 'revision_translation_affected' field if needed.
408 if ($entity_type->isTranslatable()) {
409 $revision_translation_affected_field = BaseFieldDefinition::create('boolean')
410 ->setName($entity_type->getKey('revision_translation_affected'))
411 ->setTargetEntityTypeId($entity_type->id())
412 ->setTargetBundle(NULL)
413 ->setLabel(new TranslatableMarkup('Revision translation affected'))
414 ->setDescription(new TranslatableMarkup('Indicates if the last edit of a translation belongs to current revision.'))
416 ->setRevisionable(TRUE)
417 ->setTranslatable(TRUE);
419 if ($update_cached_definitions) {
420 $this->entityDefinitionUpdateManager->installFieldStorageDefinition($revision_translation_affected_field->getName(), $entity_type->id(), $entity_type->getProvider(), $revision_translation_affected_field);
422 $updated_storage_definitions[$entity_type->getKey('revision_translation_affected')] = $revision_translation_affected_field;
425 return $updated_storage_definitions;
429 * Collects the original definitions of an entity type and its fields.
431 * @param array &$sandbox
432 * A sandbox array from a hook_update_N() implementation.
434 protected function collectOriginalDefinitions(array &$sandbox) {
435 $original_entity_type = $this->lastInstalledSchemaRepository->getLastInstalledDefinition($this->entityTypeId);
436 $original_storage_definitions = $this->lastInstalledSchemaRepository->getLastInstalledFieldStorageDefinitions($this->entityTypeId);
438 /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
439 $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
440 $storage->setEntityType($original_entity_type);
441 $original_table_mapping = $storage->getTableMapping($original_storage_definitions);
443 $sandbox['original_entity_type'] = $original_entity_type;
444 $sandbox['original_storage_definitions'] = $original_storage_definitions;
445 $sandbox['original_table_mapping'] = $original_table_mapping;
447 $sandbox['original_entity_schema_data'] = $this->installedStorageSchema->get($this->entityTypeId . '.entity_schema_data', []);
448 foreach ($original_storage_definitions as $storage_definition) {
449 $sandbox['original_field_schema_data'][$storage_definition->getName()] = $this->installedStorageSchema->get($this->entityTypeId . '.field_schema_data.' . $storage_definition->getName(), []);
454 * Restores the entity type, field storage definitions and their schema data.
456 * @param array $sandbox
457 * The sandbox array from a hook_update_N() implementation.
459 protected function restoreOriginalDefinitions(array $sandbox) {
460 $original_entity_type = $sandbox['original_entity_type'];
461 $original_storage_definitions = $sandbox['original_storage_definitions'];
462 $original_entity_schema_data = $sandbox['original_entity_schema_data'];
463 $original_field_schema_data = $sandbox['original_field_schema_data'];
465 $this->lastInstalledSchemaRepository->setLastInstalledDefinition($original_entity_type);
466 $this->lastInstalledSchemaRepository->setLastInstalledFieldStorageDefinitions($original_entity_type->id(), $original_storage_definitions);
468 $this->installedStorageSchema->set($original_entity_type->id() . '.entity_schema_data', $original_entity_schema_data);
469 foreach ($original_field_schema_data as $field_name => $field_schema_data) {
470 $this->installedStorageSchema->set($original_entity_type->id() . '.field_schema_data.' . $field_name, $field_schema_data);
475 * Creates temporary entity type, field storage and table mapping objects.
477 * @param array &$sandbox
478 * A sandbox array from a hook_update_N() implementation.
479 * @param string[] $fields_to_update
480 * (optional) An array of field names that should be converted to be
481 * revisionable. Note that the 'langcode' field, if present, is updated
482 * automatically. Defaults to an empty array.
484 protected function createTemporaryDefinitions(array &$sandbox, array $fields_to_update) {
485 // Make sure to get the latest entity type definition from code.
486 $this->entityTypeManager->useCaches(FALSE);
487 $actual_entity_type = $this->entityTypeManager->getDefinition($this->entityTypeId);
489 $temporary_entity_type = clone $actual_entity_type;
490 $temporary_entity_type->set('base_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getBaseTable()));
491 $temporary_entity_type->set('revision_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getRevisionTable()));
492 if ($temporary_entity_type->isTranslatable()) {
493 $temporary_entity_type->set('data_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getDataTable()));
494 $temporary_entity_type->set('revision_data_table', TemporaryTableMapping::getTempTableName($temporary_entity_type->getRevisionDataTable()));
497 /** @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage */
498 $storage = $this->entityTypeManager->getStorage($this->entityTypeId);
499 $storage->setTemporary(TRUE);
500 $storage->setEntityType($temporary_entity_type);
502 $updated_storage_definitions = $this->updateFieldStorageDefinitionsToRevisionable($temporary_entity_type, $sandbox['original_storage_definitions'], $fields_to_update, FALSE);
503 $temporary_table_mapping = $storage->getTableMapping($updated_storage_definitions);
505 $sandbox['temporary_entity_type'] = $temporary_entity_type;
506 $sandbox['temporary_table_mapping'] = $temporary_table_mapping;
507 $sandbox['updated_storage_definitions'] = $updated_storage_definitions;