Skip to content

Commit

Permalink
Implement an EnumType for MySQL/MariaDB
Browse files Browse the repository at this point in the history
  • Loading branch information
derrabus committed Oct 10, 2024
1 parent e0f3674 commit 0f5cfd2
Show file tree
Hide file tree
Showing 10 changed files with 234 additions and 12 deletions.
19 changes: 19 additions & 0 deletions src/Platforms/AbstractMySQLPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
use Doctrine\DBAL\SQL\Builder\SelectSQLBuilder;
use Doctrine\DBAL\TransactionIsolationLevel;
use Doctrine\DBAL\Types\Types;
use RuntimeException;

use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
use function count;
use function implode;
use function in_array;
use function is_array;
use function is_numeric;
use function sprintf;
use function str_replace;
Expand Down Expand Up @@ -645,6 +648,21 @@ public function getDecimalTypeDeclarationSQL(array $column): string
return parent::getDecimalTypeDeclarationSQL($column) . $this->getUnsignedDeclaration($column);
}

/**
* {@inheritDoc}
*/
public function getEnumDeclarationSQL(array $column): string

Check warning on line 654 in src/Platforms/AbstractMySQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractMySQLPlatform.php#L654

Added line #L654 was not covered by tests
{
if (! isset($column['values']) || ! is_array($column['values']) || $column['values'] === []) {
throw new RuntimeException('Incomplete ENUM column definition. ENUM columns require an array of values.');

Check warning on line 657 in src/Platforms/AbstractMySQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractMySQLPlatform.php#L656-L657

Added lines #L656 - L657 were not covered by tests
}

return sprintf('ENUM(%s)', implode(', ', array_map(
$this->quoteStringLiteral(...),
$column['values'],
)));

Check warning on line 663 in src/Platforms/AbstractMySQLPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractMySQLPlatform.php#L660-L663

Added lines #L660 - L663 were not covered by tests
}

/**
* Get unsigned declaration for a column.
*
Expand Down Expand Up @@ -718,6 +736,7 @@ protected function initializeDoctrineTypeMappings(): void
'datetime' => Types::DATETIME_MUTABLE,
'decimal' => Types::DECIMAL,
'double' => Types::FLOAT,
'enum' => Types::ENUM,
'float' => Types::SMALLFLOAT,
'int' => Types::INTEGER,
'integer' => Types::INTEGER,
Expand Down
20 changes: 20 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use Doctrine\DBAL\Types;
use Doctrine\DBAL\Types\Exception\TypeNotFound;
use Doctrine\DBAL\Types\Type;
use RuntimeException;

use function addcslashes;
use function array_map;
Expand All @@ -51,6 +52,8 @@
use function is_float;
use function is_int;
use function is_string;
use function max;
use function mb_strlen;
use function preg_quote;
use function preg_replace;
use function sprintf;
Expand Down Expand Up @@ -190,6 +193,23 @@ public function getBinaryTypeDeclarationSQL(array $column): string
}
}

/**
* Returns the SQL snippet to declare an ENUM column.
*
* Enum is a non-standard type that is especially popular in MySQL and MariaDB. By default, this method map to
* a simple VARCHAR field which allows us to deploy it on any platform, e.g. SQLite.
*
* @param array<string, mixed> $column
*/
public function getEnumDeclarationSQL(array $column): string
{
if (! isset($column['values']) || ! is_array($column['values']) || $column['values'] === []) {
throw new RuntimeException('Incomplete ENUM column definition. ENUM columns require an array of values.');

Check warning on line 207 in src/Platforms/AbstractPlatform.php

View check run for this annotation

Codecov / codecov/patch

src/Platforms/AbstractPlatform.php#L207

Added line #L207 was not covered by tests
}

return $this->getStringTypeDeclarationSQL(['length' => max(...array_map(mb_strlen(...), $column['values']))]);
}

/**
* Returns the SQL snippet to declare a GUID/UUID column.
*
Expand Down
44 changes: 33 additions & 11 deletions src/Schema/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class Column extends AbstractAsset

protected bool $_autoincrement = false;

/** @var list<string> */
protected array $_values = [];

/** @var array<string, mixed> */
protected array $_platformOptions = [];

Expand Down Expand Up @@ -231,22 +234,41 @@ public function getComment(): string
return $this->_comment;
}

/**
* @param list<string> $values
*
* @return $this
*/
public function setValues(array $values): static
{
$this->_values = $values;

return $this;
}

/** @return list<string> */
public function getValues(): array

Check warning on line 250 in src/Schema/Column.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/Column.php#L250

Added line #L250 was not covered by tests
{
return $this->_values;

Check warning on line 252 in src/Schema/Column.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/Column.php#L252

Added line #L252 was not covered by tests
}

