diff --git a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php index cfe0736b15..a4f8b5ca9a 100644 --- a/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php +++ b/core/lib/Drupal/Core/Entity/Sql/SqlContentEntityStorageSchema.php @@ -1199,12 +1199,12 @@ protected function addTableDefaults(&$schema) { * The entity type. * @param array $schema * The table schema, passed by reference. - * - * @return array - * A partial schema array for the base table. */ protected function processBaseTable(ContentEntityTypeInterface $entity_type, array &$schema) { - $this->processIdentifierSchema($schema, $entity_type->getKey('id')); + // Process the schema for the 'id' entity key only if it exists. + if ($entity_type->hasKey('id')) { + $this->processIdentifierSchema($schema, $entity_type->getKey('id')); + } } /** @@ -1214,12 +1214,12 @@ protected function processBaseTable(ContentEntityTypeInterface $entity_type, arr * The entity type. * @param array $schema * The table schema, passed by reference. - * - * @return array - * A partial schema array for the base table. */ protected function processRevisionTable(ContentEntityTypeInterface $entity_type, array &$schema) { - $this->processIdentifierSchema($schema, $entity_type->getKey('revision')); + // Process the schema for the 'revision' entity key only if it exists. + if ($entity_type->hasKey('revision')) { + $this->processIdentifierSchema($schema, $entity_type->getKey('revision')); + } } /** @@ -1358,11 +1358,23 @@ protected function createSharedTableSchema(FieldStorageDefinitionInterface $stor // Create field columns. $schema[$table_name] = $this->getSharedTableFieldSchema($storage_definition, $table_name, $column_names); if (!$only_save) { + // The entity schema needs to be checked because the field schema is + // potentially incomplete. + // @todo Fix this in https://www.drupal.org/node/2929120. + $entity_schema = $this->getEntitySchema($this->entityType); foreach ($schema[$table_name]['fields'] as $name => $specifier) { + // Check if the field is part of the primary keys and pass along + // this information when adding the field. + // @see \Drupal\Core\Database\Schema::addField() + $new_keys = []; + if (isset($entity_schema[$table_name]['primary key']) && array_intersect($column_names, $entity_schema[$table_name]['primary key'])) { + $new_keys = ['primary key' => $entity_schema[$table_name]['primary key']]; + } + // 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)) { - $schema_handler->addField($table_name, $name, $specifier); + $schema_handler->addField($table_name, $name, $specifier, $new_keys); } } if (!empty($schema[$table_name]['indexes'])) { diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntitySchemaTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntitySchemaTest.php index 9a20bd3777..fe46cc76f6 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntitySchemaTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntitySchemaTest.php @@ -3,11 +3,13 @@ namespace Drupal\KernelTests\Core\Entity; use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; /** - * Tests adding a custom bundle field. + * Tests the default entity storage schema handler. * - * @group system + * @group Entity */ class EntitySchemaTest extends EntityKernelTestBase { @@ -109,6 +111,168 @@ public function testEntitySchemaUpdate() { $this->assertTrue($schema_handler->tableExists($dedicated_tables[0]), new FormattableMarkup('Field schema correct for the @table table.', ['@table' => $table])); } + /** + * Tests deleting and creating a field that is part of a primary key. + * + * @param string $entity_type_id + * The ID of the entity type whose schema is being tested. + * @param string $field_name + * The name of the field that is being re-installed. + * + * @dataProvider providerTestPrimaryKeyUpdate + */ + public function testPrimaryKeyUpdate($entity_type_id, $field_name) { + // EntityKernelTestBase::setUp() already installs the schema for the + // 'entity_test' entity type. + if ($entity_type_id !== 'entity_test') { + $this->installEntitySchema($entity_type_id); + } + + /* @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $update_manager */ + $update_manager = $this->container->get('entity.definition_update_manager'); + $entity_type = $update_manager->getEntityType($entity_type_id); + + /* @see \Drupal\Core\Entity\ContentEntityBase::baseFieldDefinitions() */ + switch ($field_name) { + case 'id': + $field = BaseFieldDefinition::create('integer') + ->setLabel('ID') + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + break; + + case 'revision_id': + $field = BaseFieldDefinition::create('integer') + ->setLabel('Revision ID') + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + break; + + case 'langcode': + $field = BaseFieldDefinition::create('language') + ->setLabel('Language'); + if ($entity_type->isRevisionable()) { + $field->setRevisionable(TRUE); + } + if ($entity_type->isTranslatable()) { + $field->setTranslatable(TRUE); + } + break; + } + + $field + ->setName($field_name) + ->setTargetEntityTypeId($entity_type_id) + ->setProvider($entity_type->getProvider()); + + // First, test explicitly deleting and re-installing a field. Make sure that + // all primary keys are there to start with. + $this->assertPrimaryKeys($entity_type); + + // Then uninstall the field and make sure all primary keys that the field + // was part of have been updated. Uninstalling a primary key field has + // different consequences, depending on which field has been uninstalled: + // - id: the entity type does not have any table in the database anymore; + // - revision: the entity type is no longer revisionable, so it doesn't have + // any revision or revision_data tables anymore; + // - langcode: the entity type is no longer translatable, so it doesn't have + // any data or revision_data tables anymore; + $update_manager->uninstallFieldStorageDefinition($field); + $this->assertPrimaryKeys($entity_type, $field_name); + + // Finally, reinstall the field and make sure the primary keys have been + // recreated. + $update_manager->installFieldStorageDefinition($field->getName(), $entity_type_id, $field->getProvider(), $field); + $this->assertPrimaryKeys($entity_type); + + // Now test updating a field without data. This will end up deleting + // and re-creating the field, similar to the code above. + $update_manager->updateFieldStorageDefinition($field); + $this->assertPrimaryKeys($entity_type); + + // Now test updating a field with data. + /* @var \Drupal\Core\Entity\FieldableEntityStorageInterface $storage */ + $storage = $this->entityManager->getStorage($entity_type_id); + // The schema of ID fields is incorrectly recreated as 'int' instead of + // 'serial', so we manually have to specify an ID. + // @todo Remove this in https://www.drupal.org/project/drupal/issues/2928906 + $storage->create(['id' => 1, 'revision_id' => 1])->save(); + $this->assertTrue($storage->countFieldData($field, TRUE)); + $update_manager->updateFieldStorageDefinition($field); + $this->assertPrimaryKeys($entity_type); + $this->assertTrue($storage->countFieldData($field, TRUE)); + } + + /** + * Asserts that the primary keys are as expected for a given entity type. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type whose primary keys are being checked. + * @param string $removed_field + * (optional) The name of a field that has been removed. If passed, any + * primary key that would otherwise have contained this field will be + * checked to have been dropped. + */ + protected function assertPrimaryKeys(EntityTypeInterface $entity_type, $removed_field = NULL) { + $base_table = $entity_type->getBaseTable(); + $revision_table = $entity_type->getRevisionTable(); + $data_table = $entity_type->getDataTable(); + $revision_data_table = $entity_type->getRevisionDataTable(); + + $id_key = ($removed_field !== 'id') ? $entity_type->getKey('id') : NULL; + $revision_key = ($removed_field !== 'revision_id') ? $entity_type->getKey('revision') : NULL; + $langcode_key = ($removed_field !== 'langcode') ? $entity_type->getKey('langcode') : NULL; + + // Build up a map of primary keys depending on the entity type + // configuration. If the field that is being removed is part of a table's + // primary key make sure that the primary key was correctly dropped. + $key_map = []; + $key_map[$base_table] = $id_key ? [$id_key] : []; + if ($entity_type->isRevisionable()) { + $key_map[$revision_table] = $revision_key ? [$revision_key] : []; + } + if ($entity_type->isTranslatable()) { + $key_map[$data_table] = ($id_key && $langcode_key) ? [$id_key, $langcode_key] : []; + } + if ($entity_type->isRevisionable() && $entity_type->isTranslatable()) { + $key_map[$revision_data_table] = ($revision_key && $langcode_key) ? [$revision_key, $langcode_key] : []; + } + + $schema = $this->database->schema(); + $find_primary_key_columns = new \ReflectionMethod(get_class($schema), 'findPrimaryKeyColumns'); + $find_primary_key_columns->setAccessible(TRUE); + foreach ($key_map as $table => $primary_key) { + $this->assertEquals($primary_key, $find_primary_key_columns->invoke($schema, $table)); + } + } + + /** + * Provides test cases for EntitySchemaTest::testPrimaryKeyUpdate() + * + * @return array + * An array of test cases consisting of an entity type ID and a field name. + */ + public function providerTestPrimaryKeyUpdate() { + // Build up test cases for all possible entity type configurations. + // For each entity type we test reinstalling each field that is part of + // any table's primary key. + $tests = []; + + $tests['entity_test:id'] = ['entity_test', 'id']; + + $tests['entity_test_rev:id'] = ['entity_test_rev', 'id']; + $tests['entity_test_rev:revision_id'] = ['entity_test_rev', 'revision_id']; + + $tests['entity_test_mul:id'] = ['entity_test_mul', 'id']; + $tests['entity_test_mul:langcode'] = ['entity_test_mul', 'langcode']; + + $tests['entity_test_mulrev:id'] = ['entity_test_mulrev', 'id']; + $tests['entity_test_mulrev:revision_id'] = ['entity_test_mulrev', 'revision_id']; + $tests['entity_test_mulrev:langcode'] = ['entity_test_mulrev', 'langcode']; + + return $tests; + } + /** * {@inheritdoc} */ diff --git a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php index c9359bd9b8..d05172ef0d 100644 --- a/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/Sql/SqlContentEntityStorageTest.php @@ -337,6 +337,14 @@ public function testOnEntityTypeCreate() { $this->entityType->expects($this->once()) ->method('getKeys') ->will($this->returnValue(['id' => 'id'])); + $this->entityType->expects($this->any()) + ->method('hasKey') + ->will($this->returnValueMap([ + // SqlContentEntityStorageSchema::initializeBaseTable() + ['revision', FALSE], + // SqlContentEntityStorageSchema::processBaseTable() + ['id', TRUE], + ])); $this->entityType->expects($this->any()) ->method('getKey') ->will($this->returnValueMap([