Commit 6702a79a authored by catch's avatar catch

Issue #2346019 by amateescu, tstoeckler, jibran, plach, Berdir, larowlan:...

Issue #2346019 by amateescu, tstoeckler, jibran, plach, Berdir, larowlan: Handle initial values when creating a new field storage definition
parent e798a834
......@@ -12,6 +12,7 @@
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException;
use Drupal\Core\Entity\Schema\DynamicallyFieldableEntityStorageSchemaInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldException;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\FieldStorageConfigInterface;
......@@ -201,7 +202,10 @@ public function requiresFieldStorageSchemaChanges(FieldStorageDefinitionInterfac
return FALSE;
}
return $this->getSchemaFromStorageDefinition($storage_definition) != $this->loadFieldSchemaData($original);
$current_schema = $this->getSchemaFromStorageDefinition($storage_definition);
$this->processFieldStorageSchema($current_schema);
return $current_schema != $this->loadFieldSchemaData($original);
}
/**
......@@ -852,6 +856,7 @@ protected function loadFieldSchemaData(FieldStorageDefinitionInterface $storage_
* The field schema data array.
*/
protected function saveFieldSchemaData(FieldStorageDefinitionInterface $storage_definition, $schema) {
$this->processFieldStorageSchema($schema);
$this->installedStorageSchema()->set($storage_definition->getTargetEntityTypeId() . '.field_schema_data.' . $storage_definition->getName(), $schema);
}
......@@ -1101,6 +1106,23 @@ protected function processIdentifierSchema(&$schema, $key) {
unset($schema['fields'][$key]['default']);
}
/**
* Processes the schema for a field storage definition.
*
* @param array &$field_storage_schema
* An array that contains the schema data for a field storage definition.
*/
protected function processFieldStorageSchema(array &$field_storage_schema) {
// Clean up some schema properties that should not be taken into account
// after a field storage has been created.
foreach ($field_storage_schema as $table_name => $table_schema) {
foreach ($table_schema['fields'] as $key => $schema) {
unset($field_storage_schema[$table_name]['fields'][$key]['initial']);
unset($field_storage_schema[$table_name]['fields'][$key]['initial_from_field']);
}
}
}
/**
* Performs the specified operation on a field.
*
......@@ -1613,29 +1635,62 @@ protected function hasNullFieldPropertyData($table_name, $column_name) {
* - foreign keys: The schema definition for the foreign keys.
*
* @throws \Drupal\Core\Field\FieldException
* Exception thrown if the schema contains reserved column names.
* Exception thrown if the schema contains reserved column names or if the
* initial values definition is invalid.
*/
protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping) {
$schema = [];
$table_mapping = $this->storage->getTableMapping();
$field_schema = $storage_definition->getSchema();
// Check that the schema does not include forbidden column names.
if (array_intersect(array_keys($field_schema['columns']), $this->storage->getTableMapping()->getReservedColumns())) {
if (array_intersect(array_keys($field_schema['columns']), $table_mapping->getReservedColumns())) {
throw new FieldException("Illegal field column names on {$storage_definition->getName()}");
}
$field_name = $storage_definition->getName();
$base_table = $this->storage->getBaseTable();
// Define the initial values, if any.
$initial_value = $initial_value_from_field = [];
$storage_definition_is_new = empty($this->loadFieldSchemaData($storage_definition));
if ($storage_definition_is_new && $storage_definition instanceof BaseFieldDefinition && $table_mapping->allowsSharedTableStorage($storage_definition)) {
if (($initial_storage_value = $storage_definition->getInitialValue()) && !empty($initial_storage_value)) {
// We only support initial values for fields that are stored in shared
// tables (i.e. single-value fields).
// @todo Implement initial value support for multi-value fields in
// https://www.drupal.org/node/2883851.
$initial_value = reset($initial_storage_value);
}
if ($initial_value_field_name = $storage_definition->getInitialValueFromField()) {
// Check that the field used for populating initial values is valid. We
// must use the last installed version of that, as the new field might
// be created in an update function and the storage definition of the
// "from" field might get changed later.
$last_installed_storage_definitions = $this->entityManager->getLastInstalledFieldStorageDefinitions($this->entityType->id());
if (!isset($last_installed_storage_definitions[$initial_value_field_name])) {
throw new FieldException("Illegal initial value definition on {$storage_definition->getName()}: The field $initial_value_field_name does not exist.");
}
if ($storage_definition->getType() !== $last_installed_storage_definitions[$initial_value_field_name]->getType()) {
throw new FieldException("Illegal initial value definition on {$storage_definition->getName()}: The field types do not match.");
}
if (!$table_mapping->allowsSharedTableStorage($last_installed_storage_definitions[$initial_value_field_name])) {
throw new FieldException("Illegal initial value definition on {$storage_definition->getName()}: Both fields have to be stored in the shared entity tables.");
}
$initial_value_from_field = $table_mapping->getColumnNames($initial_value_field_name);
}
}
// A shared table contains rows for entities where the field is empty
// (since other fields stored in the same table might not be empty), thus
// the only columns that can be 'not null' are those for required
// properties of required fields. However, even those would break in the
// case where a new field is added to a table that contains existing rows.
// For now, we only hardcode 'not null' to a couple "entity keys", in order
// to keep their indexes optimized.
// @todo Revisit once we have support for 'initial' in
// https://www.drupal.org/node/2346019.
// properties of required fields. For now, we only hardcode 'not null' to a
// few "entity keys", in order to keep their indexes optimized.
// @todo Fix this in https://www.drupal.org/node/2841291.
$not_null_keys = $this->entityType->getKeys();
// Label fields are not necessarily required.
unset($not_null_keys['label']);
......@@ -1654,6 +1709,14 @@ protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $st
$schema['fields'][$schema_field_name] = $column_schema;
$schema['fields'][$schema_field_name]['not null'] = in_array($field_name, $not_null_keys);
// Use the initial value of the field storage, if available.
if ($initial_value && isset($initial_value[$field_column_name])) {
$schema['fields'][$schema_field_name]['initial'] = $initial_value[$field_column_name];
}
elseif (!empty($initial_value_from_field)) {
$schema['fields'][$schema_field_name]['initial_from_field'] = $initial_value_from_field[$field_column_name];
}
}
if (!empty($field_schema['indexes'])) {
......
......@@ -509,6 +509,89 @@ public function setDefaultValueCallback($callback) {
return $this;
}
/**
* Returns the initial value for the field.
*
* @return array
* The initial value for the field, as a numerically indexed array of items,
* each item being a property/value array (array() for no default value).
*/
public function getInitialValue() {
$value = isset($this->definition['initial_value']) ? $this->definition['initial_value'] : [];
// Normalize into the "array keyed by delta" format.
if (isset($value) && !is_array($value)) {
$value = [
[$this->getMainPropertyName() => $value],
];
}
return $value;
}
/**
* Sets an initial value for the field.
*
* @param mixed $value
* The initial value for the field. This can be either:
* - a literal, in which case it will be assigned to the first property of
* the first item;
* - a numerically indexed array of items, each item being a property/value
* array;
* - a non-numerically indexed array, in which case the array is assumed to
* be a property/value array and used as the first item;
* - an empty array for no initial value.
*
* @return $this
*/
public function setInitialValue($value) {
// @todo Implement initial value support for multi-value fields in
// https://www.drupal.org/node/2883851.
if ($this->isMultiple()) {
throw new FieldException('Multi-value fields can not have an initial value.');
}
if ($value === NULL) {
$value = [];
}
// Unless the value is an empty array, we may need to transform it.
if (!is_array($value) || !empty($value)) {
if (!is_array($value)) {
$value = [[$this->getMainPropertyName() => $value]];
}
elseif (is_array($value) && !is_numeric(array_keys($value)[0])) {
$value = [0 => $value];
}
}
$this->definition['initial_value'] = $value;
return $this;
}
/**
* Returns the name of the field that will be used for getting initial values.
*
* @return string|null
* The field name.
*/
public function getInitialValueFromField() {
return isset($this->definition['initial_value_from_field']) ? $this->definition['initial_value_from_field'] : NULL;
}
/**
* Sets a field that will be used for getting initial values.
*
* @param string $field_name
* The name of the field that will be used for getting initial values.
*
* @return $this
*/
public function setInitialValueFromField($field_name) {
$this->definition['initial_value_from_field'] = $field_name;
return $this;
}
/**
* {@inheritdoc}
*/
......
......@@ -11,6 +11,7 @@
use Drupal\Core\Entity\EntityTypeEvents;
use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Field\FieldException;
use Drupal\Core\Field\FieldStorageDefinitionEvents;
use Drupal\Core\Language\LanguageInterface;
use Drupal\entity_test_update\Entity\EntityTestUpdate;
......@@ -817,4 +818,119 @@ public function testLongNameFieldIndexes() {
$this->assertFalse($this->entityDefinitionUpdateManager->needsUpdates(), 'Entity and field schema data are correctly detected.');
}
/**
* Tests adding a base field with initial values.
*/
public function testInitialValue() {
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
$db_schema = $this->database->schema();
// Create two entities before adding the base field.
/** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */
$storage->create()->save();
$storage->create()->save();
// Add a base field with an initial value.
$this->addBaseField();
$storage_definition = BaseFieldDefinition::create('string')
->setLabel(t('A new base field'))
->setInitialValue('test value');
$this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
$this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
// Check that the initial values have been applied.
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
$entities = $storage->loadMultiple();
$this->assertEquals('test value', $entities[1]->get('new_base_field')->value);
$this->assertEquals('test value', $entities[2]->get('new_base_field')->value);
}
/**
* Tests adding a base field with initial values inherited from another field.
*/
public function testInitialValueFromField() {
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
$db_schema = $this->database->schema();
// Create two entities before adding the base field.
/** @var \Drupal\entity_test\Entity\EntityTestUpdate $entity */
$storage->create(['name' => 'First entity'])->save();
$storage->create(['name' => 'Second entity'])->save();
// Add a base field with an initial value inherited from another field.
$this->addBaseField();
$storage_definition = BaseFieldDefinition::create('string')
->setLabel(t('A new base field'))
->setInitialValueFromField('name');
$this->assertFalse($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' does not exist before applying the update.");
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
$this->assertTrue($db_schema->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table.");
// Check that the initial values have been applied.
$storage = \Drupal::entityTypeManager()->getStorage('entity_test_update');
$entities = $storage->loadMultiple();
$this->assertEquals('First entity', $entities[1]->get('new_base_field')->value);
$this->assertEquals('Second entity', $entities[2]->get('new_base_field')->value);
}
/**
* Tests the error handling when using initial values from another field.
*/
public function testInitialValueFromFieldErrorHandling() {
// Check that setting invalid values for 'initial value from field' doesn't
// work.
try {
$this->addBaseField();
$storage_definition = BaseFieldDefinition::create('string')
->setLabel(t('A new base field'))
->setInitialValueFromField('field_that_does_not_exist');
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
$this->fail('Using a non-existent field as initial value does not work.');
}
catch (FieldException $e) {
$this->assertEquals('Illegal initial value definition on new_base_field: The field field_that_does_not_exist does not exist.', $e->getMessage());
$this->pass('Using a non-existent field as initial value does not work.');
}
try {
$this->addBaseField();
$storage_definition = BaseFieldDefinition::create('integer')
->setLabel(t('A new base field'))
->setInitialValueFromField('name');
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $storage_definition);
$this->fail('Using a field of a different type as initial value does not work.');
}
catch (FieldException $e) {
$this->assertEquals('Illegal initial value definition on new_base_field: The field types do not match.', $e->getMessage());
$this->pass('Using a field of a different type as initial value does not work.');
}
try {
// Add a base field that will not be stored in the shared tables.
$initial_field = BaseFieldDefinition::create('string')
->setName('initial_field')
->setLabel(t('An initial field'))
->setCardinality(2);
$this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field]);
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('initial_field', 'entity_test_update', 'entity_test', $initial_field);
// Now add the base field which will try to use the previously added field
// as the source of its initial values.
$new_base_field = BaseFieldDefinition::create('string')
->setName('new_base_field')
->setLabel(t('A new base field'))
->setInitialValueFromField('initial_field');
$this->state->set('entity_test_update.additional_base_field_definitions', ['initial_field' => $initial_field, 'new_base_field' => $new_base_field]);
$this->entityDefinitionUpdateManager->installFieldStorageDefinition('new_base_field', 'entity_test_update', 'entity_test', $new_base_field);
$this->fail('Using a field that is not stored in the shared tables as initial value does not work.');
}
catch (FieldException $e) {
$this->assertEquals('Illegal initial value definition on new_base_field: Both fields have to be stored in the shared entity tables.', $e->getMessage());
$this->pass('Using a field that is not stored in the shared tables as initial value does not work.');
}
}
}
......@@ -194,6 +194,52 @@ public function testFieldDefaultValue() {
$this->assertEquals([], $definition->getDefaultValue($entity));
}
/**
* Tests field initial value.
*
* @covers ::getInitialValue
* @covers ::setInitialValue
*/
public function testFieldInitialValue() {
$definition = BaseFieldDefinition::create($this->fieldType);
$default_value = [
'value' => $this->randomMachineName(),
];
$expected_default_value = [$default_value];
$definition->setInitialValue($default_value);
$entity = $this->getMockBuilder('Drupal\Core\Entity\ContentEntityBase')
->disableOriginalConstructor()
->getMock();
// Set the field item list class to be used to avoid requiring the typed
// data manager to retrieve it.
$definition->setClass('Drupal\Core\Field\FieldItemList');
$this->assertEquals($expected_default_value, $definition->getInitialValue($entity));
$data_definition = $this->getMockBuilder('Drupal\Core\TypedData\DataDefinition')
->disableOriginalConstructor()
->getMock();
$data_definition->expects($this->any())
->method('getClass')
->will($this->returnValue('Drupal\Core\Field\FieldItemBase'));
$definition->setItemDefinition($data_definition);
// Set default value only with a literal.
$definition->setInitialValue($default_value['value']);
$this->assertEquals($expected_default_value, $definition->getInitialValue($entity));
// Set default value with an indexed array.
$definition->setInitialValue($expected_default_value);
$this->assertEquals($expected_default_value, $definition->getInitialValue($entity));
// Set default value with an empty array.
$definition->setInitialValue([]);
$this->assertEquals([], $definition->getInitialValue($entity));
// Set default value with NULL.
$definition->setInitialValue(NULL);
$this->assertEquals([], $definition->getInitialValue($entity));
}
/**
* Tests field translatable methods.
*
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment