Verified Commit a3108d01 authored by Lee Rowlands's avatar Lee Rowlands
Browse files

feat: #3411490 Replace array-based DB Schema API with a value object structure

By: mondrake
By: daffie
By: geek-merlin
By: mradcliffe
By: alexpott
By: joachim
By: catch
By: amateescu
By: larowlan
parent 62c7178d
Loading
Loading
Loading
Loading
Loading
+0 −6
Changes for core/.phpstan-baseline.php: 0 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -1609,12 +1609,6 @@
	'count' => 1,
	'path' => __DIR__ . '/lib/Drupal/Core/Batch/BatchStorage.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Core\\\\Batch\\\\BatchStorage\\:\\:schemaDefinition\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
	'count' => 1,
	'path' => __DIR__ . '/lib/Drupal/Core/Batch/BatchStorage.php',
];
$ignoreErrors[] = [
	'message' => '#^Method Drupal\\\\Core\\\\Batch\\\\BatchStorage\\:\\:update\\(\\) has no return type specified\\.$#',
	'identifier' => 'missingType.return',
+44 −34
Changes for core/lib/Drupal/Core/Batch/BatchStorage.php: 44 added lines, 34 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,13 @@
use Drupal\Core\Access\CsrfTokenGenerator;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\DatabaseException;
use Drupal\Core\Database\SchemaDefinition\Column;
use Drupal\Core\Database\SchemaDefinition\ColumnSize;
use Drupal\Core\Database\SchemaDefinition\Index;
use Drupal\Core\Database\SchemaDefinition\PrimaryKey;
use Drupal\Core\Database\SchemaDefinition\Schema;
use Drupal\Core\Database\SchemaDefinition\SchemaDefinitionType;
use Drupal\Core\Database\SchemaDefinition\Table;
use Symfony\Component\HttpFoundation\Session\SessionInterface;

/**
@@ -166,9 +173,7 @@ protected function doInsertBatchRecord(): int {
   */
  protected function ensureTableExists() {
    try {
      $database_schema = $this->connection->schema();
      $schema_definition = $this->schemaDefinition();
      $database_schema->createTable(static::TABLE_NAME, $schema_definition);
      $this->connection->schema()->createSchemaFromDefinition($this->schemaDefinition());
    }
    // If another process has already created the batch table, attempting to
    // recreate it will throw an exception. In this case just catch the
@@ -204,39 +209,44 @@ protected function catchException(\Exception $e) {
   *
   * @internal
   */
  public function schemaDefinition() {
    return [
      'description' => 'Stores details about batches (processes that run in multiple HTTP requests).',
      'fields' => [
        'bid' => [
          'description' => 'Primary Key: Unique batch ID.',
          'type' => 'serial',
          'unsigned' => TRUE,
          'not null' => TRUE,
  public function schemaDefinition(): Schema {
    $tables[] = new Table(
      name: static::TABLE_NAME,
      description: 'Stores details about batches (processes that run in multiple HTTP requests).',
      columns: [
        Column::serial(
          name: 'bid',
          description: 'Primary Key: Unique batch ID.',
        ),
        Column::varcharAscii(
          name: 'token',
          description: "A string token generated against the current user's session id and the batch id, used to ensure that only the user who submitted the batch can effectively access it.",
          length: 64,
          notNull: TRUE,
        ),
        Column::int(
          name: 'timestamp',
          description: 'A Unix timestamp indicating when this batch was submitted for processing. Stale batches are purged at cron time.',
          notNull: TRUE,
        ),
        Column::blob(
          name: 'batch',
          description: 'A serialized array containing the processing data for the batch.',
          size: ColumnSize::Big,
          notNull: FALSE,
        ),
      ],
        'token' => [
          'description' => "A string token generated against the current user's session id and the batch id, used to ensure that only the user who submitted the batch can effectively access it.",
          'type' => 'varchar_ascii',
          'length' => 64,
          'not null' => TRUE,
      primaryKey: new PrimaryKey(['bid']),
      indexes: [
        new Index(name: 'token', columns: ['token']),
      ],
        'timestamp' => [
          'description' => 'A Unix timestamp indicating when this batch was submitted for processing. Stale batches are purged at cron time.',
          'type' => 'int',
          'not null' => TRUE,
        ],
        'batch' => [
          'description' => 'A serialized array containing the processing data for the batch.',
          'type' => 'blob',
          'not null' => FALSE,
          'size' => 'big',
        ],
      ],
      'primary key' => ['bid'],
      'indexes' => [
        'token' => ['token'],
      ],
    ];
    );

    return new Schema(
      type: SchemaDefinitionType::Storage,
      name: 'batch',
      tables: $tables,
    );
  }

}
+12 −0
Changes for core/lib/Drupal/Core/Database/Exception/SchemaDefinitionException.php: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\Database\Exception;

use Drupal\Core\Database\DatabaseException;
use Drupal\Core\Database\SchemaException;

/**
 * Exception thrown by the Schema Definition API.
 */
class SchemaDefinitionException extends SchemaException implements DatabaseException {
}
+40 −1
Changes for core/lib/Drupal/Core/Database/Schema.php: 40 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,6 +3,9 @@
namespace Drupal\Core\Database;

use Drupal\Core\Database\Query\PlaceholderInterface;
use Drupal\Core\Database\SchemaDefinition\Schema as SchemaDefinition;
use Drupal\Core\Database\SchemaDefinition\SchemaDefinitionType;
use Drupal\Core\Database\SchemaDefinition\Table as TableDefinition;

/**
 * Provides a base implementation for Database Schema.
@@ -628,7 +631,43 @@ protected function introspectIndexSchema($table) {
  abstract public function changeField($table, $field, $field_new, $spec, $keys_new = []);

  /**
   * Create a new table from a Drupal table definition.
   * Creates the tables in a schema definition.
   *
   * @param \Drupal\Core\Database\SchemaDefinition\Schema $schema
   *   A Schema definition.
   *
   * @throws \Drupal\Core\Database\SchemaObjectExistsException
   *   If any of the specified tables already exists.
   * @throws \BadMethodCallException
   *   When concrete driver class is missing implementations.
   */
  final public function createSchemaFromDefinition(SchemaDefinition $schema): void {
    foreach ($schema->tables as $table) {
      $this->createTableFromDefinition($schema->type, $schema->name, $table);
    }
  }

  /**
   * Creates a new table from a table schema definition.
   *
   * @param \Drupal\Core\Database\SchemaDefinition\SchemaDefinitionType $schemaDefinitionType
   *   The type of Drupal feature providing the schema.
   * @param string $schemaName
   *   The schema name.
   * @param \Drupal\Core\Database\SchemaDefinition\Table $table
   *   The table definition.
   *
   * @throws \Drupal\Core\Database\SchemaObjectExistsException
   *   If the specified table already exists.
   * @throws \BadMethodCallException
   *   When concrete driver class is missing implementations.
   */
  final public function createTableFromDefinition(SchemaDefinitionType $schemaDefinitionType, string $schemaName, TableDefinition $table): void {
    $this->createTable($table->name, $table->toArray());
  }

  /**
   * Create a new table from a Drupal array table definition.
   *
   * @param string $name
   *   The name of the table to create.
+461 −0
Changes for core/lib/Drupal/Core/Database/SchemaDefinition/Column.php: 461 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Core\Database\SchemaDefinition;

use Drupal\Core\Database\Exception\SchemaDefinitionException;

/**
 * Describes a database table's column.
 */
final class Column implements SchemaDefinitionInterface {

  /**
   * Constructor.
   *
   * @param string $name
   *   The column name.
   * @param ?ColumnType $type
   *   (Optional) The column data type, generic. Each database will map this to
   *   its own definition. This argument is mandatory unless $dbSpecificExtra is
   *   specified. See
   *   \Drupal\Core\Database\SchemaDefinition\ColumnType for allowed values.
   * @param ?string $description
   *   (Optional) A string in non-markup plain text describing this field and
   *   its purpose. References to other tables should be enclosed in curly
   *   brackets. For example, the users_data table 'uid' field description
   *   might contain "The {users}.uid this record affects.".
   * @param ?bool $serialize
   *   (Optional) A boolean indicating whether the field will be stored as a
   *   serialized string. If NULL, it is not specified. Defaults to NULL.
   * @param ?ColumnSize $size
   *   (Optional) The column data size. This is a hint about the largest value
   *   the column will store. See
   *   \Drupal\Core\Database\SchemaDefinition\ColumnSize for allowed values.
   * @param ?bool $notNull
   *   (Optional)  If true, no NULL values will be allowed in this database
   *   column. If false, NULL values will be allowed. If NULL, it is not
   *   specified. Defaults to NULL.
   * @param StringValue|IntValue|FloatValue|NullValue|null $default
   *   (Optional) The field's default value.
   * @param ?int $length
   *   (Optional) The maximum length of a type 'char', 'varchar' or 'text'
   *   field. Ignored for other field types.
   * @param ?bool $unsigned
   *   (Optional) A boolean indicating whether a type 'int', 'float' and
   *   'numeric' only is signed or unsigned. If NULL, it is not specified.
   *    Defaults to NULL.
   * @param ?int $precision
   *   (Optional) Mandatory for type 'numeric' fields, indicates the precision
   *   (total number of significant digits). Ignored for other field types.
   * @param ?int $scale
   *   (Optional) Mandatory for type 'numeric' fields, indicates the scale
   *   (decimal digits right of the decimal point). Ignored for other field
   *   types.
   * @param ?bool $binary
   *   (Optional) A boolean indicating that MySQL should force 'char',
   *   'varchar' or 'text' fields to use case-sensitive binary collation. This
   *   has no effect on other database types for which case sensitivity is
   *   already the default behavior. If NULL, it is not specified. Defaults to
   *   NULL.
   * @param array<string,array<string,mixed>>|null $dbSpecificExtra
   *   (Optional) If you need to use a column type not included in the
   *   officially supported list of types above, you can specify a type for
   *   each database backend. Specify this as an associative array having the
   *   database type ('mysql', 'sqlite', 'pgsql', 'oracle', etc.) as the key,
   *   and an array of extra information <string,mixed> as the value. If NULL,
   *   it is not specified. Defaults to NULL.
   *
   * @see \Drupal\Core\Database\SchemaDefinition\ColumnType
   * @see \Drupal\Core\Database\SchemaDefinition\ColumnSize
   */
  private function __construct(
    public readonly string $name,
    public readonly ?ColumnType $type = NULL,
    public readonly ?string $description = NULL,
    public readonly ?bool $serialize = NULL,
    public readonly ?ColumnSize $size = NULL,
    public readonly ?bool $notNull = NULL,
    public readonly StringValue|IntValue|FloatValue|NullValue|null $default = NULL,
    public readonly ?int $length = NULL,
    public readonly ?bool $unsigned = NULL,
    public readonly ?int $precision = NULL,
    public readonly ?int $scale = NULL,
    public readonly ?bool $binary = NULL,
    public readonly ?array $dbSpecificExtra = NULL,
  ) {
  }

  /**
   * Returns new a Column object.
   */
  public static function create(
    string $name,
    ?ColumnType $type = NULL,
    ?string $description = NULL,
    ?bool $serialize = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|IntValue|FloatValue|NullValue|null $default = NULL,
    ?int $length = NULL,
    ?bool $unsigned = NULL,
    ?int $precision = NULL,
    ?int $scale = NULL,
    ?bool $binary = NULL,
    ?array $dbSpecificExtra = NULL,
  ): self {
    $instance = new self(
      name: $name,
      type: $type,
      description: $description,
      serialize: $serialize,
      size: $size,
      notNull: $notNull,
      default: $default,
      length: $length,
      unsigned: $unsigned,
      precision: $precision,
      scale: $scale,
      binary: $binary,
      dbSpecificExtra: $dbSpecificExtra,
    );
    $instance->validate();
    return $instance;
  }

  /**
   * Validates the properties of the value object.
   *
   * @throws \Drupal\Core\Database\Exception\SchemaDefinitionException
   */
  private function validate(): void {

    // If no column type specified, a db specific one should be set.
    if ($this->type === NULL && $this->dbSpecificExtra === NULL) {
      throw new SchemaDefinitionException("Neither 'type' nor 'dbSpecificExtra' for column '{$this->name}'");
    }

    // Cannot set notNull for serial columns.
    if ($this->notNull !== NULL && $this->type === ColumnType::Serial) {
      throw new SchemaDefinitionException("Cannot set 'notNull' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set unsigned for some column types.
    if ($this->unsigned !== NULL && $this->type !== NULL && !in_array($this->type, [
      ColumnType::Int,
      ColumnType::Float,
      ColumnType::Numeric,
    ], TRUE)) {
      throw new SchemaDefinitionException("Cannot set 'unsigned' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set length for some column types.
    if ($this->length !== NULL && $this->type !== NULL && !in_array($this->type, [
      ColumnType::Char,
      ColumnType::Varchar,
      ColumnType::VarcharAscii,
      ColumnType::Text,
    ], TRUE)) {
      throw new SchemaDefinitionException("Cannot set 'length' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set binary for some column types.
    if ($this->binary !== NULL && $this->type !== NULL && !in_array($this->type, [
      ColumnType::Char,
      ColumnType::Varchar,
      ColumnType::VarcharAscii,
      ColumnType::Text,
    ], TRUE)) {
      throw new SchemaDefinitionException("Cannot set 'binary' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set scale of a numeric column.
    if ($this->precision !== NULL && $this->type !== NULL && $this->type !== ColumnType::Numeric) {
      throw new SchemaDefinitionException("Cannot set 'precision' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set precision of a numeric column.
    if ($this->scale !== NULL && $this->type !== NULL && $this->type !== ColumnType::Numeric) {
      throw new SchemaDefinitionException("Cannot set 'scale' for {$this->type->value} column '{$this->name}'");
    }

    // Can only set serialize on a text/blob column.
    if ($this->serialize !== NULL && $this->type !== NULL &&  !in_array($this->type, [
      ColumnType::Text,
      ColumnType::Blob,
    ], TRUE)) {
      throw new SchemaDefinitionException("Cannot set 'serialize' for {$this->type->value} column '{$this->name}'");
    }

    // Check validity of default type.
    if ($this->type !== NULL && $this->default !== NULL && !$this->type->isValidDefaultValue($this->default)) {
      throw new SchemaDefinitionException("Cannot set a {$this->default->name} default for column '{$this->name}'");
    }

    // Cannot set a null value if notNull is true.
    if ($this->notNull && $this->default && $this->default->value === NULL) {
      throw new SchemaDefinitionException("Cannot set a null default for column '{$this->name}': notNull is true");
    }

  }

  /**
   * {@inheritdoc}
   */
  public function toArray(): array {
    $spec = [];
    if ($this->type) {
      $spec['type'] = $this->type->value;
      if ($this->type === ColumnType::Serial) {
        $spec['not null'] = TRUE;
        $spec['unsigned'] = TRUE;
      }
    }
    if ($this->description !== NULL) {
      $spec['description'] = $this->description;
    }
    if ($this->serialize !== NULL) {
      $spec['serialize'] = $this->serialize;
    }
    if ($this->size) {
      $spec['size'] = $this->size->value;
    }
    if ($this->notNull !== NULL) {
      $spec['not null'] = $this->notNull;
    }
    if ($this->default) {
      $spec['default'] = $this->default->value;
    }
    if ($this->length !== NULL) {
      $spec['length'] = $this->length;
    }
    if ($this->unsigned !== NULL) {
      $spec['unsigned'] = $this->unsigned;
    }
    if ($this->precision !== NULL) {
      $spec['precision'] = $this->precision;
    }
    if ($this->scale !== NULL) {
      $spec['scale'] = $this->scale;
    }
    if ($this->binary !== NULL) {
      $spec['binary'] = $this->binary;
    }
    if ($this->dbSpecificExtra !== NULL) {
      foreach ($this->dbSpecificExtra as $extra) {
        foreach ($extra as $key => $value) {
          $spec[$key] = $value;
        }
      }
    }
    return $spec;
  }

  /**
   * Returns a Column object for a char column.
   */
  public static function char(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|NullValue|null $default = NULL,
    ?int $length = NULL,
    ?bool $binary = NULL,
  ): self {
    return self::create(
      type: ColumnType::Char,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      length: $length,
      binary: $binary,
    );
  }

  /**
   * Returns a Column object for a varchar column.
   */
  public static function varchar(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|NullValue|null $default = NULL,
    ?int $length = NULL,
    ?bool $binary = NULL,
  ): self {
    return self::create(
      type: ColumnType::Varchar,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      length: $length,
      binary: $binary,
    );
  }

  /**
   * Returns a Column object for a varcharAscii column.
   */
  public static function varcharAscii(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|NullValue|null $default = NULL,
    ?int $length = NULL,
    ?bool $binary = NULL,
  ): self {
    return self::create(
      type: ColumnType::VarcharAscii,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      length: $length,
      binary: $binary,
    );
  }

  /**
   * Returns a Column object for a text column.
   */
  public static function text(
    string $name,
    ?string $description = NULL,
    ?bool $serialize = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|NullValue|null $default = NULL,
    ?int $length = NULL,
    ?bool $binary = NULL,
  ): self {
    return self::create(
      type: ColumnType::Text,
      name: $name,
      description: $description,
      serialize: $serialize,
      size: $size,
      notNull: $notNull,
      default: $default,
      length: $length,
      binary: $binary,
    );
  }

  /**
   * Returns a Column object for an int column.
   */
  public static function int(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    IntValue|NullValue|null $default = NULL,
    ?bool $unsigned = NULL,
  ): self {
    return self::create(
      type: ColumnType::Int,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      unsigned: $unsigned,
    );
  }

  /**
   * Returns a Column object for a serial column.
   */
  public static function serial(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
  ): self {
    return self::create(
      type: ColumnType::Serial,
      name: $name,
      description: $description,
      size: $size,
    );
  }

  /**
   * Returns a Column object for an float column.
   */
  public static function float(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    FloatValue|NullValue|null $default = NULL,
    ?bool $unsigned = NULL,
  ): self {
    return self::create(
      type: ColumnType::Float,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      unsigned: $unsigned,
    );
  }

  /**
   * Returns a Column object for an numeric column.
   */
  public static function numeric(
    string $name,
    ?string $description = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    FloatValue|NullValue|null $default = NULL,
    ?bool $unsigned = NULL,
    ?int $precision = NULL,
    ?int $scale = NULL,
  ): self {
    return self::create(
      type: ColumnType::Numeric,
      name: $name,
      description: $description,
      size: $size,
      notNull: $notNull,
      default: $default,
      unsigned: $unsigned,
      precision: $precision,
      scale: $scale,
    );
  }

  /**
   * Returns a Column object for a blob column.
   */
  public static function blob(
    string $name,
    ?string $description = NULL,
    ?bool $serialize = NULL,
    ?ColumnSize $size = NULL,
    ?bool $notNull = NULL,
    StringValue|NullValue|null $default = NULL,
  ): self {
    return self::create(
      type: ColumnType::Blob,
      name: $name,
      description: $description,
      serialize: $serialize,
      size: $size,
      notNull: $notNull,
      default: $default,
    );
  }

}
Loading