/** @return array<string, mixed> */
public function toArray(): array
{
return array_merge([
'name' => $this->_name,
'type' => $this->_type,
'default' => $this->_default,
'notnull' => $this->_notnull,
'length' => $this->_length,
'precision' => $this->_precision,
'scale' => $this->_scale,
'fixed' => $this->_fixed,
'unsigned' => $this->_unsigned,
'autoincrement' => $this->_autoincrement,
'name' => $this->_name,
'type' => $this->_type,
'default' => $this->_default,
'notnull' => $this->_notnull,
'length' => $this->_length,
'precision' => $this->_precision,
'scale' => $this->_scale,
'fixed' => $this->_fixed,
'unsigned' => $this->_unsigned,
'autoincrement' => $this->_autoincrement,
'columnDefinition' => $this->_columnDefinition,
'comment' => $this->_comment,
'comment' => $this->_comment,
'values' => $this->_values,
], $this->_platformOptions);
}
}
21 changes: 21 additions & 0 deletions src/Schema/MySQLSchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@
use Doctrine\DBAL\Types\Type;

use function array_change_key_case;
use function array_map;
use function assert;
use function explode;
use function implode;
use function is_string;
use function preg_match;
use function preg_match_all;
use function str_contains;
use function strtok;
use function strtolower;
Expand Down Expand Up @@ -134,6 +136,8 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column

$type = $this->platform->getDoctrineTypeMapping($dbType);

$values = [];

Check warning on line 139 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L139

Added line #L139 was not covered by tests

switch ($dbType) {
case 'char':
case 'binary':
Expand Down Expand Up @@ -192,6 +196,10 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column
case 'year':
$length = null;
break;

case 'enum':
$values = $this->parseEnumExpression($tableColumn['type']);
break;

Check warning on line 202 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L200-L202

Added lines #L200 - L202 were not covered by tests
}

if ($this->platform instanceof MariaDBPlatform) {
Expand All @@ -209,6 +217,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column
'scale' => $scale,
'precision' => $precision,
'autoincrement' => str_contains($tableColumn['extra'], 'auto_increment'),
'values' => $values,

Check warning on line 220 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L220

Added line #L220 was not covered by tests
];

if (isset($tableColumn['comment'])) {
Expand All @@ -228,6 +237,18 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column
return $column;
}

/** @return list<string> */
private function parseEnumExpression(string $expression): array

Check warning on line 241 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L241

Added line #L241 was not covered by tests
{
$result = preg_match_all("/'([^']*(?:''[^']*)*)'/", $expression, $matches);
assert($result !== false);

Check warning on line 244 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L243-L244

Added lines #L243 - L244 were not covered by tests

return array_map(
static fn (string $match): string => strtr($match, ["''" => "'"]),
$matches[1],
);

Check warning on line 249 in src/Schema/MySQLSchemaManager.php

View check run for this annotation

Codecov / codecov/patch

src/Schema/MySQLSchemaManager.php#L246-L249

Added lines #L246 - L249 were not covered by tests
}

/**
* Return Doctrine/Mysql-compatible column default values for MariaDB 10.2.7+ servers.
*
Expand Down
18 changes: 18 additions & 0 deletions src/Types/EnumType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Types;

use Doctrine\DBAL\Platforms\AbstractPlatform;

final class EnumType extends Type
{
/**
* {@inheritDoc}
*/
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getEnumDeclarationSQL($column);
}
}
1 change: 1 addition & 0 deletions src/Types/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ abstract class Type
Types::DATETIMETZ_MUTABLE => DateTimeTzType::class,
Types::DATETIMETZ_IMMUTABLE => DateTimeTzImmutableType::class,
Types::DECIMAL => DecimalType::class,
Types::ENUM => EnumType::class,
Types::FLOAT => FloatType::class,
Types::GUID => GuidType::class,
Types::INTEGER => IntegerType::class,
Expand Down
1 change: 1 addition & 0 deletions src/Types/Types.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class Types
public const DATETIMETZ_IMMUTABLE = 'datetimetz_immutable';
public const DECIMAL = 'decimal';
public const FLOAT = 'float';
public const ENUM = 'enum';
public const GUID = 'guid';
public const INTEGER = 'integer';
public const JSON = 'json';
Expand Down
5 changes: 4 additions & 1 deletion tests/Functional/Schema/MySQLSchemaManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,10 @@ public function testColumnIntrospection(): void
$doctrineTypes = array_keys(Type::getTypesMap());

foreach ($doctrineTypes as $type) {
$table->addColumn('col_' . $type, $type, ['length' => 8, 'precision' => 8, 'scale' => 2]);
$table->addColumn('col_' . $type, $type, match ($type) {
Types::ENUM => ['values' => ['foo', 'bar']],
default => ['length' => 8, 'precision' => 8, 'scale' => 2],
});
}

$this->dropAndCreateTable($table);
Expand Down
116 changes: 116 additions & 0 deletions tests/Functional/Types/EnumTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Tests\Functional\Types;

use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Tests\FunctionalTestCase;
use Doctrine\DBAL\Types\EnumType;
use Doctrine\DBAL\Types\Type;
use Doctrine\DBAL\Types\Types;
use PHPUnit\Framework\Attributes\DataProvider;

