diff --git a/.github/workflows/diagnostics.yml b/.github/workflows/diagnostics.yml
index 965020297..7f7bca80e 100644
--- a/.github/workflows/diagnostics.yml
+++ b/.github/workflows/diagnostics.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Pull source
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup PHP with PECL extension
uses: shivammathur/setup-php@v2
@@ -29,7 +29,7 @@ jobs:
# setup caches
- name: Cache composer cache directory
- uses: actions/cache@v4
+ uses: actions/cache@v5
env:
cache-name: composer-cache-dir
with:
@@ -37,7 +37,7 @@ jobs:
key: ${{ runner.os }}-${{ matrix.php }}-build-${{ env.cache-name }}
- name: Cache vendor directory
- uses: actions/cache@v4
+ uses: actions/cache@v5
env:
cache-name: vendor
with:
@@ -47,7 +47,7 @@ jobs:
${{ runner.os }}-${{ matrix.php }}-${{ matrix.contao }}-build-${{ env.cache-name }}-
- name: Cache phpcq directory
- uses: actions/cache@v4
+ uses: actions/cache@v5
env:
cache-name: phpcq
with:
@@ -69,7 +69,7 @@ jobs:
run: ./vendor/bin/phpcq run -v ${{ matrix.output }}
- name: Upload build directory to artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
if: success() || failure()
with:
name: phpcq-builds-php-${{ matrix.php }}-${{ matrix.contao }}
diff --git a/psalm.xml b/psalm.xml
index c38e88615..683d39edc 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -23,7 +23,6 @@
-
@@ -271,7 +270,6 @@
-
diff --git a/runonce/runonce.php b/runonce/runonce.php
deleted file mode 100644
index b7c7a5429..000000000
--- a/runonce/runonce.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
- * @author Christopher Boelter
- * @author Sven Baumann
- * @copyright 2012-2019 The MetaModels team.
- * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
- * @filesource
- */
-
-// Let our handler handle the necessary steps.
-MetaModels\Helper\UpgradeHandler::perform();
diff --git a/src/CoreBundle/Migration/DcaSettingPublishedMigration.php b/src/CoreBundle/Migration/DcaSettingPublishedMigration.php
new file mode 100644
index 000000000..92c6bf34a
--- /dev/null
+++ b/src/CoreBundle/Migration/DcaSettingPublishedMigration.php
@@ -0,0 +1,106 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function in_array;
+
+/**
+ * Adds the "published" column to "tl_metamodel_dcasetting" and sets all existing
+ * rows to published=1 to preserve the prior behaviour (everything was published).
+ *
+ * Introduced: MetaModels 1.0.1.
+ */
+final class DcaSettingPublishedMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Add "published" column to "tl_metamodel_dcasetting".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ if (!$this->tablesExist(['tl_metamodel_dcasetting'])) {
+ return false;
+ }
+
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dcasetting')
+ );
+
+ return !in_array('published', $columnNames, true);
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $this->connection->executeStatement(
+ "ALTER TABLE `tl_metamodel_dcasetting` ADD COLUMN `published` char(1) NOT NULL default ''"
+ );
+ $this->connection->executeStatement(
+ 'UPDATE `tl_metamodel_dcasetting` SET `published`=1'
+ );
+
+ return new MigrationResult(true, 'Added "published" column to "tl_metamodel_dcasetting".');
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Migration/InputScreenFlagMigration.php b/src/CoreBundle/Migration/InputScreenFlagMigration.php
new file mode 100644
index 000000000..9977cc82a
--- /dev/null
+++ b/src/CoreBundle/Migration/InputScreenFlagMigration.php
@@ -0,0 +1,196 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function in_array;
+use function sprintf;
+use function time;
+
+/**
+ * Converts the legacy "flag" column in "tl_metamodel_dca" into proper sort-group
+ * entries in "tl_metamodel_dca_sortgroup" and drops the column afterwards.
+ *
+ * The flag value encodes both the grouping type and sort direction; odd values
+ * sort ascending, even values sort descending.
+ *
+ * Flag mapping:
+ * 1–2 → char grouping, length 1
+ * 3–4 → char grouping, length 2
+ * 5–6 → day grouping
+ * 7–8 → month grouping
+ * 9–10 → year grouping
+ * 11–12 → digit grouping
+ * other → no grouping
+ */
+final class InputScreenFlagMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Migrate "flag" column to sort-group entries in "tl_metamodel_dca_sortgroup".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ if (!$this->tablesExist(['tl_metamodel_dca'])) {
+ return false;
+ }
+
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dca')
+ );
+
+ return in_array('flag', $columnNames, true);
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $this->ensureSortGroupTableExists();
+
+ $dcaRows = $this->connection->fetchAllAssociative('SELECT * FROM `tl_metamodel_dca`');
+ $count = 0;
+ foreach ($dcaRows as $dca) {
+ [$renderGroupType, $renderGroupLen] = $this->resolveGroupType((int) $dca['flag']);
+
+ $this->connection->insert(
+ 'tl_metamodel_dca_sortgroup',
+ [
+ 'pid' => (int) $dca['id'],
+ 'sorting' => 128,
+ 'tstamp' => time(),
+ 'name' => null,
+ 'isdefault' => '1',
+ 'ismanualsort' => '1',
+ 'rendergrouptype' => $renderGroupType,
+ 'rendergrouplen' => $renderGroupLen,
+ 'rendergroupattr' => 0,
+ 'rendersort' => in_array((int) $dca['flag'], [2, 4, 6, 8, 10, 12], true) ? 'desc' : 'asc',
+ 'rendersortattr' => 0,
+ ]
+ );
+ $count++;
+ }
+
+ $this->connection->executeStatement(
+ 'ALTER TABLE `tl_metamodel_dca` DROP COLUMN `flag`'
+ );
+
+ return new MigrationResult(
+ true,
+ sprintf('Created %d sort-group row(s) and dropped "flag" column from "tl_metamodel_dca".', $count)
+ );
+ }
+
+ /**
+ * Returns [renderGroupType, renderGroupLen] for the given flag value.
+ *
+ * @return array{string, int}
+ */
+ private function resolveGroupType(int $flag): array
+ {
+ if (in_array($flag, [1, 2, 3, 4], true)) {
+ return ['char', in_array($flag, [1, 2], true) ? 1 : 2];
+ }
+ if (in_array($flag, [5, 6], true)) {
+ return ['day', 0];
+ }
+ if (in_array($flag, [7, 8], true)) {
+ return ['month', 0];
+ }
+ if (in_array($flag, [9, 10], true)) {
+ return ['year', 0];
+ }
+ if (in_array($flag, [11, 12], true)) {
+ return ['digit', 0];
+ }
+
+ return ['none', 0];
+ }
+
+ private function ensureSortGroupTableExists(): void
+ {
+ if ($this->tablesExist(['tl_metamodel_dca_sortgroup'])) {
+ return;
+ }
+
+ $this->connection->executeStatement(
+ 'CREATE TABLE `tl_metamodel_dca_sortgroup` (
+ `id` int(10) unsigned NOT NULL auto_increment,
+ `pid` int(10) unsigned NOT NULL default \'0\',
+ `sorting` int(10) unsigned NOT NULL default \'0\',
+ `tstamp` int(10) unsigned NOT NULL default \'0\',
+ `name` text NULL,
+ `isdefault` char(1) NOT NULL default \'\',
+ `ismanualsort` char(1) NOT NULL default \'\',
+ `rendergrouptype` varchar(10) NOT NULL default \'none\',
+ `rendergrouplen` int(10) unsigned NOT NULL default \'1\',
+ `rendergroupattr` int(10) unsigned NOT NULL default \'0\',
+ `rendersort` varchar(10) NOT NULL default \'asc\',
+ `rendersortattr` int(10) unsigned NOT NULL default \'0\',
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ $this->existsCache = [];
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Migration/InputScreenModeMigration.php b/src/CoreBundle/Migration/InputScreenModeMigration.php
new file mode 100644
index 000000000..29b770c3d
--- /dev/null
+++ b/src/CoreBundle/Migration/InputScreenModeMigration.php
@@ -0,0 +1,124 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function in_array;
+
+/**
+ * Replaces the legacy numeric "mode" column in "tl_metamodel_dca" with the
+ * string-based "rendermode" column ("flat", "parented", "hierarchical").
+ */
+final class InputScreenModeMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Replace numeric "mode" with string "rendermode" in "tl_metamodel_dca".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ if (!$this->tablesExist(['tl_metamodel_dca'])) {
+ return false;
+ }
+
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dca')
+ );
+
+ return in_array('mode', $columnNames, true);
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dca')
+ );
+
+ if (!in_array('rendermode', $columnNames, true)) {
+ $this->connection->executeStatement(
+ "ALTER TABLE `tl_metamodel_dca` ADD COLUMN `rendermode` varchar(12) NOT NULL default ''"
+ );
+ }
+
+ $this->connection->executeStatement(
+ "UPDATE `tl_metamodel_dca` SET `rendermode`='flat' WHERE `mode` IN (0, 1, 2, 3)"
+ );
+ $this->connection->executeStatement(
+ "UPDATE `tl_metamodel_dca` SET `rendermode`='parented' WHERE `mode` IN (4)"
+ );
+ $this->connection->executeStatement(
+ "UPDATE `tl_metamodel_dca` SET `rendermode`='hierarchical' WHERE `mode` IN (5, 6)"
+ );
+
+ $this->connection->executeStatement(
+ 'ALTER TABLE `tl_metamodel_dca` DROP COLUMN `mode`'
+ );
+
+ return new MigrationResult(
+ true,
+ 'Replaced numeric "mode" with string "rendermode" in "tl_metamodel_dca".'
+ );
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Migration/IsClosedMigration.php b/src/CoreBundle/Migration/IsClosedMigration.php
new file mode 100644
index 000000000..3aec7cd91
--- /dev/null
+++ b/src/CoreBundle/Migration/IsClosedMigration.php
@@ -0,0 +1,122 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function in_array;
+
+/**
+ * Replaces the legacy "isclosed" column in "tl_metamodel_dca" with the three
+ * separate flags "iseditable", "iscreatable", and "isdeleteable", deriving their
+ * values via bitwise XOR (isclosed^1 = the inverse).
+ */
+final class IsClosedMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Replace "isclosed" with "iseditable", "iscreatable", "isdeleteable" in "tl_metamodel_dca".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ if (!$this->tablesExist(['tl_metamodel_dca'])) {
+ return false;
+ }
+
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dca')
+ );
+
+ return in_array('isclosed', $columnNames, true);
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $schemaManager = $this->connection->createSchemaManager();
+ $columnNames = array_keys($schemaManager->listTableColumns('tl_metamodel_dca'));
+
+ foreach (['iseditable', 'iscreatable', 'isdeleteable'] as $col) {
+ if (!in_array($col, $columnNames, true)) {
+ $this->connection->executeStatement(
+ 'ALTER TABLE `tl_metamodel_dca`'
+ . " ADD COLUMN `{$col}` char(1) NOT NULL default ''"
+ );
+ }
+ }
+
+ $this->connection->executeStatement(
+ 'UPDATE `tl_metamodel_dca`
+ SET `iseditable`=`isclosed`^1, `iscreatable`=`isclosed`^1, `isdeleteable`=`isclosed`^1'
+ );
+
+ $this->connection->executeStatement(
+ 'ALTER TABLE `tl_metamodel_dca` DROP COLUMN `isclosed`'
+ );
+
+ return new MigrationResult(
+ true,
+ 'Replaced "isclosed" with "iseditable", "iscreatable", "isdeleteable" in "tl_metamodel_dca".'
+ );
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Migration/JumpToMigration.php b/src/CoreBundle/Migration/JumpToMigration.php
new file mode 100644
index 000000000..6634196d7
--- /dev/null
+++ b/src/CoreBundle/Migration/JumpToMigration.php
@@ -0,0 +1,131 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_key_exists;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function implode;
+use function strtolower;
+
+/**
+ * Adds the "metamodel_jumpTo" column to "tl_content" and "tl_module" and copies
+ * any existing "jumpTo" values over.
+ *
+ * Introduced: MetaModels pre-release 1.0.
+ */
+final class JumpToMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Add "metamodel_jumpTo" column to "tl_content" and "tl_module".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ return !empty($this->findTablesNeedingMigration());
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $messages = [];
+ foreach ($this->findTablesNeedingMigration() as $tableName => $hasJumpTo) {
+ $this->connection->executeStatement(
+ 'ALTER TABLE `' . $tableName . '`'
+ . " ADD COLUMN `metamodel_jumpTo` int(10) unsigned NOT NULL default '0'"
+ );
+ if ($hasJumpTo) {
+ $this->connection->executeStatement(
+ 'UPDATE `' . $tableName . '` SET `metamodel_jumpTo`=`jumpTo`'
+ );
+ }
+ $messages[] = $tableName;
+ }
+
+ return new MigrationResult(true, 'Added "metamodel_jumpTo" to: ' . implode(', ', $messages));
+ }
+
+ /**
+ * Returns a map of table name → whether a "jumpTo" source column exists.
+ *
+ * @return array
+ */
+ private function findTablesNeedingMigration(): array
+ {
+ $result = [];
+ foreach (['tl_content', 'tl_module'] as $tableName) {
+ if (!$this->tablesExist([$tableName])) {
+ continue;
+ }
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns($tableName)
+ );
+ if (\in_array('metamodel_jumpto', $columnNames, true)) {
+ continue;
+ }
+ $result[$tableName] = \in_array('jumpto', $columnNames, true);
+ }
+
+ return $result;
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Migration/SubPalettesToConditionsMigration.php b/src/CoreBundle/Migration/SubPalettesToConditionsMigration.php
new file mode 100644
index 000000000..0908efb52
--- /dev/null
+++ b/src/CoreBundle/Migration/SubPalettesToConditionsMigration.php
@@ -0,0 +1,205 @@
+
+ * @copyright 2012-2026 The MetaModels team.
+ * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
+ * @filesource
+ */
+
+declare(strict_types=1);
+
+namespace MetaModels\CoreBundle\Migration;
+
+use Contao\CoreBundle\Migration\AbstractMigration;
+use Contao\CoreBundle\Migration\MigrationResult;
+use Doctrine\DBAL\Connection;
+
+use function array_intersect;
+use function array_keys;
+use function array_map;
+use function array_values;
+use function count;
+use function in_array;
+use function sprintf;
+use function time;
+
+/**
+ * Converts old sub-palette settings in "tl_metamodel_dcasetting" into proper
+ * property-value conditions in "tl_metamodel_dcasetting_condition" and drops
+ * the legacy "subpalette" column afterwards.
+ */
+final class SubPalettesToConditionsMigration extends AbstractMigration
+{
+ /**
+ * The database connection.
+ *
+ * @var Connection
+ */
+ private Connection $connection;
+
+ /** @var list */
+ private array $existsCache = [];
+
+ /**
+ * Create a new instance.
+ *
+ * @param Connection $connection The database connection.
+ */
+ public function __construct(Connection $connection)
+ {
+ $this->connection = $connection;
+ }
+
+ #[\Override]
+ public function getName(): string
+ {
+ return 'MetaModels: Migrate sub-palettes to input field conditions in "tl_metamodel_dcasetting".';
+ }
+
+ #[\Override]
+ public function shouldRun(): bool
+ {
+ if (!$this->tablesExist(['tl_metamodel_dcasetting'])) {
+ return false;
+ }
+
+ $columnNames = array_keys(
+ $this->connection->createSchemaManager()->listTableColumns('tl_metamodel_dcasetting')
+ );
+
+ return in_array('subpalette', $columnNames, true);
+ }
+
+ #[\Override]
+ public function run(): MigrationResult
+ {
+ $this->ensureConditionTableExists();
+ $count = $this->migrateSubPaletteRows();
+ $this->connection->executeStatement(
+ 'ALTER TABLE `tl_metamodel_dcasetting` DROP COLUMN `subpalette`'
+ );
+
+ return new MigrationResult(
+ true,
+ sprintf(
+ 'Migrated %d sub-palette row(s) to conditions and dropped "subpalette" column.',
+ $count
+ )
+ );
+ }
+
+ private function ensureConditionTableExists(): void
+ {
+ if ($this->tablesExist(['tl_metamodel_dcasetting_condition'])) {
+ return;
+ }
+
+ $this->connection->executeStatement(
+ 'CREATE TABLE `tl_metamodel_dcasetting_condition` (
+ `id` int(10) unsigned NOT NULL auto_increment,
+ `pid` int(10) unsigned NOT NULL default \'0\',
+ `settingId` int(10) unsigned NOT NULL default \'0\',
+ `sorting` int(10) unsigned NOT NULL default \'0\',
+ `tstamp` int(10) unsigned NOT NULL default \'0\',
+ `enabled` char(1) NOT NULL default \'\',
+ `type` varchar(255) NOT NULL default \'\',
+ `attr_id` int(10) unsigned NOT NULL default \'0\',
+ `comment` varchar(255) NOT NULL default \'\',
+ `value` blob NULL,
+ PRIMARY KEY (`id`)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
+ );
+ $this->existsCache = [];
+ }
+
+ private function migrateSubPaletteRows(): int
+ {
+ $subpalettes = $this->connection->fetchAllAssociative(
+ 'SELECT * FROM `tl_metamodel_dcasetting` WHERE `subpalette` != 0'
+ );
+
+ if ([] === $subpalettes) {
+ return 0;
+ }
+
+ // Build attr_id lookup: dcasetting.id → dcasetting.attr_id (for non-subpalette attribute settings).
+ $checkboxRows = $this->connection->fetchAllAssociative(
+ "SELECT `id`, `attr_id` FROM `tl_metamodel_dcasetting` WHERE `subpalette`=0 AND `dcatype`='attribute'"
+ );
+ $checkboxAttrById = [];
+ foreach ($checkboxRows as $row) {
+ $checkboxAttrById[(int) $row['id']] = (int) $row['attr_id'];
+ }
+
+ // Build colName lookup: attr_id → colName.
+ $attributeRows = $this->connection->fetchAllAssociative(
+ 'SELECT `attribute`.`id`, `attribute`.`colName`
+ FROM `tl_metamodel_dcasetting` AS `setting`
+ LEFT JOIN `tl_metamodel_attribute` AS `attribute` ON (`setting`.`attr_id` = `attribute`.`id`)
+ WHERE `setting`.`dcatype` = \'attribute\''
+ );
+ $colNameByAttrId = [];
+ foreach ($attributeRows as $row) {
+ $colNameByAttrId[(int) $row['id']] = $row['colName'];
+ }
+
+ $count = 0;
+ foreach ($subpalettes as $subpalette) {
+ $parentSettingId = (int) $subpalette['subpalette'];
+ $parentAttrId = $checkboxAttrById[$parentSettingId] ?? 0;
+ $parentColName = $colNameByAttrId[$parentAttrId] ?? '';
+
+ $this->connection->insert(
+ 'tl_metamodel_dcasetting_condition',
+ [
+ 'pid' => 0,
+ 'settingId' => (int) $subpalette['id'],
+ 'sorting' => 128,
+ 'tstamp' => time(),
+ 'enabled' => '1',
+ 'type' => 'conditionpropertyvalueis',
+ 'attr_id' => $parentAttrId,
+ 'comment' => sprintf('Only show when checkbox "%s" is checked', $parentColName),
+ 'value' => '1',
+ ]
+ );
+
+ $this->connection->update(
+ 'tl_metamodel_dcasetting',
+ ['subpalette' => 0],
+ ['id' => (int) $subpalette['id']]
+ );
+ $this->connection->update(
+ 'tl_metamodel_dcasetting',
+ ['submitOnChange' => 1],
+ ['id' => $parentSettingId]
+ );
+
+ $count++;
+ }
+
+ return $count;
+ }
+
+ private function tablesExist(array $tableNames): bool
+ {
+ if ([] === $this->existsCache) {
+ $this->existsCache = array_values($this->connection->createSchemaManager()->listTableNames());
+ }
+
+ return count($tableNames) === count(
+ array_intersect($tableNames, array_map('strtolower', $this->existsCache))
+ );
+ }
+}
diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml
index fbc728799..5205668d6 100644
--- a/src/CoreBundle/Resources/config/services.yml
+++ b/src/CoreBundle/Resources/config/services.yml
@@ -296,6 +296,42 @@ services:
tags:
- name: contao.migration
+ MetaModels\CoreBundle\Migration\JumpToMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
+ MetaModels\CoreBundle\Migration\DcaSettingPublishedMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
+ MetaModels\CoreBundle\Migration\SubPalettesToConditionsMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
+ MetaModels\CoreBundle\Migration\IsClosedMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
+ MetaModels\CoreBundle\Migration\InputScreenModeMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
+ MetaModels\CoreBundle\Migration\InputScreenFlagMigration:
+ arguments:
+ $connection: '@database_connection'
+ tags:
+ - name: contao.migration
+
MetaModels\CoreBundle\Formatter\SelectAttributeOptionLabelFormatter:
public: false
diff --git a/src/Helper/UpgradeHandler.php b/src/Helper/UpgradeHandler.php
deleted file mode 100644
index b2a12790c..000000000
--- a/src/Helper/UpgradeHandler.php
+++ /dev/null
@@ -1,394 +0,0 @@
-
- * @author Sven Baumann
- * @author Ingolf Steinhardt
- * @copyright 2012-2023 The MetaModels team.
- * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later
- * @filesource
- */
-
-namespace MetaModels\Helper;
-
-use Contao\Database;
-
-/**
- * Upgrade handler class that changes structural changes in the database.
- * This should rarely be necessary, but sometimes we need it.
- */
-class UpgradeHandler
-{
- /**
- * Retrieve the database instance from Contao.
- *
- * @return Database
- *
- * @SuppressWarnings(PHPMD.ShortMethodName)
- * @SuppressWarnings(PHPMD.CamelCaseMethodName)
- */
- protected static function DB()
- {
- return Database::getInstance();
- }
-
- /**
- * Handle database upgrade for the jumpTo field.
- *
- * Introduced: pre-release 1.0.
- *
- * If the field 'metamodel_jumpTo' does exist in tl_module or tl_content,
- * it will get created and the content from jumpTo will get copied over.
- *
- * @return void
- */
- protected static function upgradeJumpTo()
- {
- $objDB = self::DB();
- if (
- $objDB->tableExists('tl_content', null, true)
- && !$objDB->fieldExists('metamodel_jumpTo', 'tl_content', true)
- ) {
- // Create the column in the database and copy the data over.
- TableManipulation::createColumn(
- 'tl_content',
- 'metamodel_jumpTo',
- 'int(10) unsigned NOT NULL default \'0\''
- );
- if ($objDB->fieldExists('jumpTo', 'tl_content', true)) {
- $objDB->execute('UPDATE tl_content SET metamodel_jumpTo=jumpTo;');
- }
- }
-
- if (
- $objDB->tableExists('tl_module', null, true)
- && !$objDB->fieldExists('metamodel_jumpTo', 'tl_module', true)
- ) {
- // Create the column in the database and copy the data over.
- TableManipulation::createColumn(
- 'tl_module',
- 'metamodel_jumpTo',
- 'int(10) unsigned NOT NULL default \'0\''
- );
- if ($objDB->fieldExists('jumpTo', 'tl_module', true)) {
- $objDB->execute('UPDATE tl_module SET metamodel_jumpTo=jumpTo;');
- }
- }
- }
-
- /**
- * Handle database upgrade for the published field in tl_metamodel_dcasetting.
- *
- * Introduced: version 1.0.1
- *
- * If the field 'published' does not exist in tl_metamodel_dcasetting,
- * it will get created and all rows within that table will get initialized to 1
- * to have the prior behaviour back (everything was being published before then).
- *
- * @return void
- */
- protected static function upgradeDcaSettingsPublished()
- {
- $objDB = self::DB();
- if (
- $objDB->tableExists('tl_metamodel_dcasetting', null, true)
- && !$objDB->fieldExists('published', 'tl_metamodel_dcasetting', true)
- ) {
- // Create the column in the database and copy the data over.
- TableManipulation::createColumn(
- 'tl_metamodel_dcasetting',
- 'published',
- 'char(1) NOT NULL default \'\''
- );
- // Publish everything we had so far.
- $objDB->execute('UPDATE tl_metamodel_dcasetting SET published=1;');
- }
- }
-
- /**
- * Handle database upgrade for changing sub palettes to input field conditions.
- *
- * @return void
- */
- protected static function changeSubPalettesToConditions()
- {
- $objDB = self::DB();
-
- // Create the table.
- if (!$objDB->tableExists('tl_metamodel_dcasetting_condition')) {
- $objDB->execute(
- 'CREATE TABLE `tl_metamodel_dcasetting_condition` (
- `id` int(10) unsigned NOT NULL auto_increment,
- `pid` int(10) unsigned NOT NULL default \'0\',
- `settingId` int(10) unsigned NOT NULL default \'0\',
- `sorting` int(10) unsigned NOT NULL default \'0\',
- `tstamp` int(10) unsigned NOT NULL default \'0\',
- `enabled` char(1) NOT NULL default \'\',
- `type` varchar(255) NOT NULL default \'\',
- `attr_id` int(10) unsigned NOT NULL default \'0\',
- `comment` varchar(255) NOT NULL default \'\',
- `value` blob NULL,
- PRIMARY KEY (`id`)
- )ENGINE=MyISAM DEFAULT CHARSET=utf8;'
- );
- }
-
- if (
- $objDB->tableExists('tl_metamodel_dcasetting', null, true)
- && $objDB->fieldExists('subpalette', 'tl_metamodel_dcasetting', true)
- ) {
- $subpalettes = $objDB->execute('SELECT * FROM tl_metamodel_dcasetting WHERE subpalette!=0');
-
- if ($subpalettes->numRows) {
- // Get all attribute names and setting ids.
- $attributes = $objDB
- ->execute('
- SELECT attr_id, colName
- FROM tl_metamodel_dcasetting AS setting
- LEFT JOIN tl_metamodel_attribute AS attribute
- ON (setting.attr_id=attribute.id)
- WHERE dcatype=\'attribute\'
- ');
-
- $attr = array();
- while ($attributes->next()) {
- /** @psalm-suppress UndefinedMagicPropertyFetch */
- $attr[$attributes->attr_id] = $attributes->colName;
- }
-
- $checkboxes = $objDB->execute('
- SELECT *
- FROM tl_metamodel_dcasetting
- WHERE
- subpalette=0
- AND dcatype=\'attribute\'
- ');
-
- $check = array();
- while ($checkboxes->next()) {
- /** @psalm-suppress UndefinedMagicPropertyFetch */
- $check[$checkboxes->id] = $checkboxes->attr_id;
- }
-
- while ($subpalettes->next()) {
- // Add property value condition for parent property dependency.
- /** @psalm-suppress UndefinedMagicPropertyFetch */
- $data = [
- 'pid' => 0,
- 'settingId' => $subpalettes->id,
- 'sorting' => '128',
- 'tstamp' => \time(),
- 'enabled' => '1',
- 'type' => 'conditionpropertyvalueis',
- 'attr_id' => $check[$subpalettes->subpalette],
- 'comment' => \sprintf(
- 'Only show when checkbox "%s" is checked',
- $attr[$check[$subpalettes->subpalette]]
- ),
- 'value' => '1',
- ];
-
- $objDB
- ->prepare('INSERT INTO tl_metamodel_dcasetting_condition %s')
- ->set($data)
- ->execute();
-
- $objDB
- ->prepare('UPDATE tl_metamodel_dcasetting SET subpalette=0 WHERE id=?')
- ->execute($subpalettes->id);
-
- $objDB
- ->prepare('UPDATE tl_metamodel_dcasetting SET submitOnChange=1 WHERE id=?')
- ->execute($subpalettes->subpalette);
- }
- }
-
- TableManipulation::dropColumn('tl_metamodel_dcasetting', 'subpalette', true);
- }
- }
-
- /**
- * Upgrade the database to change from closed dca to editable, creatable and deletable.
- *
- * @return void
- */
- protected static function upgradeClosed()
- {
- $objDB = self::DB();
-
- // Change isclosed to iseditable, iscreatable and isdeleteable.
- if (
- $objDB->tableExists('tl_metamodel_dca', null, true)
- && !$objDB->fieldExists('iseditable', 'tl_metamodel_dca')
- ) {
- // Create the column in the database and copy the data over.
- TableManipulation::createColumn(
- 'tl_metamodel_dca',
- 'iseditable',
- 'char(1) NOT NULL default \'\''
- );
- TableManipulation::createColumn(
- 'tl_metamodel_dca',
- 'iscreatable',
- 'char(1) NOT NULL default \'\''
- );
- TableManipulation::createColumn(
- 'tl_metamodel_dca',
- 'isdeleteable',
- 'char(1) NOT NULL default \'\''
- );
-
- $objDB->execute('
- UPDATE tl_metamodel_dca
- SET
- iseditable=isclosed^1,
- iscreatable=isclosed^1,
- isdeleteable=isclosed^1
- ');
-
- TableManipulation::dropColumn('tl_metamodel_dca', 'isclosed', true);
- }
- }
-
- /**
- * Upgrade the input screens.
- *
- * @return void
- */
- protected static function upgradeInputScreenMode()
- {
- $objDB = self::DB();
- if (!$objDB->tableExists('tl_metamodel_dca', null, true)) {
- return;
- }
-
- if (!$objDB->fieldExists('mode', 'tl_metamodel_dca')) {
- return;
- }
-
- // Create the fields for grouping and sorting and migrate.
- if (!$objDB->fieldExists('rendermode', 'tl_metamodel_dca')) {
- TableManipulation::createColumn(
- 'tl_metamodel_dca',
- 'rendermode',
- 'varchar(12) NOT NULL default \'\''
- );
- }
-
- $objDB->execute('UPDATE tl_metamodel_dca SET rendermode="flat" WHERE mode IN (0,1,2,3)');
- $objDB->execute('UPDATE tl_metamodel_dca SET rendermode="parented" WHERE mode IN (4)');
- $objDB->execute('UPDATE tl_metamodel_dca SET rendermode="hierarchical" WHERE mode IN (5,6)');
-
- TableManipulation::dropColumn('tl_metamodel_dca', 'mode', true);
- }
-
- /**
- * Upgrade the input screens.
- *
- * @return void
- *
- * @SuppressWarnings(PHPMD.CyclomaticComplexity)
- */
- protected static function upgradeInputScreenFlag()
- {
- $objDB = self::DB();
- if (!$objDB->tableExists('tl_metamodel_dca', null, true)) {
- return;
- }
- if (!$objDB->fieldExists('flag', 'tl_metamodel_dca')) {
- return;
- }
-
- if (!$objDB->tableExists('tl_metamodel_dca_sortgroup', null, true)) {
- $objDB->execute('
- CREATE TABLE `tl_metamodel_dca_sortgroup` (
- `id` int(10) unsigned NOT NULL auto_increment,
- `pid` int(10) unsigned NOT NULL default \'0\',
- `sorting` int(10) unsigned NOT NULL default \'0\',
- `tstamp` int(10) unsigned NOT NULL default \'0\',
- `name` text NULL,
- `isdefault` char(1) NOT NULL default \'\',
- `ismanualsort` char(1) NOT NULL default \'\',
- `rendergrouptype` varchar(10) NOT NULL default \'none\',
- `rendergrouplen` int(10) unsigned NOT NULL default \'1\',
- `rendergroupattr` int(10) unsigned NOT NULL default \'0\',
- `rendersort` varchar(10) NOT NULL default \'asc\',
- `rendersortattr` int(10) unsigned NOT NULL default \'0\',
- PRIMARY KEY (`id`)
- ) ENGINE=MyISAM DEFAULT CHARSET=utf8;
- ');
- }
-
- $dca = $objDB->execute('SELECT * FROM tl_metamodel_dca');
-
- while ($dca->next()) {
- $renderGroupLen = 0;
- /** @psalm-suppress UndefinedMagicPropertyFetch */
- if (\in_array($dca->flag, [1, 2, 3, 4])) {
- $renderGroupType = 'char';
- if (\in_array($dca->flag, [1, 2])) {
- $renderGroupLen = 1;
- } else {
- $renderGroupLen = 2;
- }
- } elseif (\in_array($dca->flag, [5, 6])) {
- $renderGroupType = 'day';
- } elseif (\in_array($dca->flag, [7, 8])) {
- $renderGroupType = 'month';
- } elseif (\in_array($dca->flag, [9, 10])) {
- $renderGroupType = 'year';
- } elseif (\in_array($dca->flag, [11, 12])) {
- $renderGroupType = 'digit';
- } else {
- $renderGroupType = 'none';
- }
-
- /** @psalm-suppress UndefinedMagicPropertyFetch */
- $data = [
- 'pid' => $dca->id,
- 'sorting' => 128,
- 'tstamp' => time(),
- 'name' => null,
- 'isdefault' => '1',
- 'ismanualsort' => '1',
- 'rendergrouptype' => $renderGroupType,
- 'rendergrouplen' => $renderGroupLen,
- 'rendergroupattr' => 0,
- 'rendersort' => \in_array($dca->flag, [2, 4, 6, 8, 10, 12]) ? 'desc' : 'asc',
- 'rendersortattr' => 0,
- ];
-
- $objDB
- ->prepare('INSERT INTO tl_metamodel_dca_sortgroup %s')
- ->set($data)
- ->execute();
- }
-
- TableManipulation::dropColumn('tl_metamodel_dca', 'flag', true);
- }
-
- /**
- * Perform all upgrade steps.
- *
- * @return void
- */
- public static function perform()
- {
- self::upgradeJumpTo();
- self::upgradeDcaSettingsPublished();
- self::changeSubPalettesToConditions();
- self::upgradeClosed();
- self::upgradeInputScreenMode();
- self::upgradeInputScreenFlag();
- }
-}