Skip to content
SqlContentEntityStorageSchema.php 66.8 KiB
Newer Older
 * Contains \Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema.
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Exception\FieldStorageDefinitionUpdateForbiddenException;
use Drupal\Core\Entity\Schema\DynamicallyFieldableEntityStorageSchemaInterface;
use Drupal\Core\Field\FieldException;
use Drupal\Core\Field\FieldStorageDefinitionInterface;
use Drupal\field\FieldStorageConfigInterface;

/**
 * Defines a schema handler that supports revisionable, translatable entities.
 *
 * Entity types may extend this class and optimize the generated schema for all
 * entity base tables by overriding getEntitySchema() for cross-field
 * optimizations and getSharedTableFieldSchema() for optimizations applying to
 * a single field.
class SqlContentEntityStorageSchema implements DynamicallyFieldableEntityStorageSchemaInterface {
  /**
   * The entity manager.
   *
   * @var \Drupal\Core\Entity\EntityManagerInterface
   */
  protected $entityManager;

  /**
   * The entity type this schema builder is responsible for.
   *
   * @var \Drupal\Core\Entity\ContentEntityTypeInterface
   */
  protected $entityType;

  /**
   * The storage field definitions for this entity type.
   *
   * @var \Drupal\Core\Field\FieldStorageDefinitionInterface[]
  /**
   * The original storage field definitions for this entity type. Used during
   * field schema updates.
   *
   * @var \Drupal\Core\Field\FieldDefinitionInterface[]
   */
  protected $originalDefinitions;

  /**
   * The storage object for the given entity type.
   *
   * @var \Drupal\Core\Entity\Sql\SqlContentEntityStorage
   */
  protected $storage;

  /**
   * A static cache of the generated schema array.
   *
   * @var array
   */
  protected $schema;

  /**
   * The database connection to be used.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $database;

  /**
   * The key-value collection for tracking installed storage schema.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   */
  protected $installedStorageSchema;

   * Constructs a SqlContentEntityStorageSchema.
   *
   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
   *   The entity manager.
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   * @param \Drupal\Core\Entity\Sql\SqlContentEntityStorage $storage
   *   The storage of the entity type. This must be an SQL-based storage.
   * @param \Drupal\Core\Database\Connection $database
   *   The database connection to be used.
  public function __construct(EntityManagerInterface $entity_manager, ContentEntityTypeInterface $entity_type, SqlContentEntityStorage $storage, Connection $database) {
    $this->entityManager = $entity_manager;
    $this->entityType = $entity_type;
    $this->fieldStorageDefinitions = $entity_manager->getFieldStorageDefinitions($entity_type->id());
    $this->storage = $storage;
    $this->database = $database;
  }

  /**
   * Returns the keyvalue collection for tracking the installed schema.
   *
   * @return \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   *
   * @todo Inject this dependency in the constructor once this class can be
   *   instantiated as a regular entity handler:
   *   https://www.drupal.org/node/2332857.
   */
  protected function installedStorageSchema() {
    if (!isset($this->installedStorageSchema)) {
      $this->installedStorageSchema = \Drupal::keyValue('entity.storage_schema.sql');
    }
    return $this->installedStorageSchema;
  }

  /**
   * {@inheritdoc}
   */
  public function requiresEntityStorageSchemaChanges(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
    return
      $entity_type->getStorageClass() != $original->getStorageClass() ||
      $entity_type->isRevisionable() != $original->isRevisionable() ||
      $entity_type->isTranslatable() != $original->isTranslatable() ||
      // Detect changes in key or index definitions.
      $this->getEntitySchemaData($entity_type, $this->getEntitySchema($entity_type, TRUE)) != $this->loadEntitySchemaData($original);
  }

  /**
   * {@inheritdoc}
   */
  public function requiresFieldStorageSchemaChanges(FieldStorageDefinitionInterface $storage_definition, FieldStorageDefinitionInterface $original) {
    $table_mapping = $this->storage->getTableMapping();

    if (
      $storage_definition->hasCustomStorage() != $original->hasCustomStorage() ||
      $storage_definition->getSchema() != $original->getSchema() ||
      $storage_definition->isRevisionable() != $original->isRevisionable() ||
      $storage_definition->isTranslatable() != $original->isTranslatable() ||
      $table_mapping->allowsSharedTableStorage($storage_definition) != $table_mapping->allowsSharedTableStorage($original) ||
      $table_mapping->requiresDedicatedTableStorage($storage_definition) != $table_mapping->requiresDedicatedTableStorage($original)
    ) {
      return TRUE;
    }

    if ($table_mapping->requiresDedicatedTableStorage($storage_definition)) {
      return $this->getDedicatedTableSchema($storage_definition) != $this->loadFieldSchemaData($original);
    }
    elseif ($table_mapping->allowsSharedTableStorage($storage_definition)) {
      $field_name = $storage_definition->getName();
      $schema = array();
      foreach (array_diff($table_mapping->getTableNames(), $table_mapping->getDedicatedTableNames()) as $table_name) {
        if (in_array($field_name, $table_mapping->getFieldNames($table_name))) {
          $column_names = $table_mapping->getColumnNames($storage_definition->getName());
          $schema[$table_name] = $this->getSharedTableFieldSchema($storage_definition, $table_name, $column_names);
        }
      }
      return $schema != $this->loadFieldSchemaData($original);
    }
    else {
      // The field has custom storage, so we don't know if a schema change is
      // needed or not, but since per the initial checks earlier in this
      // function, nothing about the definition changed that we manage, we
      // return FALSE.
      return FALSE;
    }
  }

  /**
   * {@inheritdoc}
   */
  public function requiresEntityDataMigration(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
    // 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();
    if (!class_exists($original_storage_class)) {
      return TRUE;
    // Use the original entity type since the storage has not been updated.
    $original_storage = $this->entityManager->createHandlerInstance($original_storage_class, $original);
    return $original_storage->hasData();
  }

  /**
   * {@inheritdoc}
   */
  public function requiresFieldDataMigration(FieldStorageDefinitionInterface $storage_definition, FieldStorageDefinitionInterface $original) {
    return !$this->storage->countFieldData($original, TRUE);
  /**
   * {@inheritdoc}
   */
  public function onEntityTypeCreate(EntityTypeInterface $entity_type) {
    $schema_handler = $this->database->schema();

    // Create entity tables.
    $schema = $this->getEntitySchema($entity_type, TRUE);
    foreach ($schema as $table_name => $table_schema) {
      if (!$schema_handler->tableExists($table_name)) {
        $schema_handler->createTable($table_name, $table_schema);
      }
    }

    // Create dedicated field tables.
    $field_storage_definitions = $this->entityManager->getFieldStorageDefinitions($entity_type->id());
    $table_mapping = $this->storage->getTableMapping($field_storage_definitions);
    foreach ($field_storage_definitions as $field_storage_definition) {
      if ($table_mapping->requiresDedicatedTableStorage($field_storage_definition)) {
        $this->createDedicatedTableSchema($field_storage_definition);
      }
      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);
      }
    }

    // Save data about entity indexes and keys.
    $this->saveEntitySchemaData($entity_type, $schema);
  }

  /**
   * {@inheritdoc}
   */
  public function onEntityTypeUpdate(EntityTypeInterface $entity_type, EntityTypeInterface $original) {
    $this->checkEntityType($entity_type);
    $this->checkEntityType($original);

    // If no schema changes are needed, we don't need to do anything.
    if (!$this->requiresEntityStorageSchemaChanges($entity_type, $original)) {
      return;
    }

    // If a migration is required, we can't proceed.
    if ($this->requiresEntityDataMigration($entity_type, $original)) {
      throw new EntityStorageException(String::format('The SQL storage cannot change the schema for an existing entity type with data.'));
    }

    // If we have no data just recreate the entity schema from scratch.
    if ($this->isTableEmpty($this->storage->getBaseTable())) {
      if ($this->database->supportsTransactionalDDL()) {
        // If the database supports transactional DDL, we can go ahead and rely
        // on it. If not, we will have to rollback manually if something fails.
        $transaction = $this->database->startTransaction();
      }
      try {
        $this->onEntityTypeDelete($original);
        $this->onEntityTypeCreate($entity_type);
      }
      catch (\Exception $e) {
        if ($this->database->supportsTransactionalDDL()) {
          $transaction->rollback();
        }
        else {
          // Recreate original schema.
          $this->onEntityTypeCreate($original);
        }
        throw $e;
      }
    }
    else {
      $schema_handler = $this->database->schema();

      // Drop original indexes and unique keys.
      foreach ($this->loadEntitySchemaData($entity_type) as $table_name => $schema) {
        if (!empty($schema['indexes'])) {
          foreach ($schema['indexes'] as $name => $specifier) {
            $schema_handler->dropIndex($table_name, $name);
          }
        }
        if (!empty($schema['unique keys'])) {
          foreach ($schema['unique keys'] as $name => $specifier) {
            $schema_handler->dropUniqueKey($table_name, $name);
          }
        }
      }

      // Create new indexes and unique keys.
      $entity_schema = $this->getEntitySchema($entity_type, TRUE);
      foreach ($this->getEntitySchemaData($entity_type, $entity_schema) as $table_name => $schema) {
        if (!empty($schema['indexes'])) {
          foreach ($schema['indexes'] as $name => $specifier) {
            $schema_handler->addIndex($table_name, $name, $specifier);
          }
        }
        if (!empty($schema['unique keys'])) {
          foreach ($schema['unique keys'] as $name => $specifier) {
            $schema_handler->addUniqueKey($table_name, $name, $specifier);
          }
        }
      }

      // Store the updated entity schema.
      $this->saveEntitySchemaData($entity_type, $entity_schema);
  public function onEntityTypeDelete(EntityTypeInterface $entity_type) {
    $schema_handler = $this->database->schema();
    $actual_definition = $this->entityManager->getDefinition($entity_type->id());
    // @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.
    $this->storage->setEntityType($entity_type);

    // Delete entity tables.
    foreach ($this->getEntitySchemaTables() as $table_name) {
      if ($schema_handler->tableExists($table_name)) {
        $schema_handler->dropTable($table_name);
      }
    }

    // Delete dedicated field tables.
    $field_storage_definitions = $this->entityManager->getLastInstalledFieldStorageDefinitions($entity_type->id());
    $this->originalDefinitions = $field_storage_definitions;
    $table_mapping = $this->storage->getTableMapping($field_storage_definitions);
    foreach ($field_storage_definitions as $field_storage_definition) {
      if ($table_mapping->requiresDedicatedTableStorage($field_storage_definition)) {
        $this->deleteDedicatedTableSchema($field_storage_definition);
      }
    }
    $this->originalDefinitions = NULL;

    $this->storage->setEntityType($actual_definition);

    // Delete the entity schema.
    $this->deleteEntitySchemaData($entity_type);
  /**
   * {@inheritdoc}
   */
  public function onFieldStorageDefinitionCreate(FieldStorageDefinitionInterface $storage_definition) {
    $this->performFieldSchemaOperation('create', $storage_definition);
  }

  /**
   * {@inheritdoc}
   */
  public function onFieldStorageDefinitionUpdate(FieldStorageDefinitionInterface $storage_definition, FieldStorageDefinitionInterface $original) {
    // Store original definitions so that switching between shared and dedicated
    // field table layout works.
    $this->originalDefinitions = $this->fieldStorageDefinitions;
    $this->originalDefinitions[$original->getName()] = $original;
    $this->performFieldSchemaOperation('update', $storage_definition, $original);
    $this->originalDefinitions = NULL;
  }

  /**
   * {@inheritdoc}
   */
  public function onFieldStorageDefinitionDelete(FieldStorageDefinitionInterface $storage_definition) {
    // Only configurable fields currently support purging, so prevent deletion
    // of ones we can't purge if they have existing data.
    // @todo Add purging to all fields: https://www.drupal.org/node/2282119.
    if (!($storage_definition instanceof FieldStorageConfigInterface) && $this->storage->countFieldData($storage_definition, TRUE)) {
      throw new FieldStorageDefinitionUpdateForbiddenException('Unable to delete a field with data that can\'t be purged.');
    }

    // Retrieve a table mapping which contains the deleted field still.
    $table_mapping = $this->storage->getTableMapping(
      $this->entityManager->getLastInstalledFieldStorageDefinitions($this->entityType->id())
    );
    if ($table_mapping->requiresDedicatedTableStorage($storage_definition)) {
      // Move the table to a unique name while the table contents are being
      // deleted.
      $table = $table_mapping->getDedicatedDataTableName($storage_definition);
      $new_table = $table_mapping->getDedicatedDataTableName($storage_definition, TRUE);
      $this->database->schema()->renameTable($table, $new_table);
      if ($this->entityType->isRevisionable()) {
        $revision_table = $table_mapping->getDedicatedRevisionTableName($storage_definition);
        $revision_new_table = $table_mapping->getDedicatedRevisionTableName($storage_definition, TRUE);
        $this->database->schema()->renameTable($revision_table, $revision_new_table);
      }
    }

    // @todo Remove when finalizePurge() is invoked from the outside for all
    //   fields: https://www.drupal.org/node/2282119.
    if (!($storage_definition instanceof FieldStorageConfigInterface)) {
      $this->performFieldSchemaOperation('delete', $storage_definition);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function finalizePurge(FieldStorageDefinitionInterface $storage_definition) {
    $this->performFieldSchemaOperation('delete', $storage_definition);
  }

  /**
   * Checks that we are dealing with the correct entity type.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type to be checked.
   *
   * @return bool
   *   TRUE if the entity type matches the current one.
   *
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  protected function checkEntityType(EntityTypeInterface $entity_type) {
    if ($entity_type->id() != $this->entityType->id()) {
      throw new EntityStorageException(String::format('Unsupported entity type @id', array('@id' => $entity_type->id())));
    }
    return TRUE;
  }

  /**
   * Returns the entity schema for the specified entity type.
   *
   * Entity types may override this method in order to optimize the generated
   * schema of the entity tables. However, only cross-field optimizations should
   * be added here; e.g., an index spanning multiple fields. Optimizations that
   * apply to a single field have to be added via
   * SqlContentEntityStorageSchema::getSharedTableFieldSchema() instead.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type definition.
   * @param bool $reset
   *   (optional) If set to TRUE static cache will be ignored and a new schema
   *   array generation will be performed. Defaults to FALSE.
   *
   * @return array
   *   A Schema API array describing the entity schema, excluding dedicated
   *   field tables.
   *
   * @throws \Drupal\Core\Field\FieldException
   */
  protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) {
    $this->checkEntityType($entity_type);
    $entity_type_id = $entity_type->id();
    if (!isset($this->schema[$entity_type_id]) || $reset) {
      // Back up the storage definition and replace it with the passed one.
      // @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.
      $actual_definition = $this->entityManager->getDefinition($entity_type_id);
      $this->storage->setEntityType($entity_type);

      // Prepare basic information about the entity type.
      $tables = $this->getEntitySchemaTables();

      // Initialize the table schema.
      $schema[$tables['base_table']] = $this->initializeBaseTable($entity_type);
      if (isset($tables['revision_table'])) {
        $schema[$tables['revision_table']] = $this->initializeRevisionTable($entity_type);
        $schema[$tables['data_table']] = $this->initializeDataTable($entity_type);
      }
      if (isset($tables['revision_data_table'])) {
        $schema[$tables['revision_data_table']] = $this->initializeRevisionDataTable($entity_type);
      // We need to act only on shared entity schema tables.
      $table_mapping = $this->storage->getTableMapping();
      $table_names = array_diff($table_mapping->getTableNames(), $table_mapping->getDedicatedTableNames());
      $storage_definitions = $this->entityManager->getFieldStorageDefinitions($entity_type_id);
      foreach ($table_names as $table_name) {
        if (!isset($schema[$table_name])) {
          $schema[$table_name] = array();
        }
        foreach ($table_mapping->getFieldNames($table_name) as $field_name) {
          if (!isset($storage_definitions[$field_name])) {
            throw new FieldException(String::format('Field storage definition for "@field_name" could not be found.', array('@field_name' => $field_name)));
          }
          // Add the schema for base field definitions.
          elseif ($table_mapping->allowsSharedTableStorage($storage_definitions[$field_name])) {
            $column_names = $table_mapping->getColumnNames($field_name);
            $storage_definition = $storage_definitions[$field_name];
            $schema[$table_name] = array_merge_recursive($schema[$table_name], $this->getSharedTableFieldSchema($storage_definition, $table_name, $column_names));
          }
        }

        // Add the schema for extra fields.
        foreach ($table_mapping->getExtraColumns($table_name) as $column_name) {
          if ($column_name == 'default_langcode') {
            $this->addDefaultLangcodeSchema($schema[$table_name]);
          }
        }
      }

      // Process tables after having gathered field information.
      $this->processBaseTable($entity_type, $schema[$tables['base_table']]);
      if (isset($tables['revision_table'])) {
        $this->processRevisionTable($entity_type, $schema[$tables['revision_table']]);
        $this->processDataTable($entity_type, $schema[$tables['data_table']]);
      }
      if (isset($tables['revision_data_table'])) {
        $this->processRevisionDataTable($entity_type, $schema[$tables['revision_data_table']]);
      $this->schema[$entity_type_id] = $schema;

      // Restore the actual definition.
      $this->storage->setEntityType($actual_definition);
    return $this->schema[$entity_type_id];
  }

  /**
   * Gets a list of entity type tables.
   *
   * @return array
   *   A list of entity type tables, keyed by table key.
   */
  protected function getEntitySchemaTables() {
    return array_filter(array(
      'base_table' => $this->storage->getBaseTable(),
      'revision_table' => $this->storage->getRevisionTable(),
      'data_table' => $this->storage->getDataTable(),
      'revision_data_table' => $this->storage->getRevisionDataTable(),
    ));
  }

  /**
   * Returns entity schema definitions for index and key definitions.
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type definition.
   *   The entity schema array.
   *
   * @return array
   *   A stripped down version of the $schema Schema API array containing, for
   *   each table, only the key and index definitions not derived from field
   *   storage definitions.
  protected function getEntitySchemaData(ContentEntityTypeInterface $entity_type, array $schema) {
    $schema_data = array();
    $entity_type_id = $entity_type->id();
    $keys = array('indexes', 'unique keys');
    $unused_keys = array_flip(array('description', 'fields', 'foreign keys'));
    foreach ($schema as $table_name => $table_schema) {
      $table_schema = array_diff_key($table_schema, $unused_keys);
      foreach ($keys as $key) {
        // Exclude data generated from field storage definitions, we will check
        // that separately.
        if (!empty($table_schema[$key])) {
          $data_keys = array_keys($table_schema[$key]);
          $entity_keys = array_filter($data_keys, function ($key) use ($entity_type_id) {
            return strpos($key, $entity_type_id . '_field__') !== 0;
          });
          $table_schema[$key] = array_intersect_key($table_schema[$key], array_flip($entity_keys));
        }
      $schema_data[$table_name] = array_filter($table_schema);
    return $schema_data;
  }

  /**
   * Returns an index schema array for a given field.
   *
   * @param string $field_name
   *   The name of the field.
   * @param array $field_schema
   *   The schema of the field.
   * @param string[] $column_mapping
   *   A mapping of field column names to database column names.
   *
   * @return array
   *   The schema definition for the indexes.
   */
  protected function getFieldIndexes($field_name, array $field_schema, array $column_mapping) {
    return $this->getFieldSchemaData($field_name, $field_schema, $column_mapping, 'indexes');
  }

  /**
   * Returns a unique key schema array for a given field.
   *
   * @param string $field_name
   *   The name of the field.
   * @param array $field_schema
   *   The schema of the field.
   * @param string[] $column_mapping
   *   A mapping of field column names to database column names.
   *
   * @return array
   *   The schema definition for the unique keys.
   */
  protected function getFieldUniqueKeys($field_name, array $field_schema, array $column_mapping) {
    return $this->getFieldSchemaData($field_name, $field_schema, $column_mapping, 'unique keys');
  }

  /**
   * Returns field schema data for the given key.
   *
   * @param string $field_name
   *   The name of the field.
   * @param array $field_schema
   *   The schema of the field.
   * @param string[] $column_mapping
   *   A mapping of field column names to database column names.
   * @param string $schema_key
   *   The type of schema data. Either 'indexes' or 'unique keys'.
   *
   * @return array
   *   The schema definition for the specified key.
   */
  protected function getFieldSchemaData($field_name, array $field_schema, array $column_mapping, $schema_key) {
    $data = array();

    foreach ($field_schema[$schema_key] as $key => $columns) {
      // To avoid clashes with entity-level indexes or unique keys we use
      // "{$entity_type_id}_field__" as a prefix instead of just
      // "{$entity_type_id}__". We additionally namespace the specifier by the
      // field name to avoid clashes when multiple fields of the same type are
      // added to an entity type.
      $entity_type_id = $this->entityType->id();
      $real_key = $this->getFieldSchemaIdentifierName($entity_type_id, $field_name, $key);
      foreach ($columns as $column) {
        // Allow for indexes and unique keys to specified as an array of column
        // name and length.
        if (is_array($column)) {
          list($column_name, $length) = $column;
          $data[$real_key][] = array($column_mapping[$column_name], $length);
        }
        else {
          $data[$real_key][] = $column_mapping[$column];
        }
      }
    }

    return $data;
  }

  /**
   * Generates a safe schema identifier (name of an index, column name etc.).
   *
   * @param string $entity_type_id
   *   The ID of the entity type.
   * @param string $field_name
   *   The name of the field.
   * @param string|null $key
   *   (optional) A further key to append to the name.
   *
   * @return string
   *   The field identifier name.
   */
  protected function getFieldSchemaIdentifierName($entity_type_id, $field_name, $key = NULL) {
    $real_key = isset($key) ? "{$entity_type_id}_field__{$field_name}__{$key}" : "{$entity_type_id}_field__{$field_name}";
    // Limit the string to 48 characters, keeping a 16 characters margin for db
    // prefixes.
    if (strlen($real_key) > 48) {
      // Use a shorter separator, a truncated entity_type, and a hash of the
      // field name.
      // Truncate to the same length for the current and revision tables.
      $entity_type = substr($entity_type_id, 0, 36);
      $field_hash = substr(hash('sha256', $real_key), 0, 10);
      $real_key = $entity_type . '__' . $field_hash;
    }
    return $real_key;
  }

  /**
   * Returns field foreign keys.
   *
   * @param string $field_name
   *   The name of the field.
   * @param array $field_schema
   *   The schema of the field.
   * @param string[] $column_mapping
   *   A mapping of field column names to database column names.
   *
   * @return array
   *   The schema definition for the foreign keys.
   */
  protected function getFieldForeignKeys($field_name, array $field_schema, array $column_mapping) {
    $foreign_keys = array();

    foreach ($field_schema['foreign keys'] as $specifier => $specification) {
      // To avoid clashes with entity-level foreign keys we use
      // "{$entity_type_id}_field__" as a prefix instead of just
      // "{$entity_type_id}__". We additionally namespace the specifier by the
      // field name to avoid clashes when multiple fields of the same type are
      // added to an entity type.
      $entity_type_id = $this->entityType->id();
      $real_specifier = "{$entity_type_id}_field__{$field_name}__{$specifier}";
      $foreign_keys[$real_specifier]['table'] = $specification['table'];
      foreach ($specification['columns'] as $column => $referenced) {
        $foreign_keys[$real_specifier]['columns'][$column_mapping[$column]] = $referenced;
      }
    }

    return $foreign_keys;
  }

  /**
   * Returns the schema for the 'default_langcode' metadata field.
   *
   * @param array $schema
   *   The table schema to add the field schema to, passed by reference.
   *
   * @return array
   *   A schema field array for the 'default_langcode' metadata field.
   */
  protected function addDefaultLangcodeSchema(&$schema) {
    $schema['fields']['default_langcode'] =  array(
      'description' => 'Boolean indicating whether field values are in the default entity language.',
      'type' => 'int',
      'size' => 'tiny',
      'not null' => TRUE,
      'default' => 1,
    );
  }

  /**
   * Loads stored schema data for the given entity type definition.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   *
   * @return array
   *   The entity schema data array.
   */
  protected function loadEntitySchemaData(EntityTypeInterface $entity_type) {
    return $this->installedStorageSchema()->get($entity_type->id() . '.entity_schema_data', array());
  }

  /**
   * Stores schema data for the given entity type definition.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   * @param array $schema
   *   The entity schema data array.
   */
  protected function saveEntitySchemaData(EntityTypeInterface $entity_type, $schema) {
    $data = $this->getEntitySchemaData($entity_type, $schema);
    $this->installedStorageSchema()->set($entity_type->id() . '.entity_schema_data', $data);
  }

  /**
   * Deletes schema data for the given entity type definition.
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *   The entity type definition.
   */
  protected function deleteEntitySchemaData(EntityTypeInterface $entity_type) {
    $this->installedStorageSchema()->delete($entity_type->id() . '.entity_schema_data');
  }

  /**
   * Loads stored schema data for the given field storage definition.
   *
   * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage_definition
   *   The field storage definition.
   *
   * @return array
   *   The field schema data array.
   */
  protected function loadFieldSchemaData(FieldStorageDefinitionInterface $storage_definition) {
    return $this->installedStorageSchema()->get($storage_definition->getTargetEntityTypeId() . '.field_schema_data.' . $storage_definition->getName(), array());
  }

  /**
   * Stores schema data for the given field storage definition.
   *
   * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage_definition
   *   The field storage definition.
   * @param array $schema
   *   The field schema data array.
   */
  protected function saveFieldSchemaData(FieldStorageDefinitionInterface $storage_definition, $schema) {
    $this->installedStorageSchema()->set($storage_definition->getTargetEntityTypeId() . '.field_schema_data.' . $storage_definition->getName(), $schema);
  }

  /**
   * Deletes schema data for the given field storage definition.
   *
   * @param \Drupal\Core\Field\FieldStorageDefinitionInterface $storage_definition
   *   The field storage definition.
   */
  protected function deleteFieldSchemaData(FieldStorageDefinitionInterface $storage_definition) {
    $this->installedStorageSchema()->delete($storage_definition->getTargetEntityTypeId() . '.field_schema_data.' . $storage_definition->getName());
  }

  /**
   * Initializes common information for a base table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   *
   * @return array
   *   A partial schema array for the base table.
   */
  protected function initializeBaseTable(ContentEntityTypeInterface $entity_type) {
    $entity_type_id = $entity_type->id();

    $schema = array(
      'description' => "The base table for $entity_type_id entities.",
      'primary key' => array($entity_type->getKey('id')),
      'indexes' => array(),
      'foreign keys' => array(),
    );

    if ($entity_type->hasKey('revision')) {
      $revision_key = $entity_type->getKey('revision');
      $key_name = $this->getEntityIndexName($entity_type, $revision_key);
      $schema['unique keys'][$key_name] = array($revision_key);
      $schema['foreign keys'][$entity_type_id . '__revision'] = array(
        'table' => $this->storage->getRevisionTable(),
        'columns' => array($revision_key => $revision_key),
      );
    }

    return $schema;
  }

  /**
   * Initializes common information for a revision table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   *
   * @return array
   *   A partial schema array for the revision table.
   */
  protected function initializeRevisionTable(ContentEntityTypeInterface $entity_type) {
    $entity_type_id = $entity_type->id();
    $id_key = $entity_type->getKey('id');
    $revision_key = $entity_type->getKey('revision');

    $schema = array(
      'description' => "The revision table for $entity_type_id entities.",
      'primary key' => array($revision_key),
      'indexes' => array(),
      'foreign keys' => array(
        $entity_type_id . '__revisioned' => array(
          'table' => $this->storage->getBaseTable(),
          'columns' => array($id_key => $id_key),
        ),
      ),
    );

    $schema['indexes'][$this->getEntityIndexName($entity_type, $id_key)] = array($id_key);
    return $schema;
  }

  /**
   * Initializes common information for a data table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   *
   * @return array
   *   A partial schema array for the data table.
   */
  protected function initializeDataTable(ContentEntityTypeInterface $entity_type) {
    $entity_type_id = $entity_type->id();
    $id_key = $entity_type->getKey('id');

    $schema = array(
      'description' => "The data table for $entity_type_id entities.",
      // @todo Use the language entity key when https://drupal.org/node/2143729
      //   is in.
      'primary key' => array($id_key, 'langcode'),
      'indexes' => array(),
      'foreign keys' => array(
        $entity_type_id => array(
          'table' => $this->storage->getBaseTable(),
          'columns' => array($id_key => $id_key),
        ),
      ),
    );

    if ($entity_type->hasKey('revision')) {
      $key = $entity_type->getKey('revision');
      $schema['indexes'][$this->getEntityIndexName($entity_type, $key)] = array($key);
    return $schema;
  }

  /**
   * Initializes common information for a revision data table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   *
   * @return array
   *   A partial schema array for the revision data table.
   */
  protected function initializeRevisionDataTable(ContentEntityTypeInterface $entity_type) {
    $entity_type_id = $entity_type->id();
    $id_key = $entity_type->getKey('id');
    $revision_key = $entity_type->getKey('revision');

    $schema = array(
      'description' => "The revision data table for $entity_type_id entities.",
      // @todo Use the language entity key when https://drupal.org/node/2143729
      //   is in.
      'primary key' => array($revision_key, 'langcode'),
      'indexes' => array(),
      'foreign keys' => array(
        $entity_type_id => array(
          'table' => $this->storage->getBaseTable(),
          'columns' => array($id_key => $id_key),
        ),
        $entity_type_id . '__revision' => array(
          'table' => $this->storage->getRevisionTable(),
          'columns' => array($revision_key => $revision_key),
        )
      ),
    );

  /**
   * Adds defaults to a table schema definition.
   *
   * @param $schema
   *   The schema definition array for a single table, passed by reference.
   */
  protected function addTableDefaults(&$schema) {
    $schema += array(
      'fields' => array(),
      'unique keys' => array(),
      'indexes' => array(),
      'foreign keys' => array(),
    );
  }

  /**
   * Processes the gathered schema for a base table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   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'));
  }

  /**
   * Processes the gathered schema for a base table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   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'));
  }

  /**
   * Processes the gathered schema for a base table.
   *
   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $entity_type
   *   The entity type.
   * @param array $schema
   *   The table schema, passed by reference.
   *
   * @return array
   *   A partial schema array for the base table.
   */
  protected function processDataTable(ContentEntityTypeInterface $entity_type, array &$schema) {