final class EnumTypeTest extends FunctionalTestCase
{
protected function setUp(): void
{
$this->dropTableIfExists('my_enum_table');
}

public function testIntrospectEnum(): void
{
if (! $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
self::markTestSkipped('This test requires MySQL or MariaDB.');
}

$this->connection->executeStatement(<<< 'SQL'
CREATE TABLE my_enum_table (
id BIGINT NOT NULL PRIMARY KEY,
suit ENUM('hearts', 'diamonds', 'clubs', 'spades') NOT NULL DEFAULT 'hearts'
);
SQL);

$schemaManager = $this->connection->createSchemaManager();
$table = $schemaManager->introspectTable('my_enum_table');

self::assertCount(2, $table->getColumns());
self::assertTrue($table->hasColumn('suit'));
self::assertInstanceOf(EnumType::class, $table->getColumn('suit')->getType());
self::assertSame(['hearts', 'diamonds', 'clubs', 'spades'], $table->getColumn('suit')->getValues());
self::assertSame('hearts', $table->getColumn('suit')->getDefault());
}

public function testDeployEnum(): void
{
$schemaManager = $this->connection->createSchemaManager();
$schema = new Schema(schemaConfig: $schemaManager->createSchemaConfig());
$table = $schema->createTable('my_enum_table');
$table->addColumn('id', Types::BIGINT, ['notnull' => true]);
$table->addColumn('suit', Types::ENUM, [
'values' => ['hearts', 'diamonds', 'clubs', 'spades'],
'notnull' => true,
'default' => 'hearts',
]);
$table->setPrimaryKey(['id']);

$schemaManager->createSchemaObjects($schema);

$introspectedTable = $schemaManager->introspectTable('my_enum_table');

self::assertTrue($schemaManager->createComparator()->compareTables($table, $introspectedTable)->isEmpty());

$this->connection->insert('my_enum_table', ['id' => 1, 'suit' => 'hearts'], ['suit' => Types::ENUM]);
$this->connection->insert(
'my_enum_table',
['id' => 2, 'suit' => 'diamonds'],
['suit' => Type::getType(Types::ENUM)],
);

self::assertEquals(
[[1, 'hearts'], [2, 'diamonds']],
$this->connection->fetchAllNumeric('SELECT id, suit FROM my_enum_table ORDER BY id ASC'),
);
}

/** @param list<string> $expectedValues */
#[DataProvider('provideEnumDefinitions')]
public function testIntrospectEnumValues(string $definition, array $expectedValues): void
{
if (! $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) {
self::markTestSkipped('This test requires MySQL or MariaDB.');
}

$this->connection->executeStatement(<<< SQL
CREATE TABLE my_enum_table (
id BIGINT NOT NULL PRIMARY KEY,
my_enum $definition DEFAULT NULL
);
SQL);

$schemaManager = $this->connection->createSchemaManager();
$table = $schemaManager->introspectTable('my_enum_table');

self::assertInstanceOf(EnumType::class, $table->getColumn('my_enum')->getType());
self::assertSame($expectedValues, $table->getColumn('my_enum')->getValues());
self::assertNull($table->getColumn('my_enum')->getDefault());
}

/** @return iterable<string, array{string, list<string>}> */
public static function provideEnumDefinitions(): iterable
{
yield 'simple' => ['ENUM("a", "b", "c")', ['a', 'b', 'c']];
yield 'empty first' => ['ENUM("", "a", "b", "c")', ['', 'a', 'b', 'c']];
yield 'empty in the middle' => ['ENUM("a", "", "b", "c")', ['a', '', 'b', 'c']];
yield 'empty last' => ['ENUM("a", "b", "c", "")', ['a', 'b', 'c', '']];
yield 'with spaces' => ['ENUM("a b", "c d", "e f")', ['a b', 'c d', 'e f']];
yield 'with quotes' => ['ENUM("a\'b", "c\'d", "e\'f")', ['a\'b', 'c\'d', 'e\'f']];
yield 'with commas' => ['ENUM("a,b", "c,d", "e,f")', ['a,b', 'c,d', 'e,f']];
yield 'with parentheses' => ['ENUM("(a)", "(b)", "(c)")', ['(a)', '(b)', '(c)']];
yield 'with quotes and commas' => ['ENUM("a\'b", "c\'d", "e\'f")', ['a\'b', 'c\'d', 'e\'f']];
yield 'with quotes and parentheses' => ['ENUM("(a)", "(b)", "(c)")', ['(a)', '(b)', '(c)']];
yield 'with commas and parentheses' => ['ENUM("(a,b)", "(c,d)", "(e,f)")', ['(a,b)', '(c,d)', '(e,f)']];
yield 'with quotes, commas and parentheses' => ['ENUM("(a\'b)", "(c\'d)", "(e\'f)")', ['(a\'b)', '(c\'d)', '(e\'f)']];

Check warning on line 114 in tests/Functional/Types/EnumTypeTest.php

View workflow job for this annotation

GitHub Actions / Coding Standards / Coding Standards (PHP: 8.3)

Line exceeds 120 characters; contains 126 characters
}
}
1 change: 1 addition & 0 deletions tests/Schema/ColumnTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public function testToArray(): void
'autoincrement' => false,
'columnDefinition' => null,
'comment' => '',
'values' => [],
'foo' => 'bar',
];

Expand Down

0 comments on commit 0f5cfd2

Please sign in to comment.