Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,26 @@ private function buildCreateTableSQL(Table $table, bool $createForeignKeys): arr
}
}

if ($createForeignKeys && $table->hasOption(Table::OPTION_EXTRA_CREATE_SQL)) {
Copy link
Member

@morozov morozov Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should be adding new options to the Table class. Options is an untyped associative array whose value types need to be validated at runtime. Using options has been already deprecated for some schema objects like indexes.

And as for the feature itself, Table is a value object that represents the table schema. The SQL used to create the table is defined by the platform, not Table. If we allow that, I can imagine all sorts of issues with schema introspection, migrations and so on.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I fully get the concern but anyway: would you like to suggest me an alternative that'd address your requirements and that'd enable the target I'm trying to achieve here?

Copy link
Member

@morozov morozov Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, sorry. I don't know how exactly the schema management capabilities from DBAL are integrated into Symfony and what the use case is.

Copy link
Member Author

@nicolas-grekas nicolas-grekas Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Symfony, this is used to generate migration files that contain extra statements, in our case a trigger and a function on PgSQL, to leverage pg_notify. See symfony/symfony#62820 for how this PR would be used. That's quite simple and effective plumbing.

Copy link
Member

@morozov morozov Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s also a footgun similar to custom column DDL where people use it for various workarounds and then get surprised why their schema migrations don’t work as expected.

In this specific case, it doesn’t look like you need the DBAL to execute these platform-specific and application-specific statements. You can execute them in Symfony before or after the migration.

Copy link
Member Author

@nicolas-grekas nicolas-grekas Dec 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow your lead here. Symfony isn't really involved here, this is all doctrine/* code, with a lot of hardcoded stuff. About the risk either: I don't see how preventing ppl from achieving what they need by closing everything makes a better platform.

You can execute them in Symfony before or after the migration

What we need to achieve is generating migration files with extra statements that people can then inspect/commit. How does hooking before/after the migration come into play in your mind?

if (! is_array($table->getOption(Table::OPTION_EXTRA_CREATE_SQL))) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array',
Table::OPTION_EXTRA_CREATE_SQL,
));
}

foreach ($table->getOption(Table::OPTION_EXTRA_CREATE_SQL) as $extraSql) {
if (! is_string($extraSql)) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array of strings',
Table::OPTION_EXTRA_CREATE_SQL,
));
}

$sql[] = $extraSql;
}
}

return $sql;
}

Expand All @@ -971,6 +991,28 @@ public function getCreateTablesSQL(array $tables): array
$table->getQuotedName($this),
);
}

if (! $table->hasOption(Table::OPTION_EXTRA_CREATE_SQL)) {
continue;
}

if (! is_array($table->getOption(Table::OPTION_EXTRA_CREATE_SQL))) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array',
Table::OPTION_EXTRA_CREATE_SQL,
));
}

foreach ($table->getOption(Table::OPTION_EXTRA_CREATE_SQL) as $extraSql) {
if (! is_string($extraSql)) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array of strings',
Table::OPTION_EXTRA_CREATE_SQL,
));
}

$sql[] = $extraSql;
}
}

return $sql;
Expand All @@ -986,6 +1028,26 @@ public function getDropTablesSQL(array $tables): array
$sql = [];

foreach ($tables as $table) {
if ($table->hasOption(Table::OPTION_EXTRA_DROP_SQL)) {
if (! is_array($table->getOption(Table::OPTION_EXTRA_DROP_SQL))) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array',
Table::OPTION_EXTRA_DROP_SQL,
));
}

foreach ($table->getOption(Table::OPTION_EXTRA_DROP_SQL) as $extraSql) {
if (! is_string($extraSql)) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array of strings',
Table::OPTION_EXTRA_DROP_SQL,
));
}

$sql[] = $extraSql;
}
}

foreach ($table->getForeignKeys() as $foreignKey) {
$sql[] = $this->getDropForeignKeySQL(
$foreignKey->getQuotedName($this),
Expand Down
26 changes: 26 additions & 0 deletions src/Platforms/SQLitePlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
use function count;
use function explode;
use function implode;
use function is_array;
use function is_string;
use function sprintf;
use function str_replace;
use function strpos;
Expand Down Expand Up @@ -593,6 +595,30 @@ public function getDropTablesSQL(array $tables): array
{
$sql = [];

foreach ($tables as $table) {
if (! $table->hasOption(Table::OPTION_EXTRA_DROP_SQL)) {
continue;
}

if (! is_array($table->getOption(Table::OPTION_EXTRA_DROP_SQL))) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array',
Table::OPTION_EXTRA_DROP_SQL,
));
}

foreach ($table->getOption(Table::OPTION_EXTRA_DROP_SQL) as $extraSql) {
if (! is_string($extraSql)) {
throw new InvalidArgumentException(sprintf(
'Table option "%s" must be an array of strings',
Table::OPTION_EXTRA_DROP_SQL,
));
}

$sql[] = $extraSql;
}
}

foreach ($tables as $table) {
$sql[] = $this->getDropTableSQL($table->getQuotedName($this));
}
Expand Down
3 changes: 3 additions & 0 deletions src/Schema/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
*/
class Table extends AbstractNamedObject
{
public const OPTION_EXTRA_CREATE_SQL = 'extra_create_sql';
public const OPTION_EXTRA_DROP_SQL = 'extra_drop_sql';

/** @var Column[] */
protected array $_columns = [];

Expand Down
205 changes: 205 additions & 0 deletions tests/Platforms/AbstractPlatformTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -1279,4 +1279,209 @@ public static function getEnumDeclarationSQLWithInvalidValuesProvider(): array
"field 'values' is an empty array" => [['values' => []]],
];
}

