From 85c51fcabdc24159b1e03a696d54ab3d7ec9daf1 Mon Sep 17 00:00:00 2001 From: Ingolf Steinhardt Date: Tue, 1 Sep 2026 12:16:13 +0200 Subject: [PATCH] Enable DC_General versioning for render settings, input screens, filter settings and MetaModel items Turns on "enableVersioning" for tl_metamodel_dcasetting, tl_metamodel_rendersettings, tl_metamodel_filtersetting and the tl_metamodel_item base DCA every mm_* table inherits from - now reachable thanks to the accompanying dc-general fix. Implements item-level versioning in MetaModels\DcGeneral\Data\Driver (saveVersion/getVersion/getVersions/setVersionActive/getActiveVersion), property-based rather than a raw row copy: it reuses the same Model::getProperty()/setProperty() path a normal edit already takes (through each attribute's valueToWidget()/widgetToValue()), so complex attributes (tags, table fields, ...) are covered without any attribute-specific code - see .claude/dcg-versionierung.md. DataProviderBuilder::build() also hard-coded setVersioningEnabled(false) for MetaModels items specifically, mirroring the dc-general bug this change relies on being fixed. And Driver::sameModels() had an unrelated, pre-existing bug of its own: it compared both items' shared MetaModel instance instead of their values, which is always true and short-circuited the real per-attribute comparison below it - every save looked "unchanged" to storeVersion() after the first version, so no further versions were ever recorded. --- .../DefinitionBuilder/DataProviderBuilder.php | 6 +- .../contao/dca/tl_metamodel_dcasetting.php | 2 +- .../contao/dca/tl_metamodel_filtersetting.php | 2 +- .../contao/dca/tl_metamodel_item.php | 2 +- .../dca/tl_metamodel_rendersettings.php | 2 +- src/DcGeneral/Data/Driver.php | 164 +++++++++++++++--- 6 files changed, 150 insertions(+), 28 deletions(-) diff --git a/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php b/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php index fa2d43846..1b19c5ba2 100644 --- a/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php +++ b/src/CoreBundle/EventListener/DcGeneral/DefinitionBuilder/DataProviderBuilder.php @@ -69,6 +69,8 @@ public function __construct(ViewCombination $viewCombination, IFactory $factory) * @param IMetaModelDataDefinition $container The data container. * * @return void + * + * @SuppressWarnings(PHPMD.Superglobals) */ #[\Override] protected function build(IMetaModelDataDefinition $container) @@ -95,7 +97,9 @@ protected function build(IMetaModelDataDefinition $container) ->setTableName($container->getName()) ->setClassName(Driver::class) ->setInitializationData(['source' => $container->getName()]) - ->setVersioningEnabled(false); + ->setVersioningEnabled( + (bool) ($GLOBALS['TL_DCA'][$container->getName()]['config']['enableVersioning'] ?? false) + ); $basicDefinition->setDataProvider($container->getName()); } diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel_dcasetting.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel_dcasetting.php index fb2734023..443ef9e32 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel_dcasetting.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel_dcasetting.php @@ -33,7 +33,7 @@ 'config' => [ 'dataContainer' => General::class, 'switchToEdit' => true, - 'enableVersioning' => false, + 'enableVersioning' => true, 'sql' => [ 'keys' => [ 'id' => 'primary', diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php index bab9008dc..2265f0986 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php @@ -33,7 +33,7 @@ 'label' => 'list_label.label', 'description' => 'list_label.description', 'switchToEdit' => false, - 'enableVersioning' => false, + 'enableVersioning' => true, 'sql' => [ 'keys' => [ 'id' => 'primary', diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel_item.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel_item.php index 06a488813..53d5a9c36 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel_item.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel_item.php @@ -33,7 +33,7 @@ 'config' => [ 'dataContainer' => General::class, 'switchToEdit' => false, - 'enableVersioning' => false, + 'enableVersioning' => true, ], 'dca_config' => [ 'data_provider' => [ diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel_rendersettings.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel_rendersettings.php index 52f09dcda..cc272237f 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel_rendersettings.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel_rendersettings.php @@ -34,7 +34,7 @@ 'dataContainer' => General::class, 'ptable' => 'tl_metamodel', 'switchToEdit' => false, - 'enableVersioning' => false, + 'enableVersioning' => true, 'sql' => [ 'keys' => [ 'id' => 'primary', diff --git a/src/DcGeneral/Data/Driver.php b/src/DcGeneral/Data/Driver.php index cd50dc1f1..46069b771 100644 --- a/src/DcGeneral/Data/Driver.php +++ b/src/DcGeneral/Data/Driver.php @@ -38,7 +38,9 @@ use ContaoCommunityAlliance\DcGeneral\Data\FilterOptionCollectionInterface; use ContaoCommunityAlliance\DcGeneral\Data\ModelInterface; use ContaoCommunityAlliance\DcGeneral\Data\MultiLanguageDataProviderInterface; +use ContaoCommunityAlliance\DcGeneral\Data\VersionModel; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; use MetaModels\Attribute\IAttribute; use MetaModels\Attribute\IComplex; use MetaModels\Attribute\ITranslated; @@ -159,19 +161,49 @@ public function delete($item) /** * Save a new Version of a record. * + * Stores the model's properties the same way a normal edit would arrive at them - through + * {@see Model::getProperty()}, which already runs every attribute's valueToWidget() - so + * restoring later can go back through setProperty()/widgetToValue() and reuse the very same + * save path a regular edit takes. This covers complex attributes (tags, table fields, ...) + * without any attribute-specific code, at the cost of only covering the currently active + * language for translated attributes - see ".claude/dcg-versionierung.md". + * * @param ModelInterface $model The model to be saved. * @param string $username The username that creates the new version. * * @return void * - * @throws \RuntimeException As this is currently unimplemented, an Exception is thrown. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws Exception When the database can not be queried. */ #[\Override] public function saveVersion(ModelInterface $model, $username) { - throw new \RuntimeException('Versioning not supported in MetaModels so far.'); + assert($this->connection instanceof Connection); + $fromTable = $this->getMetaModel()->getTableName(); + + $count = (int) $this->connection + ->createQueryBuilder() + ->select('COUNT(*) AS count') + ->from('tl_version') + ->andWhere('pid = :pid') + ->andWhere('fromTable = :fromTable') + ->setParameter('pid', $model->getId()) + ->setParameter('fromTable', $fromTable) + ->executeQuery() + ->fetchOne(); + + $newVersion = $count + 1; + + $this->connection->insert('tl_version', [ + 'pid' => $model->getId(), + 'tstamp' => \time(), + 'version' => $newVersion, + 'fromTable' => $fromTable, + 'username' => $username, + 'data' => \serialize($model->getPropertiesAsArray()), + ]); + + $this->setVersionActive($model->getId(), $newVersion); } /** @@ -180,16 +212,48 @@ public function saveVersion(ModelInterface $model, $username) * @param mixed $mixID The ID of record. * @param mixed $mixVersion The ID of the version. * - * @return never-return - * - * @throws \RuntimeException As this is currently unimplemented, an Exception is thrown. + * @return ModelInterface|null * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws Exception When the database can not be queried. */ #[\Override] public function getVersion($mixID, $mixVersion) { - throw new \RuntimeException('Versioning not supported in MetaModels so far.'); + assert($this->connection instanceof Connection); + + $row = $this->connection + ->createQueryBuilder() + ->select('data') + ->from('tl_version') + ->andWhere('pid = :pid') + ->andWhere('version = :version') + ->andWhere('fromTable = :fromTable') + ->setParameter('pid', $mixID) + ->setParameter('version', $mixVersion) + ->setParameter('fromTable', $this->getMetaModel()->getTableName()) + ->executeQuery() + ->fetchAssociative(); + + if (false === $row) { + return null; + } + + $data = \unserialize((string) $row['data']); + if (!\is_array($data)) { + return null; + } + + $model = $this->getEmptyModel(); + $model->setId($mixID); + foreach ($data as $propertyName => $value) { + if ('id' === $propertyName) { + continue; + } + + $model->setProperty((string) $propertyName, $value); + } + + return $model; } /** @@ -200,14 +264,23 @@ public function getVersion($mixID, $mixVersion) * * @return void * - * @throws \RuntimeException As this is currently unimplemented, an Exception is thrown. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws Exception When the database can not be queried. */ #[\Override] public function setVersionActive($mixID, $mixVersion) { - throw new \RuntimeException('Versioning not supported in MetaModels so far.'); + assert($this->connection instanceof Connection); + $fromTable = $this->getMetaModel()->getTableName(); + $updateValues = ['pid' => $mixID, 'fromTable' => $fromTable]; + + // "active" is a strict tinyint(1), not the char(1) flag most Contao/MetaModels tables use - + // an empty string fails under strict SQL mode. + $this->connection->update('tl_version', ['active' => 0], $updateValues); + $this->connection->update( + 'tl_version', + ['active' => 1], + ['pid' => $mixID, 'fromTable' => $fromTable, 'version' => $mixVersion] + ); } /** @@ -217,14 +290,27 @@ public function setVersionActive($mixID, $mixVersion) * * @return mixed * - * @throws \RuntimeException As this is currently unimplemented, an Exception is thrown. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws Exception When the database can not be queried. */ #[\Override] public function getActiveVersion($mixID) { - throw new \RuntimeException('Versioning not supported in MetaModels so far.'); + assert($this->connection instanceof Connection); + + $version = $this->connection + ->createQueryBuilder() + ->select('version') + ->from('tl_version') + ->andWhere('pid = :pid') + ->andWhere('fromTable = :fromTable') + ->andWhere('active = :active') + ->setParameter('pid', $mixID) + ->setParameter('fromTable', $this->getMetaModel()->getTableName()) + ->setParameter('active', '1') + ->executeQuery() + ->fetchOne(); + + return false === $version ? null : $version; } /** @@ -571,13 +657,48 @@ public function getCount(ConfigInterface $config) * * @return CollectionInterface * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @throws Exception When the database can not be queried. */ #[\Override] public function getVersions($mixID, $onlyActive = false) { - // No version support on MetaModels so far, sorry. - return new DefaultCollection(); + assert($this->connection instanceof Connection); + + $queryBuilder = $this->connection + ->createQueryBuilder() + ->select('tstamp', 'version', 'username', 'active') + ->from('tl_version') + ->andWhere('pid = :pid') + ->andWhere('fromTable = :fromTable') + ->setParameter('pid', $mixID) + ->setParameter('fromTable', $this->getMetaModel()->getTableName()); + + if ($onlyActive) { + $queryBuilder->andWhere('active = :active')->setParameter('active', '1'); + } else { + $queryBuilder->orderBy('version', 'DESC'); + } + + // A version-list row is metadata about a version (tstamp/version/username/active), not a + // MetaModels item - VersionModel (a plain property bag) fits, the full Item/attribute + // machinery getEmptyModel() would pull in does not and does not implement + // VersionModelInterface, which the edit mask's version drop-down requires. + $collection = $this->getEmptyCollection(); + foreach ($queryBuilder->executeQuery()->fetchAllAssociative() as $row) { + $model = new VersionModel(); + $model->setProviderName($this->getMetaModel()->getTableName()); + foreach ($row as $propertyName => $value) { + $model->setProperty($propertyName, $value); + } + + // The template submits this as "version" to restore - the item id stays with $mixID and + // is not what identifies a single entry in this list, the version number does. + $model->setIdRaw($row['version']); + + $collection->push($model); + } + + return $collection; } /** @@ -761,9 +882,6 @@ public function sameModels($firstModel, $secondModel) assert($objNative1 instanceof IItem); $objNative2 = $secondModel->getItem(); assert($objNative2 instanceof IItem); - if ($objNative1->getMetaModel() === $objNative2->getMetaModel()) { - return true; - } foreach ($objNative1->getMetaModel()->getAttributes() as $objAttribute) { if ($objNative1->get($objAttribute->getColName()) !== $objNative2->get($objAttribute->getColName())) { return false;