public function testGetCreateTableSQLWithExtraCreateSQL(): void
{
$table = Table::editor()
->setUnquotedName('test')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->setAutoincrement(true)
->create(),
)
->setPrimaryKeyConstraint(
PrimaryKeyConstraint::editor()
->setUnquotedColumnNames('id')
->create(),
)
->setOptions([
Table::OPTION_EXTRA_CREATE_SQL => [
'CREATE INDEX idx_test ON test (id)',
'CREATE VIEW test_view AS SELECT * FROM test',
],
])
->create();

$sql = $this->platform->getCreateTableSQL($table);

// Should contain the CREATE TABLE statement
self::assertNotEmpty($sql);
self::assertStringContainsString('CREATE TABLE', $sql[0]);
// Should contain the extra SQL statements
self::assertContains('CREATE INDEX idx_test ON test (id)', $sql);
self::assertContains('CREATE VIEW test_view AS SELECT * FROM test', $sql);
}

public function testGetCreateTableSQLWithExtraCreateSQLNonArrayThrowsException(): void
{
$table = Table::editor()
->setUnquotedName('test')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->create(),
)
->setPrimaryKeyConstraint(
PrimaryKeyConstraint::editor()
->setUnquotedColumnNames('id')
->create(),
)
->setOptions([Table::OPTION_EXTRA_CREATE_SQL => 'not an array'])
->create();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Table option "%s" must be an array', Table::OPTION_EXTRA_CREATE_SQL));

$this->platform->getCreateTableSQL($table);
}

public function testGetCreateTablesSQLWithExtraCreateSQL(): void
{
$table1 = Table::editor()
->setUnquotedName('test1')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->setAutoincrement(true)
->create(),
)
->setPrimaryKeyConstraint(
PrimaryKeyConstraint::editor()
->setUnquotedColumnNames('id')
->create(),
)
->setOptions([Table::OPTION_EXTRA_CREATE_SQL => ['CREATE INDEX idx_test1 ON test1 (id)']])
->create();

$table2 = Table::editor()
->setUnquotedName('test2')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->setAutoincrement(true)
->create(),
)
->setPrimaryKeyConstraint(
PrimaryKeyConstraint::editor()
->setUnquotedColumnNames('id')
->create(),
)
->setOptions([Table::OPTION_EXTRA_CREATE_SQL => ['CREATE INDEX idx_test2 ON test2 (id)']])
->create();

$sql = $this->platform->getCreateTablesSQL([$table1, $table2]);

self::assertContains('CREATE INDEX idx_test1 ON test1 (id)', $sql);
self::assertContains('CREATE INDEX idx_test2 ON test2 (id)', $sql);
}

public function testGetCreateTablesSQLWithExtraCreateSQLNonArrayThrowsException(): void
{
$table = Table::editor()
->setUnquotedName('test')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->create(),
)
->setPrimaryKeyConstraint(
PrimaryKeyConstraint::editor()
->setUnquotedColumnNames('id')
->create(),
)
->setOptions([Table::OPTION_EXTRA_CREATE_SQL => 'not an array'])
->create();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Table option "%s" must be an array', Table::OPTION_EXTRA_CREATE_SQL));

$this->platform->getCreateTablesSQL([$table]);
}

public function testGetDropTablesSQLWithExtraDropSQL(): void
{
$table1 = Table::editor()
->setUnquotedName('test1')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->create(),
)
->setOptions([
Table::OPTION_EXTRA_DROP_SQL => [
'DROP VIEW IF EXISTS test1_view',
'DROP TRIGGER IF EXISTS test1_trigger',
],
])
->create();

$table2 = Table::editor()
->setUnquotedName('test2')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->create(),
)
->setOptions([Table::OPTION_EXTRA_DROP_SQL => ['DROP VIEW IF EXISTS test2_view']])
->create();

$sql = $this->platform->getDropTablesSQL([$table1, $table2]);

// Verify extra drop SQL is present
self::assertContains('DROP VIEW IF EXISTS test1_view', $sql);
self::assertContains('DROP TRIGGER IF EXISTS test1_trigger', $sql);
self::assertContains('DROP VIEW IF EXISTS test2_view', $sql);

// Verify DROP TABLE statements are present
$dropTable1Sql = 'DROP TABLE ' . $table1->getQuotedName($this->platform);
$dropTable2Sql = 'DROP TABLE ' . $table2->getQuotedName($this->platform);
self::assertContains($dropTable1Sql, $sql);
self::assertContains($dropTable2Sql, $sql);

// Verify all expected SQL statements are present
// The implementation guarantees extra drop SQL comes before DROP TABLE statements
$hasDropTable1 = false;
$hasDropTable2 = false;
foreach ($sql as $statement) {
if ($statement === $dropTable1Sql) {
$hasDropTable1 = true;
}

if ($statement !== $dropTable2Sql) {
continue;
}

$hasDropTable2 = true;
}

self::assertTrue($hasDropTable1);
self::assertTrue($hasDropTable2);
}

public function testGetDropTablesSQLWithExtraDropSQLNonArrayThrowsException(): void
{
$table = Table::editor()
->setUnquotedName('test')
->setColumns(
Column::editor()
->setUnquotedName('id')
->setTypeName(Types::INTEGER)
->create(),
)
->setOptions([Table::OPTION_EXTRA_DROP_SQL => 'not an array'])
->create();

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage(sprintf('Table option "%s" must be an array', Table::OPTION_EXTRA_DROP_SQL));

$this->platform->getDropTablesSQL([$table]);
}
}